stage 1 of oauth removal, switch to sanctum pat tokens

This commit is contained in:
Codex Agent
2025-11-06 20:35:58 +01:00
parent c9783bd57b
commit 776da57ca9
47 changed files with 1571 additions and 2555 deletions

View File

@@ -1,189 +1,65 @@
if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') {
type StoredTokens = {
accessToken: string;
refreshToken: string;
expiresAt: number;
scope?: string;
clientId?: string;
const CREDENTIALS: Record<string, { login: string; password: string }> = {
lumen: { login: 'hello@lumen-moments.demo', password: 'Demo1234!' },
storycraft: { login: 'storycraft-owner@demo.fotospiel', password: 'Demo1234!' },
viewfinder: { login: 'team@viewfinder.demo', password: 'Demo1234!' },
pixel: { login: 'support@pixelco.demo', password: 'Demo1234!' },
};
const CLIENTS: Record<string, string> = {
lumen: import.meta.env.VITE_OAUTH_CLIENT_ID || 'tenant-admin-app',
storycraft: 'demo-tenant-admin-storycraft',
viewfinder: 'demo-tenant-admin-viewfinder',
pixel: 'demo-tenant-admin-pixel',
};
const scopes = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
const baseUrl = window.location.origin;
const redirectUri = `${baseUrl}/event-admin/auth/callback`;
async function loginAs(label: string): Promise<void> {
const clientId = CLIENTS[label];
if (!clientId) {
console.warn('[DevAuth] Unknown tenant key', label);
async function loginAs(key: string): Promise<void> {
const credentials = CREDENTIALS[key];
if (!credentials) {
console.warn('[DevAuth] Unknown tenant key', key);
return;
}
try {
const tokens = await fetchTokens(clientId);
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
const response = await fetch('/api/v1/tenant-auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
login: credentials.login,
password: credentials.password,
}),
});
if (!response.ok) {
throw new Error(`Login failed with status ${response.status}`);
}
const payload = (await response.json()) as { token: string; abilities?: string[] };
const stored = {
accessToken: payload.token,
abilities: Array.isArray(payload.abilities) ? payload.abilities : [],
issuedAt: Date.now(),
} satisfies { accessToken: string; abilities: string[]; issuedAt: number };
try {
window.localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
} catch (error) {
console.warn('[DevAuth] Failed to persist PAT to localStorage', error);
}
try {
window.sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
} catch (error) {
console.warn('[DevAuth] Failed to persist PAT to sessionStorage', error);
}
window.location.assign('/event-admin/dashboard');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[DevAuth] Failed to login', message);
console.error('[DevAuth] Demo login failed', message);
throw error instanceof Error ? error : new Error(message);
}
}
async function fetchTokens(clientId: string): Promise<StoredTokens> {
const verifier = randomString(32);
const challenge = await sha256(verifier);
const state = randomString(12);
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri);
verifyState(callbackUrl.searchParams.get('state'), state);
const code = callbackUrl.searchParams.get('code');
if (!code) {
throw new Error('Authorize response missing code');
}
const tokenResponse = await fetch('/api/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed with ${tokenResponse.status}`);
}
const body = await tokenResponse.json();
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
return {
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
scope: body.scope,
clientId,
};
}
function randomString(bytes: number): string {
const buffer = new Uint8Array(bytes);
crypto.getRandomValues(buffer);
return base64Url(buffer);
}
async function sha256(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64Url(new Uint8Array(digest));
}
function base64Url(data: Uint8Array): string {
const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
const api = { loginAs, clients: CLIENTS };
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
const api = { loginAs, clients: Object.keys(CREDENTIALS) };
console.info('[DevAuth] Demo tenant helpers ready', api.clients);
(window as typeof window & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
(globalThis as typeof globalThis & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
}
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
const requestUrl = new URL(url, window.location.origin);
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
const isSuccess = (xhr.status >= 200 && xhr.status < 400) || xhr.status === 0;
if (!isSuccess) {
reject(new Error(`Authorize failed with ${xhr.status}`));
return;
}
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
if (contentType.includes('application/json')) {
try {
const payload = JSON.parse(xhr.responseText ?? '{}') as {
code?: string;
state?: string | null;
redirect_url?: string | null;
};
const target = payload.redirect_url ?? fallbackRedirect;
if (!target) {
throw new Error('Authorize response missing redirect target');
}
const finalUrl = new URL(target, window.location.origin);
if (payload.code && !finalUrl.searchParams.has('code')) {
finalUrl.searchParams.set('code', payload.code);
}
if (payload.state && !finalUrl.searchParams.has('state')) {
finalUrl.searchParams.set('state', payload.state);
}
resolve(finalUrl);
return;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
}
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
if (responseUrl) {
const finalUrl = new URL(responseUrl, window.location.origin);
if (finalUrl.searchParams.has('code') || finalUrl.toString() !== requestUrl.toString()) {
resolve(finalUrl);
return;
}
}
if (fallbackRedirect) {
resolve(new URL(fallbackRedirect, window.location.origin));
return;
}
reject(new Error('Authorize response missing redirect target'));
};
xhr.onerror = () => reject(new Error('Authorize request failed'));
xhr.send();
});
}
function verifyState(returnedState: string | null, expectedState: string): void {
if (returnedState && returnedState !== expectedState) {
throw new Error('Authorize state mismatch');
}
}