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=...
295 lines
7.3 KiB
TypeScript
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
|
|
}
|