ultisuite-client/lib/auth/native-auth.ts
R3D347HR4Y d6d18f911b
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Lots of stuff and mobile app
2026-06-17 00:13:28 +02:00

246 lines
7.1 KiB
TypeScript

/**
* 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<app>://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<NativeSession> {
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<app>://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<NativeSession> {
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<NativeSession>((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<NativeSession | null> {
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<void> {
const cfg = getRuntimeConfig()
await clearSession()
if (cfg?.oidc.endSessionEndpoint) {
try {
await openExternal(cfg.oidc.endSessionEndpoint)
} catch {
/* best effort */
}
}
}