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
|
||||
var assets embed.FS
|
||||
|
||||
//go:embed icon.png
|
||||
var iconPNG []byte
|
||||
|
||||
const (
|
||||
keyringService = "com.hubris.oikos-desktop"
|
||||
keyringUser = "oikos"
|
||||
@@ -322,10 +325,12 @@ func main() {
|
||||
// --- System tray ---
|
||||
systemTray := app.SystemTray.New()
|
||||
systemTray.SetLabel("Oikos")
|
||||
systemTray.SetTooltip("Oikos — Control Room")
|
||||
systemTray.SetTooltip("Oikos")
|
||||
systemTray.SetIcon(iconPNG)
|
||||
systemTray.SetTemplateIcon(iconPNG)
|
||||
|
||||
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() {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
@@ -347,7 +352,7 @@ func main() {
|
||||
minWidth, minHeight := 1024, 700
|
||||
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "Oikos — Control Room",
|
||||
Title: "Oikos",
|
||||
Width: width,
|
||||
Height: height,
|
||||
MinWidth: minWidth,
|
||||
|
||||
@@ -21,7 +21,7 @@ oikos.hubris.network {
|
||||
tls {
|
||||
dns ionos {env.IONOS_AUTH_API_TOKEN}
|
||||
}
|
||||
@enroll path /api/v1/clients/enroll
|
||||
@enroll path /api/v1/clients/enroll /oidc-callback
|
||||
handle @enroll {
|
||||
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)
|
||||
})
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="apple-touch-icon" href="apple-touch-icon.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 {
|
||||
issuer: string
|
||||
@@ -77,7 +77,19 @@ export async function startLogin(): Promise<void> {
|
||||
sessionStorage.setItem(PKCE_KEY, codeVerifier)
|
||||
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({
|
||||
response_type: 'code',
|
||||
@@ -85,7 +97,7 @@ export async function startLogin(): Promise<void> {
|
||||
redirect_uri: redirectURI,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state: oidcState,
|
||||
state: stateParam,
|
||||
scope: 'openid profile email'
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user