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:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user