Desktop OIDC: non-blocking fetch + poll, don't leave webview
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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.
This commit is contained in:
2026-07-14 00:09:46 +02:00
parent c8ef3793d7
commit 8b3fe02a10
4 changed files with 99 additions and 62 deletions

View File

@@ -149,9 +149,11 @@ func startOIDCServer() *http.Server {
return return
} }
sessionID := randomString(16)
go func() {
oidcCfg, err := fetchOIDCConfig(apiUrl) oidcCfg, err := fetchOIDCConfig(apiUrl)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return return
} }
@@ -159,7 +161,6 @@ func startOIDCServer() *http.Server {
state := randomString(32) state := randomString(32)
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort) redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
sessionID := randomString(16)
ch := make(chan string, 1) ch := make(chan string, 1)
oidcSessionsMu.Lock() oidcSessionsMu.Lock()
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch} oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
@@ -185,13 +186,36 @@ func startOIDCServer() *http.Server {
if token != "" { if token != "" {
c := &ConfigService{} c := &ConfigService{}
c.SaveConfig(apiUrl, token) 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): 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{"id": sessionID})
})
h("/oidc/result", func(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("id")
var token string
oidcSessionsMu.Lock()
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()
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) { h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {

View File

@@ -102,9 +102,8 @@ export async function startLogin(): Promise<void> {
}) })
if (isDesktop) { if (isDesktop) {
const apiUrl = getConfig().apiUrl || location.protocol + '//' + location.host const apiUrl = getConfig().apiUrl || ''
location.href = `http://127.0.0.1:18901/oidc/open?apiUrl=${encodeURIComponent(apiUrl)}` throw new Error('DESKTOP_OIDC:' + apiUrl)
throw new Error('Redirecting to login...')
} }
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}` location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`

View File

@@ -1,28 +1,10 @@
import { mount } from 'svelte' import { mount } from 'svelte'
import App from './App.svelte' import App from './App.svelte'
import './app.css' import './app.css'
import { initConfig, setConfig, getConfig } from '$lib/config' import { initConfig } 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
}
function start() { function start() {
initConfig() initConfig()
handleDesktopToken()
requestAnimationFrame(() => import('./lib/renderers')) requestAnimationFrame(() => import('./lib/renderers'))

View File

@@ -72,11 +72,43 @@
try { try {
await startLogin() await startLogin()
} catch (e: any) { } 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 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() { function logoutOIDC() {
oidcLogout() oidcLogout()
oidcUser = null oidcUser = null