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

View File

@@ -138,6 +138,13 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
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{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.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)
}
// 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
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {