Add OIDC desktop callback, app logo, rename to Oikos
- Server: /oidc-callback HTML page exchanges Authentik code for token, displays it for user to copy into the desktop app's Token tab - oidc.ts: desktop mode uses apiUrl+/oidc-callback as redirect URI, encodes PKCE verifier in state parameter - Config.svelte: add Server URL field to OIDC tab for desktop UX - Caddy: add /oidc-callback to enroll bypass (no Authentik gate) - App: favicon.png as system tray icon, window title 'Oikos' - web/index.html: title 'Oikos'
This commit is contained in:
BIN
cmd/desktop/icon.png
Normal file
BIN
cmd/desktop/icon.png
Normal file
Binary file not shown.
@@ -23,6 +23,9 @@ import (
|
|||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
//go:embed icon.png
|
||||||
|
var iconPNG []byte
|
||||||
|
|
||||||
const (
|
const (
|
||||||
keyringService = "com.hubris.oikos-desktop"
|
keyringService = "com.hubris.oikos-desktop"
|
||||||
keyringUser = "oikos"
|
keyringUser = "oikos"
|
||||||
@@ -322,10 +325,12 @@ func main() {
|
|||||||
// --- System tray ---
|
// --- System tray ---
|
||||||
systemTray := app.SystemTray.New()
|
systemTray := app.SystemTray.New()
|
||||||
systemTray.SetLabel("Oikos")
|
systemTray.SetLabel("Oikos")
|
||||||
systemTray.SetTooltip("Oikos — Control Room")
|
systemTray.SetTooltip("Oikos")
|
||||||
|
systemTray.SetIcon(iconPNG)
|
||||||
|
systemTray.SetTemplateIcon(iconPNG)
|
||||||
|
|
||||||
trayMenu := application.NewMenu()
|
trayMenu := application.NewMenu()
|
||||||
trayMenu.Add("Open Control Room").OnClick(func(ctx *application.Context) {
|
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
|
||||||
for _, w := range app.Window.GetAll() {
|
for _, w := range app.Window.GetAll() {
|
||||||
w.Show()
|
w.Show()
|
||||||
w.Focus()
|
w.Focus()
|
||||||
@@ -347,7 +352,7 @@ func main() {
|
|||||||
minWidth, minHeight := 1024, 700
|
minWidth, minHeight := 1024, 700
|
||||||
|
|
||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: "Oikos — Control Room",
|
Title: "Oikos",
|
||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
MinWidth: minWidth,
|
MinWidth: minWidth,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ oikos.hubris.network {
|
|||||||
tls {
|
tls {
|
||||||
dns ionos {env.IONOS_AUTH_API_TOKEN}
|
dns ionos {env.IONOS_AUTH_API_TOKEN}
|
||||||
}
|
}
|
||||||
@enroll path /api/v1/clients/enroll
|
@enroll path /api/v1/clients/enroll /oidc-callback
|
||||||
handle @enroll {
|
handle @enroll {
|
||||||
reverse_proxy 192.168.178.182:8090
|
reverse_proxy 192.168.178.182:8090
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
s.serveOIDCToken(w, req, cfg)
|
s.serveOIDCToken(w, req, cfg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Desktop OIDC callback — standalone HTML page that exchanges the
|
||||||
|
// authorization code for tokens and displays the access token to copy
|
||||||
|
// into the desktop app's Config screen.
|
||||||
|
r.Get("/oidc-callback", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
s.serveOIDCCallback(w, req, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
||||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||||
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
||||||
@@ -657,6 +664,148 @@ func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg co
|
|||||||
w.Write(respBody)
|
w.Write(respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveOIDCCallback serves a standalone HTML page that completes the
|
||||||
|
// desktop OIDC login flow. Authentik redirects here with ?code=...&state=...
|
||||||
|
// after the user authorizes. The state carries the PKCE verifier
|
||||||
|
// (base64url-encoded, joined with "."). The page exchanges the code for
|
||||||
|
// tokens via the token proxy, then displays the access token for the user
|
||||||
|
// to copy into the desktop app.
|
||||||
|
func (s *Server) serveOIDCCallback(w http.ResponseWriter, req *http.Request, cfg config.Config) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprint(w, `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Oikos — Connect Desktop App</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0a0a0a; color: #e0e0e0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
min-height: 100vh; padding: 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #1a1a1a; border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px; padding: 32px; max-width: 480px; width: 100%;
|
||||||
|
}
|
||||||
|
h1 { font-size: 20px; margin-bottom: 8px; }
|
||||||
|
p { font-size: 14px; color: #888; margin-bottom: 20px; }
|
||||||
|
.spinner { margin: 24px auto; width: 32px; height: 32px; border: 3px solid #2a2a2a; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.token-box {
|
||||||
|
background: #111; border: 1px solid #2a2a2a; border-radius: 8px;
|
||||||
|
padding: 16px; font-family: monospace; font-size: 13px;
|
||||||
|
word-break: break-all; margin-bottom: 16px; position: relative;
|
||||||
|
max-height: 160px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: block; width: 100%; padding: 12px; border: none; border-radius: 8px;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer; text-align: center;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #3b82f6; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #2563eb; }
|
||||||
|
.btn-secondary { background: #1a1a1a; color: #e0e0e0; border: 1px solid #2a2a2a; margin-top: 8px; }
|
||||||
|
.btn-secondary:hover { background: #222; }
|
||||||
|
.success { color: #22c55e; margin-bottom: 8px; font-weight: 600; }
|
||||||
|
.error { color: #ef4444; margin-bottom: 12px; }
|
||||||
|
.copied { color: #22c55e; font-size: 13px; text-align: center; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Connect Desktop App</h1>
|
||||||
|
<div id="loading">
|
||||||
|
<p>Exchanging authorization code...</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<div id="result" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function main() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
const state = params.get('state');
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
showError('Missing code or state parameter from Authentik redirect.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = state.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
showError('Invalid state format.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [csrf, verifier] = parts;
|
||||||
|
|
||||||
|
const redirectURI = location.origin + '/oidc-callback';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/v1/auth/oidc-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: redirectURI
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||||
|
showError(err.error || err.message || 'Token exchange failed (' + resp.status + ')');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await resp.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
showError('No access token in response.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
const result = document.getElementById('result');
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.innerHTML = '<div class="success">Authentication successful</div>' +
|
||||||
|
'<p style="margin-bottom:8px">Copy this token into the Oikos desktop app Token tab:</p>' +
|
||||||
|
'<div class="token-box" id="token">' + escapeHtml(tokens.access_token) + '</div>' +
|
||||||
|
'<button class="btn btn-primary" id="copyBtn">Copy Token</button>' +
|
||||||
|
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>' +
|
||||||
|
'<div class="copied" id="copied" style="display:none">Copied!</div>';
|
||||||
|
|
||||||
|
document.getElementById('copyBtn').addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(tokens.access_token).then(() => {
|
||||||
|
const el = document.getElementById('copied');
|
||||||
|
el.style.display = 'block';
|
||||||
|
setTimeout(() => el.style.display = 'none', 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
showError('Network error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
const result = document.getElementById('result');
|
||||||
|
result.style.display = 'block';
|
||||||
|
result.innerHTML = '<div class="error">' + escapeHtml(msg) + '</div>' +
|
||||||
|
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
|
||||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Oikos — Control Room</title>
|
<title>Oikos</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiBase } from './config'
|
import { apiBase, getConfig } from './config'
|
||||||
|
|
||||||
interface OIDCConfig {
|
interface OIDCConfig {
|
||||||
issuer: string
|
issuer: string
|
||||||
@@ -77,7 +77,19 @@ export async function startLogin(): Promise<void> {
|
|||||||
sessionStorage.setItem(PKCE_KEY, codeVerifier)
|
sessionStorage.setItem(PKCE_KEY, codeVerifier)
|
||||||
sessionStorage.setItem(STATE_KEY, oidcState)
|
sessionStorage.setItem(STATE_KEY, oidcState)
|
||||||
|
|
||||||
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
const isDesktop = getConfig().isDesktop === true
|
||||||
|
|
||||||
|
let redirectURI: string
|
||||||
|
let stateParam: string
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
const c = getConfig()
|
||||||
|
redirectURI = (c.apiUrl || location.origin).replace(/\/$/, '') + '/oidc-callback'
|
||||||
|
stateParam = oidcState + '.' + codeVerifier
|
||||||
|
} else {
|
||||||
|
redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||||
|
stateParam = oidcState
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
@@ -85,7 +97,7 @@ export async function startLogin(): Promise<void> {
|
|||||||
redirect_uri: redirectURI,
|
redirect_uri: redirectURI,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
state: oidcState,
|
state: stateParam,
|
||||||
scope: 'openid profile email'
|
scope: 'openid profile email'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user