/** * Native OIDC Authorization Code + PKCE flow for the Tauri shells. * * Replaces the server-side `/api/auth/*` routes (httpOnly cookies) on mobile: * - opens the system browser (ASWebAuthenticationSession on iOS / Custom Tabs * on Android) to the Authentik authorize endpoint, * - receives the `ulti://oauth/callback` redirect via the deep-link plugin * (forwarded as the `ulti://deep-link` window event by the Rust shell), * - exchanges the code for tokens against a **public** PKCE client (no secret), * - persists the session in the shared OS secure store (cross-app SSO). */ import { createPkcePair, randomString } from "@/lib/auth/pkce" import { platformUserFromToken } from "@/lib/auth/jwt-claims" import { getRuntimeConfig } from "@/lib/runtime-config" import { isTauriRuntime } from "@/lib/platform" import { listen } from "@/lib/native/bridge" import { clearSession, readSession, writeSession, type NativeSession, } from "@/lib/native/secure-store" const PENDING_KEY = "ulti-native-oauth-pending" const LOGIN_TIMEOUT_MS = 5 * 60 * 1000 type PendingAuth = { verifier: string state: string returnTo: string } type TokenResponse = { access_token?: string refresh_token?: string expires_in?: number id_token?: string } function savePending(p: PendingAuth) { sessionStorage.setItem(PENDING_KEY, JSON.stringify(p)) } function takePending(): PendingAuth | null { const raw = sessionStorage.getItem(PENDING_KEY) if (!raw) return null sessionStorage.removeItem(PENDING_KEY) try { return JSON.parse(raw) as PendingAuth } catch { return null } } async function openExternal(url: string) { if (isTauriRuntime()) { try { const opener = await import("@tauri-apps/plugin-opener") await opener.openUrl(url) return } catch { /* fall through to window.open */ } } window.open(url, "_blank", "noopener") } function buildSession(tokens: TokenResponse): NativeSession { const bearer = tokens.id_token ?? tokens.access_token if (!bearer) throw new Error("no_id_token") const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000 return { accessToken: bearer, refreshToken: tokens.refresh_token ?? null, expiresAt, user: platformUserFromToken(bearer), } } async function exchangeCode(code: string, verifier: string): Promise { const cfg = getRuntimeConfig() if (!cfg) throw new Error("no_runtime_config") const body = new URLSearchParams({ grant_type: "authorization_code", client_id: cfg.oidc.clientId, code, redirect_uri: cfg.oidc.redirectUri, code_verifier: verifier, }) const res = await fetch(cfg.oidc.tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }) if (!res.ok) { throw new Error(`token_exchange_failed:${res.status}`) } const session = buildSession((await res.json()) as TokenResponse) await writeSession(session) return session } /** Parse `code`/`state` out of a returned `ulti://oauth/callback?...` URL. */ function parseCallback(url: string): { code?: string; state?: string; error?: string } { try { const u = new URL(url) return { code: u.searchParams.get("code") ?? undefined, state: u.searchParams.get("state") ?? undefined, error: u.searchParams.get("error") ?? undefined, } } catch { return {} } } /** * Run the full native login. Resolves with the new session once the deep-link * callback is received and the code is exchanged. */ export async function nativeStartLogin(options?: { returnTo?: string prompt?: string }): Promise { const cfg = getRuntimeConfig() if (!cfg) throw new Error("no_runtime_config") const { verifier, challenge } = await createPkcePair() const state = randomString(16) const returnTo = options?.returnTo ?? "/mail/inbox" savePending({ verifier, state, returnTo }) const params = new URLSearchParams({ client_id: cfg.oidc.clientId, redirect_uri: cfg.oidc.redirectUri, response_type: "code", scope: "openid profile email offline_access", state, code_challenge: challenge, code_challenge_method: "S256", prompt: options?.prompt ?? "select_account", }) const authorizeUrl = `${cfg.oidc.authorizationEndpoint}?${params.toString()}` return new Promise((resolve, reject) => { let settled = false let unlisten: (() => void) | null = null const cleanup = () => { settled = true if (unlisten) unlisten() clearTimeout(timer) } const timer = setTimeout(() => { if (settled) return cleanup() reject(new Error("login_timeout")) }, LOGIN_TIMEOUT_MS) const handleUrl = async (rawUrls: unknown) => { if (settled) return const urls = Array.isArray(rawUrls) ? rawUrls : [rawUrls] for (const raw of urls) { if (typeof raw !== "string") continue if (!raw.includes("oauth/callback")) continue const { code, state: returnedState, error } = parseCallback(raw) const pending = takePending() if (error) { cleanup() reject(new Error(error)) return } if (!code || !pending || returnedState !== pending.state) { cleanup() reject(new Error("invalid_state")) return } try { const session = await exchangeCode(code, pending.verifier) cleanup() resolve(session) } catch (err) { cleanup() reject(err instanceof Error ? err : new Error("exchange_failed")) } return } } void (async () => { unlisten = await listen("ulti://deep-link", (payload) => { void handleUrl(payload) }) await openExternal(authorizeUrl) })().catch((err) => { if (settled) return cleanup() reject(err instanceof Error ? err : new Error("open_failed")) }) }) } /** Refresh the session using the stored refresh token (public client). */ export async function nativeRefresh(): Promise { const cfg = getRuntimeConfig() const session = await readSession() if (!cfg || !session?.refreshToken) return null const body = new URLSearchParams({ grant_type: "refresh_token", client_id: cfg.oidc.clientId, refresh_token: session.refreshToken, }) try { const res = await fetch(cfg.oidc.tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }) if (!res.ok) return null const tokens = (await res.json()) as TokenResponse const next = buildSession({ ...tokens, refresh_token: tokens.refresh_token ?? session.refreshToken, }) await writeSession(next) return next } catch { return null } } /** Clear the native session (and end the Authentik session in the browser). */ export async function nativeLogout(): Promise { const cfg = getRuntimeConfig() await clearSession() if (cfg?.oidc.endSessionEndpoint) { try { await openExternal(cfg.oidc.endSessionEndpoint) } catch { /* best effort */ } } }