246 lines
7.1 KiB
TypeScript
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 */
|
|
}
|
|
}
|
|
}
|