191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
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 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);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const tokens = await fetchTokens(clientId);
|
|
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
|
|
window.location.assign('/event-admin/dashboard');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error('[DevAuth] Failed to login', 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));
|
|
|
|
// @ts-expect-error Dev helper for debugging only.
|
|
window.fotospielDemoAuth = api;
|
|
// @ts-expect-error Dev helper for debugging only.
|
|
globalThis.fotospielDemoAuth = api;
|
|
}
|
|
|
|
async function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
|
|
const requestUrl = new URL(url, window.location.origin);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(requestUrl.toString(), {
|
|
method: 'GET',
|
|
credentials: 'include',
|
|
headers: {
|
|
Accept: 'application/json, text/plain, */*',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
redirect: 'manual',
|
|
});
|
|
} catch (error) {
|
|
throw new Error('Authorize request failed');
|
|
}
|
|
|
|
const status = response.status;
|
|
const isSuccess = (status >= 200 && status < 400) || status === 0;
|
|
if (!isSuccess) {
|
|
throw new Error(`Authorize failed with ${status}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('Content-Type') ?? '';
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
const payload = (await response.json()) 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);
|
|
}
|
|
|
|
return finalUrl;
|
|
} catch (error) {
|
|
throw error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
}
|
|
|
|
const locationHeader = response.headers.get('Location');
|
|
if (locationHeader) {
|
|
return new URL(locationHeader, window.location.origin);
|
|
}
|
|
|
|
if (response.url && response.url !== requestUrl.toString()) {
|
|
return new URL(response.url, window.location.origin);
|
|
}
|
|
|
|
if (fallbackRedirect) {
|
|
return new URL(fallbackRedirect, window.location.origin);
|
|
}
|
|
|
|
throw new Error('Authorize response missing redirect target');
|
|
}
|
|
|
|
function verifyState(returnedState: string | null, expectedState: string): void {
|
|
if (returnedState && returnedState !== expectedState) {
|
|
throw new Error('Authorize state mismatch');
|
|
}
|
|
}
|