From c31978042fcc2f9b5b78e377d0ed584bf979352f Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 23:37:17 +0200 Subject: [PATCH] Desktop OIDC: open system browser, capture callback on localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigService.StartOIDCLogin(): - Fetches OIDC config from the API - Generates PKCE params - Starts local HTTP server on 127.0.0.1:18901 - Opens system browser to Authentik - Captures callback directly (no copy-paste) - Exchanges code for token, saves to keychain - Returns token to SPA → auto-connects Config.svelte detects Wails environment and calls the binding. --- cmd/desktop/main.go | 173 +++++++++++++++++++++++++++++++++++- web/src/pages/Config.svelte | 18 ++++ 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 2339f99..efbc10d 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -1,13 +1,18 @@ package main import ( + "crypto/rand" + "crypto/sha256" "embed" + "encoding/base64" "encoding/json" "fmt" "io" "io/fs" "log" + "net" "net/http" + "net/url" "os" "os/exec" "os/user" @@ -33,6 +38,7 @@ const ( updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases" pollInterval = 30 * time.Second updateInterval = 6 * time.Hour + oidcCallbackPort = 18901 ) type OikosConfig struct { @@ -43,7 +49,7 @@ type OikosConfig struct { // ---- ConfigService ---- -type ConfigService struct{ app *application.App } +type ConfigService struct{} func (c *ConfigService) Name() string { return "config" } @@ -95,6 +101,167 @@ func (c *ConfigService) DisableAutoStart() error { return os.Remove(path) } +// StartOIDCLogin opens the system browser for Authentik login and returns +// the access token. The desktop app hosts a local HTTP server on a fixed +// port to receive the OIDC callback directly (no copy-paste). +func (c *ConfigService) StartOIDCLogin(apiUrl string) (string, error) { + apiUrl = strings.TrimRight(apiUrl, "/") + + oidcCfg, err := fetchOIDCConfig(apiUrl) + if err != nil { + return "", fmt.Errorf("OIDC config: %w", err) + } + + verifier, challenge, err := pkceParams() + if err != nil { + return "", err + } + + state := randomString(32) + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", oidcCallbackPort) + + type result struct { + token string + err error + } + done := make(chan result, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + gotState := r.URL.Query().Get("state") + + if gotState != state { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("State mismatch.")) + done <- result{err: fmt.Errorf("state mismatch")} + return + } + + token, err := exchangeCode(apiUrl, code, verifier, redirectURI) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Token exchange failed: %v", err) + done <- result{err: err} + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`Oikos + +

Connected

You can close this window and return to Oikos.

`)) + + done <- result{token: token} + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort)) + if err != nil { + return "", fmt.Errorf("port %d in use: %w", oidcCallbackPort, err) + } + + srv := &http.Server{Handler: mux} + go srv.Serve(listener) + defer func() { + srv.Close() + listener.Close() + }() + + authURL := fmt.Sprintf("%s/authorize/?%s", + strings.TrimRight(oidcCfg.AuthorizationEndpoint, "/"), + url.Values{ + "response_type": {"code"}, + "client_id": {oidcCfg.ClientID}, + "redirect_uri": {redirectURI}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "scope": {"openid profile email"}, + }.Encode(), + ) + + if err := exec.Command("open", authURL).Start(); err != nil { + return "", fmt.Errorf("open browser: %w", err) + } + + select { + case r := <-done: + if r.err != nil { + return "", r.err + } + c.SaveConfig(apiUrl, r.token) + return r.token, nil + case <-time.After(5 * time.Minute): + return "", fmt.Errorf("login timed out") + } +} + +type oidcConfig struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + AuthorizationEndpoint string `json:"authorization_endpoint"` +} + +func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) { + resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("server returned %d", resp.StatusCode) + } + var cfg oidcConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func pkceParams() (verifier, challenge string, _ error) { + v := randomString(64) + h := sha256.Sum256([]byte(v)) + return v, base64.RawURLEncoding.EncodeToString(h[:]), nil +} + +func randomString(n int) string { + b := make([]byte, n) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) { + body, _ := json.Marshal(map[string]string{ + "grant_type": "authorization_code", + "code": code, + "code_verifier": verifier, + "redirect_uri": redirectURI, + }) + + resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body))) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b)) + } + + var tokens struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { + return "", err + } + if tokens.AccessToken == "" { + return "", fmt.Errorf("no access_token in response") + } + return tokens.AccessToken, nil +} + // ---- Window persistence ---- type windowState struct { @@ -338,7 +505,7 @@ func main() { }) trayMenu.AddSeparator() trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) { - go checkUpdates() // force immediate check on demand + go checkUpdates() }) trayMenu.AddSeparator() trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { @@ -371,12 +538,10 @@ func main() { systemTray.AttachWindow(window) systemTray.Run() - // Register shutdown handler to save window state app.OnShutdown(func() { saveWindowState(window) }) - // Start background goroutines go pollDashboard(cfg) go checkUpdates() diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte index 99df6e3..f93ad7f 100644 --- a/web/src/pages/Config.svelte +++ b/web/src/pages/Config.svelte @@ -67,6 +67,24 @@ error = '' oidcLoggingIn = true setConfig({ apiUrl: apiUrl.trim(), token: '' }) + + const wails = (window as any).wails + if (wails?.Call?.ByName) { + try { + const token = await wails.Call.ByName('StartOIDCLogin', apiUrl.trim()) + if (token) { + setConfig({ apiUrl: apiUrl.trim(), token }) + initConfig({ apiUrl: apiUrl.trim(), token }) + onConnected() + return + } + } catch (e: any) { + error = e?.message || e || 'OIDC login failed' + oidcLoggingIn = false + return + } + } + try { await startLogin() } catch (e: any) {