import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce'; import { decodeStoredTokens } from './utils'; const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1'; const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier'; const STATE_KEY = 'tenant_oauth_state'; const REDIRECT_KEY = 'tenant_oauth_redirect'; const TOKEN_ENDPOINT = '/api/v1/oauth/token'; const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize'; const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write'; function getClientId(): string { const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined; if (!clientId) { throw new Error('VITE_OAUTH_CLIENT_ID is not configured'); } return clientId; } function buildRedirectUri(): string { return new URL('/admin/auth/callback', window.location.origin).toString(); } export class AuthError extends Error { constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) { super(message ?? code); this.name = 'AuthError'; } } export function isAuthError(value: unknown): value is AuthError { return value instanceof AuthError; } type AuthFailureHandler = () => void; const authFailureHandlers = new Set(); function notifyAuthFailure() { authFailureHandlers.forEach((handler) => { try { handler(); } catch (error) { console.error('[Auth] Failure handler threw', error); } }); } export function registerAuthFailureHandler(handler: AuthFailureHandler): () => void { authFailureHandlers.add(handler); return () => { authFailureHandlers.delete(handler); }; } export interface StoredTokens { accessToken: string; refreshToken: string; expiresAt: number; scope?: string; } export interface TokenResponse { access_token: string; refresh_token: string; expires_in: number; token_type: string; scope?: string; } export function loadTokens(): StoredTokens | null { const raw = localStorage.getItem(TOKEN_STORAGE_KEY); const stored = decodeStoredTokens(raw); if (!stored) { return null; } if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) { clearTokens(); return null; } return stored; } export function saveTokens(response: TokenResponse): StoredTokens { const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000; const stored: StoredTokens = { accessToken: response.access_token, refreshToken: response.refresh_token, expiresAt, scope: response.scope, }; localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored)); return stored; } export function clearTokens(): void { localStorage.removeItem(TOKEN_STORAGE_KEY); } export async function ensureAccessToken(): Promise { const tokens = loadTokens(); if (!tokens) { notifyAuthFailure(); throw new AuthError('unauthenticated', 'No tokens available'); } if (Date.now() < tokens.expiresAt) { return tokens.accessToken; } return refreshAccessToken(tokens.refreshToken); } async function refreshAccessToken(refreshToken: string): Promise { if (!refreshToken) { notifyAuthFailure(); throw new AuthError('unauthenticated', 'Missing refresh token'); } const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: getClientId(), }); const response = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); if (!response.ok) { console.warn('[Auth] Refresh token request failed', response.status); notifyAuthFailure(); throw new AuthError('unauthenticated', 'Refresh token invalid'); } const data = (await response.json()) as TokenResponse; const stored = saveTokens(data); return stored.accessToken; } export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise { const token = await ensureAccessToken(); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); if (!headers.has('Accept')) { headers.set('Accept', 'application/json'); } const response = await fetch(input, { ...init, headers }); if (response.status === 401) { notifyAuthFailure(); throw new AuthError('unauthorized', 'Access token rejected'); } return response; } export async function startOAuthFlow(redirectPath?: string): Promise { const verifier = generateCodeVerifier(); const state = generateState(); const challenge = await generateCodeChallenge(verifier); sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); sessionStorage.setItem(STATE_KEY, state); if (redirectPath) { sessionStorage.setItem(REDIRECT_KEY, redirectPath); } const params = new URLSearchParams({ response_type: 'code', client_id: getClientId(), redirect_uri: buildRedirectUri(), scope: SCOPES, state, code_challenge: challenge, code_challenge_method: 'S256', }); window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`; } export async function completeOAuthCallback(params: URLSearchParams): Promise { if (params.get('error')) { throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error'); } const code = params.get('code'); const returnedState = params.get('state'); const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY); const expectedState = sessionStorage.getItem(STATE_KEY); if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) { notifyAuthFailure(); throw new AuthError('invalid_state', 'PKCE state mismatch'); } sessionStorage.removeItem(CODE_VERIFIER_KEY); sessionStorage.removeItem(STATE_KEY); const body = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: getClientId(), redirect_uri: buildRedirectUri(), code_verifier: verifier, }); const response = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!response.ok) { console.error('[Auth] Authorization code exchange failed', response.status); notifyAuthFailure(); throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code'); } const data = (await response.json()) as TokenResponse; saveTokens(data); const redirectTarget = sessionStorage.getItem(REDIRECT_KEY); if (redirectTarget) { sessionStorage.removeItem(REDIRECT_KEY); } return redirectTarget; } export function clearOAuthSession(): void { sessionStorage.removeItem(CODE_VERIFIER_KEY); sessionStorage.removeItem(STATE_KEY); sessionStorage.removeItem(REDIRECT_KEY); }