feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

View File

@@ -0,0 +1,126 @@
import React from 'react';
import {
authorizedFetch,
clearOAuthSession,
clearTokens,
completeOAuthCallback,
isAuthError,
loadTokens,
registerAuthFailureHandler,
startOAuthFlow,
} from './tokens';
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
export interface TenantProfile {
id: number;
tenant_id: number;
name?: string;
slug?: string;
email?: string | null;
event_credits_balance?: number;
[key: string]: unknown;
}
interface AuthContextValue {
status: AuthStatus;
user: TenantProfile | null;
login: (redirectPath?: string) => void;
logout: (options?: { redirect?: string }) => void;
completeLogin: (params: URLSearchParams) => Promise<string | null>;
refreshProfile: () => Promise<void>;
}
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = React.useState<AuthStatus>('loading');
const [user, setUser] = React.useState<TenantProfile | null>(null);
const handleAuthFailure = React.useCallback(() => {
clearTokens();
setUser(null);
setStatus('unauthenticated');
}, []);
React.useEffect(() => {
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
return unsubscribe;
}, [handleAuthFailure]);
const refreshProfile = React.useCallback(async () => {
try {
const response = await authorizedFetch('/api/v1/tenant/me');
if (!response.ok) {
throw new Error('Failed to load profile');
}
const profile = (await response.json()) as TenantProfile;
setUser(profile);
setStatus('authenticated');
} catch (error) {
if (isAuthError(error)) {
handleAuthFailure();
} else {
console.error('[Auth] Failed to refresh profile', error);
}
throw error;
}
}, [handleAuthFailure]);
React.useEffect(() => {
const tokens = loadTokens();
if (!tokens) {
setStatus('unauthenticated');
return;
}
refreshProfile().catch(() => {
// refreshProfile already handled failures.
});
}, [refreshProfile]);
const login = React.useCallback((redirectPath?: string) => {
const target = redirectPath ?? window.location.pathname + window.location.search;
startOAuthFlow(target);
}, []);
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
clearTokens();
clearOAuthSession();
setUser(null);
setStatus('unauthenticated');
if (redirect) {
window.location.href = redirect;
}
}, []);
const completeLogin = React.useCallback(
async (params: URLSearchParams) => {
setStatus('loading');
try {
const redirectTarget = await completeOAuthCallback(params);
await refreshProfile();
return redirectTarget;
} catch (error) {
handleAuthFailure();
throw error;
}
},
[handleAuthFailure, refreshProfile]
);
const value = React.useMemo<AuthContextValue>(
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
[status, user, login, logout, completeLogin, refreshProfile]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export function useAuth(): AuthContextValue {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,16 @@
import { base64UrlEncode } from './utils';
export function generateState(): string {
return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(32)));
}
export function generateCodeVerifier(): string {
// RFC 7636 recommends a length between 43 and 128 characters.
return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(64)));
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
}

View File

@@ -0,0 +1,238 @@
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<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;
}
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): 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<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.refreshToken);
}
async function refreshAccessToken(refreshToken: string): Promise<string> {
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<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);
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<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);
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);
}

View File

@@ -0,0 +1,20 @@
export function base64UrlEncode(buffer: Uint8Array): string {
let binary = '';
buffer.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function decodeStoredTokens<T>(value: string | null): T | null {
if (!value) {
return null;
}
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('[Auth] Failed to parse stored tokens', error);
return null;
}
}