import { apiBase, getConfig } from './config' interface OIDCConfig { issuer: string client_id: string authorization_endpoint: string } interface TokenResponse { access_token: string token_type: string expires_in?: number refresh_token?: string id_token?: string } interface OIDCState { config: OIDCConfig | null accessToken: string | null refreshToken: string | null user: string | null refreshing: Promise | null } const SESSION_KEY = 'oidc_access_token' const REFRESH_KEY = 'oidc_refresh_token' const USER_KEY = 'oidc_user' const PKCE_KEY = 'oidc_pkce_verifier' const STATE_KEY = 'oidc_state' let state: OIDCState = { config: null, accessToken: sessionStorage.getItem(SESSION_KEY), refreshToken: localStorage.getItem(REFRESH_KEY), user: localStorage.getItem(USER_KEY), refreshing: null } async function fetchConfig(): Promise { try { const resp = await fetch(apiBase('/api/v1/auth/oidc-config')) if (!resp.ok) return null const cfg: OIDCConfig = await resp.json() state.config = cfg return cfg } catch { return null } } function base64URLEncode(buf: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } function generateRandom(len: number): string { const arr = new Uint8Array(len) crypto.getRandomValues(arr) return base64URLEncode(arr) } async function sha256(plain: string): Promise { return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain)) } export async function startLogin(): Promise { const cfg = state.config ?? await fetchConfig() if (!cfg) throw new Error('OIDC not configured on server') const codeVerifier = generateRandom(64) const challengeBuf = await sha256(codeVerifier) const codeChallenge = base64URLEncode(challengeBuf) const oidcState = generateRandom(32) sessionStorage.setItem(PKCE_KEY, codeVerifier) sessionStorage.setItem(STATE_KEY, oidcState) const isDesktop = new URLSearchParams(location.search).has('desktop') 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', client_id: cfg.client_id, redirect_uri: redirectURI, code_challenge: codeChallenge, code_challenge_method: 'S256', state: stateParam, scope: 'openid profile email' }) if (isDesktop) { const apiUrl = getConfig().apiUrl || '' const ret = encodeURIComponent( location.origin + location.pathname.replace(/\/$/, '') + '?desktop=1&apiUrl=' + encodeURIComponent(apiUrl) ) location.href = `http://127.0.0.1:18901/oidc/start?apiUrl=${encodeURIComponent(apiUrl)}&ret=${ret}` return } location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}` } export async function handleCallback(code: string, returnedState: string): Promise { const verifier = sessionStorage.getItem(PKCE_KEY) const savedState = sessionStorage.getItem(STATE_KEY) sessionStorage.removeItem(PKCE_KEY) sessionStorage.removeItem(STATE_KEY) if (!verifier || savedState !== returnedState) return false const cfg = state.config ?? await fetchConfig() if (!cfg) return false const redirectURI = (location.origin + location.pathname).replace(/\/$/, '') try { const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirectURI }) }) if (!resp.ok) return false const tokens: TokenResponse = await resp.json() if (!tokens.access_token) return false storeTokens(tokens) if (tokens.id_token) { const user = parseIDTokenUser(tokens.id_token) if (user) { state.user = user localStorage.setItem(USER_KEY, user) } } return true } catch { return false } } function parseIDTokenUser(idToken: string): string | null { try { const payload = idToken.split('.')[1] const claims = JSON.parse(atob(payload)) return claims.preferred_username || claims.email || claims.sub || null } catch { return null } } function storeTokens(tokens: TokenResponse) { state.accessToken = tokens.access_token sessionStorage.setItem(SESSION_KEY, tokens.access_token) if (tokens.refresh_token) { state.refreshToken = tokens.refresh_token localStorage.setItem(REFRESH_KEY, tokens.refresh_token) } } export function getToken(): string | null { return state.accessToken } export function getUser(): string | null { return state.user } export function isOIDCAvailable(): boolean { return !!(state.accessToken || state.refreshToken) } export async function ensureToken(): Promise { if (state.accessToken) return state.accessToken if (state.refreshToken) { return refreshAccessToken() } return null } async function refreshAccessToken(): Promise { if (state.refreshing) return state.refreshing const cfg = state.config ?? await fetchConfig() if (!cfg || !state.refreshToken) return null state.refreshing = (async () => { try { const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: state.refreshToken }) }) if (!resp.ok) { clearTokens() return null } const tokens: TokenResponse = await resp.json() if (!tokens.access_token) { clearTokens() return null } storeTokens(tokens) return tokens.access_token } catch { clearTokens() return null } finally { state.refreshing = null } })() return state.refreshing } function clearTokens() { state.accessToken = null state.refreshToken = null state.user = null sessionStorage.removeItem(SESSION_KEY) localStorage.removeItem(REFRESH_KEY) localStorage.removeItem(USER_KEY) } export function logout(): void { clearTokens() } export function isOIDCConfigured(): boolean { return !!(state.accessToken || state.refreshToken) } export async function initOIDC(): Promise { if (state.accessToken) return true if (state.refreshToken) { const token = await refreshAccessToken() return token !== null } return false } export function hasPendingCallback(): boolean { const params = new URLSearchParams(location.search) return params.has('code') && params.has('state') } export async function processPendingCallback(): Promise { const params = new URLSearchParams(location.search) const code = params.get('code') const oidcState = params.get('state') if (!code || !oidcState) return false const ok = await handleCallback(code, oidcState) const url = new URL(location.href) url.searchParams.delete('code') url.searchParams.delete('state') history.replaceState(null, '', url.toString()) return ok }