diff --git a/cmd/desktop/icon.png b/cmd/desktop/icon.png new file mode 100644 index 0000000..f00b11f Binary files /dev/null and b/cmd/desktop/icon.png differ diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index fcfc7fe..2339f99 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -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, diff --git a/compose/caddy/Caddyfile.oikos b/compose/caddy/Caddyfile.oikos index b2b7f1b..0d1af06 100644 --- a/compose/caddy/Caddyfile.oikos +++ b/compose/caddy/Caddyfile.oikos @@ -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 } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 107303d..19cf279 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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, ` + + + + +Oikos — Connect Desktop App + + + +
+

Connect Desktop App

+
+

Exchanging authorization code...

+
+
+ +
+ + +`) +} + // 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 { diff --git a/web/index.html b/web/index.html index bbb63c0..bc4d789 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Oikos — Control Room + Oikos diff --git a/web/src/lib/oidc.ts b/web/src/lib/oidc.ts index 87633ed..68d7f51 100644 --- a/web/src/lib/oidc.ts +++ b/web/src/lib/oidc.ts @@ -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 { 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 { redirect_uri: redirectURI, code_challenge: codeChallenge, code_challenge_method: 'S256', - state: oidcState, + state: stateParam, scope: 'openid profile email' })