From 8b3fe02a1044ac3db6da2ce128e5698abb0f6a82 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 14 Jul 2026 00:09:46 +0200 Subject: [PATCH] Desktop OIDC: non-blocking fetch + poll, don't leave webview SPA fetches /oidc/open (returns session ID immediately), then polls /oidc/result every 500ms. Go server opens browser in a goroutine. Webview never leaves the Wails origin. Token is saved to keychain and returned through the poll response. --- cmd/desktop/main.go | 102 ++++++++++++++++++++++-------------- web/src/lib/oidc.ts | 5 +- web/src/main.ts | 20 +------ web/src/pages/Config.svelte | 34 +++++++++++- 4 files changed, 99 insertions(+), 62 deletions(-) diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 9dce921..0e40b3a 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -149,49 +149,73 @@ func startOIDCServer() *http.Server { return } - oidcCfg, err := fetchOIDCConfig(apiUrl) - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - - verifier, challenge, _ := pkceParams() - state := randomString(32) - redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort) - sessionID := randomString(16) - ch := make(chan string, 1) + + go func() { + oidcCfg, err := fetchOIDCConfig(apiUrl) + if err != nil { + return + } + + verifier, challenge, _ := pkceParams() + state := randomString(32) + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort) + + ch := make(chan string, 1) + oidcSessionsMu.Lock() + oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch} + oidcSessionsMu.Unlock() + + authURL := fmt.Sprintf("%s?%s", + 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(), + ) + + exec.Command("open", authURL).Start() + + select { + case token := <-ch: + if token != "" { + c := &ConfigService{} + c.SaveConfig(apiUrl, token) + } + case <-time.After(5 * time.Minute): + } + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": sessionID}) + }) + + h("/oidc/result", func(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("id") + var token string + oidcSessionsMu.Lock() - oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch} + session, ok := oidcSessions[sessionID] + if ok { + select { + case t := <-session.ch: + token = t + session.ch <- t // put it back for other pollers + default: + } + } oidcSessionsMu.Unlock() - authURL := fmt.Sprintf("%s?%s", - 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(), - ) - - exec.Command("open", authURL).Start() - - select { - case token := <-ch: - if token != "" { - c := &ConfigService{} - c.SaveConfig(apiUrl, token) - http.Redirect(w, r, "/?desktop=1&token="+url.QueryEscape(token), http.StatusFound) - } else { - http.Redirect(w, r, "/?desktop=1&error=login_failed", http.StatusFound) - } - case <-time.After(5 * time.Minute): - http.Redirect(w, r, "/?desktop=1&error=timeout", http.StatusFound) - } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "token": token, + "pending": fmt.Sprintf("%t", !ok || (ok && token == "")), + }) }) h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/lib/oidc.ts b/web/src/lib/oidc.ts index 941e196..3e59d17 100644 --- a/web/src/lib/oidc.ts +++ b/web/src/lib/oidc.ts @@ -102,9 +102,8 @@ export async function startLogin(): Promise { }) if (isDesktop) { - const apiUrl = getConfig().apiUrl || location.protocol + '//' + location.host - location.href = `http://127.0.0.1:18901/oidc/open?apiUrl=${encodeURIComponent(apiUrl)}` - throw new Error('Redirecting to login...') + const apiUrl = getConfig().apiUrl || '' + throw new Error('DESKTOP_OIDC:' + apiUrl) } location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}` diff --git a/web/src/main.ts b/web/src/main.ts index 2aefcdd..22ef279 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,28 +1,10 @@ import { mount } from 'svelte' import App from './App.svelte' import './app.css' -import { initConfig, setConfig, getConfig } from '$lib/config' - -function handleDesktopToken() { - const params = new URLSearchParams(location.search) - const token = params.get('token') - if (token) { - const apiUrl = getConfig().apiUrl || params.get('apiUrl') || '' - setConfig({ apiUrl, token, isDesktop: true }) - initConfig({ apiUrl, token, isDesktop: true }) - // clean the URL - params.delete('token') - params.delete('apiUrl') - let q = params.toString() - history.replaceState(null, '', location.pathname + (q ? '?' + q : '')) - return true - } - return false -} +import { initConfig } from '$lib/config' function start() { initConfig() - handleDesktopToken() requestAnimationFrame(() => import('./lib/renderers')) diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte index 1cbfb25..27bf25a 100644 --- a/web/src/pages/Config.svelte +++ b/web/src/pages/Config.svelte @@ -72,11 +72,43 @@ try { await startLogin() } catch (e: any) { - error = e.message || 'OIDC login failed' + const msg = e?.message || e || '' + if (msg.startsWith('DESKTOP_OIDC:')) { + const url = msg.substring('DESKTOP_OIDC:'.length) + await desktopOIDC(url) + return + } + error = msg || 'OIDC login failed' oidcLoggingIn = false } } + async function desktopOIDC(apiUrl: string) { + try { + const resp = await fetch(`http://127.0.0.1:18901/oidc/open?apiUrl=${encodeURIComponent(apiUrl)}`) + const { id } = await resp.json() + if (!id) throw new Error('No session ID') + + for (let i = 0; i < 600; i++) { + await new Promise(r => setTimeout(r, 500)) + const r = await fetch(`http://127.0.0.1:18901/oidc/result?id=${id}`) + const data = await r.json() + if (data.token) { + setConfig({ apiUrl, token: data.token, isDesktop: true }) + initConfig({ apiUrl, token: data.token, isDesktop: true }) + oidcLoggingIn = false + onConnected() + return + } + if (!data.pending || data.pending === 'false') break + } + error = 'Login timed out' + } catch (e: any) { + error = e?.message || 'Could not reach login service' + } + oidcLoggingIn = false + } + function logoutOIDC() { oidcLogout() oidcUser = null