Desktop OIDC: full page nav to localhost, meta redirect back
SPA navigates to 127.0.0.1:18901/oidc/start, passing ret URL. Go opens browser, waits for callback, saves token, returns HTML with <meta refresh> back to Wails app with ?desktop=1&token=TOKEN. main.ts extracts token from URL on reload.
This commit is contained in:
@@ -142,80 +142,65 @@ func startOIDCServer() *http.Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
h("/oidc/open", func(w http.ResponseWriter, r *http.Request) {
|
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
|
||||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
||||||
|
returnURL := r.URL.Query().Get("ret")
|
||||||
if apiUrl == "" {
|
if apiUrl == "" {
|
||||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if returnURL == "" {
|
||||||
sessionID := randomString(16)
|
returnURL = "/?desktop=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()
|
|
||||||
session, ok := oidcSessions[sessionID]
|
|
||||||
if ok {
|
|
||||||
select {
|
|
||||||
case t := <-session.ch:
|
|
||||||
token = t
|
|
||||||
session.ch <- t // put it back for other pollers
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
ch := make(chan string, 1)
|
||||||
|
oidcSessionsMu.Lock()
|
||||||
|
sessionID := randomString(16)
|
||||||
|
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
|
||||||
oidcSessionsMu.Unlock()
|
oidcSessionsMu.Unlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
authURL := fmt.Sprintf("%s?%s",
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
oidcCfg.AuthorizationEndpoint,
|
||||||
"token": token,
|
url.Values{
|
||||||
"pending": fmt.Sprintf("%t", !ok || (ok && token == "")),
|
"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)
|
||||||
|
returnURL += "&token=" + url.QueryEscape(token)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||||
|
<meta http-equiv="refresh" content="0;url=%s">
|
||||||
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||||
|
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||||
|
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||||
|
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
|
||||||
})
|
})
|
||||||
|
|
||||||
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
|
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export async function startLogin(): Promise<void> {
|
|||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
const apiUrl = getConfig().apiUrl || ''
|
const apiUrl = getConfig().apiUrl || ''
|
||||||
throw new Error('DESKTOP_OIDC:' + apiUrl)
|
const ret = encodeURIComponent(location.origin + location.pathname.replace(/\/$/, '') + '?desktop=1')
|
||||||
|
location.href = `http://127.0.0.1:18901/oidc/start?apiUrl=${encodeURIComponent(apiUrl)}&ret=${ret}`
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
|
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
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 } from '$lib/config'
|
import { initConfig, setConfig, getConfig } from '$lib/config'
|
||||||
|
|
||||||
|
function handleDesktopToken() {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const token = params.get('token')
|
||||||
|
if (token && new URLSearchParams(location.search).has('desktop')) {
|
||||||
|
const apiUrl = getConfig().apiUrl || ''
|
||||||
|
setConfig({ apiUrl, token, isDesktop: true })
|
||||||
|
initConfig({ apiUrl, token, isDesktop: true })
|
||||||
|
params.delete('token')
|
||||||
|
const 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'))
|
||||||
|
|
||||||
|
|||||||
@@ -72,43 +72,11 @@
|
|||||||
try {
|
try {
|
||||||
await startLogin()
|
await startLogin()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message || e || ''
|
error = e.message || 'OIDC login failed'
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user