Files
oikos/web/src/lib/oidc.ts
dtoro bcf2b265c5
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
Pass apiUrl through OIDC redirect so SPA has it on return
The apiUrl configured on the Config page was lost when the webview
navigated away to localhost and back. Now it's included in the return
URL as ?desktop=1&apiUrl=...&token=...
2026-07-14 00:18:21 +02:00

295 lines
7.3 KiB
TypeScript

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<string | null> | 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<OIDCConfig | null> {
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<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain))
}
export async function startLogin(): Promise<void> {
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<boolean> {
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<string | null> {
if (state.accessToken) return state.accessToken
if (state.refreshToken) {
return refreshAccessToken()
}
return null
}
async function refreshAccessToken(): Promise<string | null> {
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<boolean> {
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<boolean> {
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
}