267 lines
7.8 KiB
TypeScript
267 lines
7.8 KiB
TypeScript
import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
|
|
import { decodeStoredTokens } from './utils';
|
|
import { ADMIN_AUTH_CALLBACK_PATH } from '../constants';
|
|
|
|
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_PATH, 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<AuthFailureHandler>();
|
|
|
|
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;
|
|
clientId?: 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<StoredTokens>(raw);
|
|
if (!stored) {
|
|
return null;
|
|
}
|
|
|
|
if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
|
|
clearTokens();
|
|
return null;
|
|
}
|
|
|
|
return stored;
|
|
}
|
|
|
|
export function saveTokens(response: TokenResponse, clientId: string = getClientId()): 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,
|
|
clientId,
|
|
};
|
|
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
|
return stored;
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export async function ensureAccessToken(): Promise<string> {
|
|
const tokens = loadTokens();
|
|
if (!tokens) {
|
|
notifyAuthFailure();
|
|
throw new AuthError('unauthenticated', 'No tokens available');
|
|
}
|
|
|
|
if (Date.now() < tokens.expiresAt) {
|
|
return tokens.accessToken;
|
|
}
|
|
|
|
return refreshAccessToken(tokens);
|
|
}
|
|
|
|
async function refreshAccessToken(tokens: StoredTokens): Promise<string> {
|
|
const clientId = tokens.clientId ?? getClientId();
|
|
|
|
if (!tokens.refreshToken) {
|
|
notifyAuthFailure();
|
|
throw new AuthError('unauthenticated', 'Missing refresh token');
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: tokens.refreshToken,
|
|
client_id: clientId,
|
|
});
|
|
|
|
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, clientId);
|
|
return stored.accessToken;
|
|
}
|
|
|
|
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
|
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<void> {
|
|
const verifier = generateCodeVerifier();
|
|
const state = generateState();
|
|
const challenge = await generateCodeChallenge(verifier);
|
|
|
|
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
|
sessionStorage.setItem(STATE_KEY, state);
|
|
localStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
|
localStorage.setItem(STATE_KEY, state);
|
|
if (redirectPath) {
|
|
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
|
localStorage.setItem(REDIRECT_KEY, redirectPath);
|
|
}
|
|
|
|
if (import.meta.env.DEV) {
|
|
console.debug('[Auth] PKCE store', { state, verifier, 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<string | null> {
|
|
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) ?? localStorage.getItem(CODE_VERIFIER_KEY);
|
|
const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
|
|
|
|
if (import.meta.env.DEV) {
|
|
console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() });
|
|
}
|
|
|
|
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
|
clearOAuthSession();
|
|
notifyAuthFailure();
|
|
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
|
}
|
|
|
|
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
|
sessionStorage.removeItem(STATE_KEY);
|
|
localStorage.removeItem(CODE_VERIFIER_KEY);
|
|
localStorage.removeItem(STATE_KEY);
|
|
|
|
const clientId = getClientId();
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
client_id: clientId,
|
|
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) {
|
|
clearOAuthSession();
|
|
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, clientId);
|
|
|
|
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
|
if (redirectTarget) {
|
|
sessionStorage.removeItem(REDIRECT_KEY);
|
|
localStorage.removeItem(REDIRECT_KEY);
|
|
} else {
|
|
localStorage.removeItem(REDIRECT_KEY);
|
|
}
|
|
|
|
return redirectTarget;
|
|
}
|
|
|
|
export function clearOAuthSession(): void {
|
|
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
|
sessionStorage.removeItem(STATE_KEY);
|
|
sessionStorage.removeItem(REDIRECT_KEY);
|
|
localStorage.removeItem(CODE_VERIFIER_KEY);
|
|
localStorage.removeItem(STATE_KEY);
|
|
localStorage.removeItem(REDIRECT_KEY);
|
|
}
|