Add OIDC desktop callback, app logo, rename to Oikos
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- 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:
2026-07-13 23:14:40 +02:00
parent 62a337f3cc
commit f1ac82255a
6 changed files with 174 additions and 8 deletions

BIN
cmd/desktop/icon.png Normal file

Binary file not shown.

View File

@@ -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,

View File

@@ -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
} }

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 {

View File

@@ -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" />

View File

@@ -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'
}) })