import { GlobalStateStore, isLoggedInStore } from '@warkypublic/svelix'; const normalizeApiURL = (url: string): string => url.replace(/\/+$/, ''); const resolveApiURL = (envURL?: string): string => { const viteEnvURL = envURL?.trim() || import.meta.env.VITE_API_URL?.trim() || import.meta.env.VITE_API_BASE_URL?.trim() || import.meta.env.VITE_URL?.trim(); if (viteEnvURL) return normalizeApiURL(viteEnvURL); if (typeof window !== 'undefined') { return `${window.location.protocol}//${window.location.host}/api`; } const stateURL = GlobalStateStore.getState().session.apiURL?.trim(); if (stateURL) return normalizeApiURL(stateURL); return ''; }; export { GlobalStateStore, isLoggedInStore }; export type OAuthClientRegistration = { client_id: string; client_name?: string; redirect_uris?: string[]; grant_types?: string[]; response_types?: string[]; token_endpoint_auth_method?: string; }; export type OAuthServerMetadata = { issuer: string; authorization_endpoint: string; token_endpoint: string; registration_endpoint: string; scopes_supported?: string[]; response_types_supported?: string[]; grant_types_supported?: string[]; token_endpoint_auth_methods_supported?: string[]; code_challenge_methods_supported?: string[]; }; export type OAuthSession = { clientId: string; redirectURI: string; codeVerifier: string; state: string; createdAt: number; }; const OAUTH_SESSION_KEY = 'amcs.oauth.session'; const OAUTH_CLIENT_KEY = 'amcs.oauth.client'; const OAUTH_DEFAULT_SCOPE = 'mcp'; export function ensureApiURL(envURL?: string): string { const resolved = resolveApiURL(envURL); if (!resolved) return ''; const state = GlobalStateStore.getState(); if (state.session.apiURL !== resolved) { state.setApiURL(resolved); } return resolved; } export function getPublicBaseURL(): string { if (typeof window === 'undefined') return ''; return `${window.location.protocol}//${window.location.host}`; } export function getOAuthRedirectURI(): string { const base = getPublicBaseURL(); return base ? `${base}/oauth/callback` : '/oauth/callback'; } function getStorage(storageKey: string): string | null { if (typeof window === 'undefined') return null; return window.localStorage.getItem(storageKey); } function setStorage(storageKey: string, value: string): void { if (typeof window === 'undefined') return; window.localStorage.setItem(storageKey, value); } function removeStorage(storageKey: string): void { if (typeof window === 'undefined') return; window.localStorage.removeItem(storageKey); } export function readOAuthClient(): OAuthClientRegistration | null { const raw = getStorage(OAUTH_CLIENT_KEY); if (!raw) return null; try { return JSON.parse(raw) as OAuthClientRegistration; } catch { removeStorage(OAUTH_CLIENT_KEY); return null; } } export function saveOAuthClient(client: OAuthClientRegistration): void { setStorage(OAUTH_CLIENT_KEY, JSON.stringify(client)); } export function readOAuthSession(): OAuthSession | null { const raw = getStorage(OAUTH_SESSION_KEY); if (!raw) return null; try { return JSON.parse(raw) as OAuthSession; } catch { removeStorage(OAUTH_SESSION_KEY); return null; } } export function saveOAuthSession(session: OAuthSession): void { setStorage(OAUTH_SESSION_KEY, JSON.stringify(session)); } export function clearOAuthSession(): void { removeStorage(OAUTH_SESSION_KEY); } export function setCurrentPath(pathname: string): void { const state = GlobalStateStore.getState(); const current = state.navigation.currentPage ?? {}; state.setCurrentPage({ ...current, path: pathname }); } function createRandomString(length = 48): string { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join(''); } return Array.from({ length }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(''); } function base64UrlEncode(buffer: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(buffer); const chunkSize = 0x8000; for (let index = 0; index < bytes.length; index += chunkSize) { binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } async function sha256(input: string): Promise { if (typeof crypto === 'undefined' || !crypto.subtle) { throw new Error('Secure browser crypto is required for OAuth login.'); } const data = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', data); return base64UrlEncode(digest); } export async function fetchOAuthMetadata(): Promise { const base = getPublicBaseURL(); const response = await fetch(`${base}/.well-known/oauth-authorization-server`); if (!response.ok) { throw new Error(`Failed to load OAuth metadata (${response.status})`); } return (await response.json()) as OAuthServerMetadata; } export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise { const redirectURI = getOAuthRedirectURI(); const response = await fetch(metadata.registration_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'AMCS Admin UI', redirect_uris: [redirectURI], grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'none' }) }); if (!response.ok) { throw new Error(`Failed to register OAuth client (${response.status})`); } const client = (await response.json()) as OAuthClientRegistration; saveOAuthClient(client); return client; } export async function buildOAuthAuthorizationURL(): Promise { removeStorage(OAUTH_CLIENT_KEY); const metadata = await fetchOAuthMetadata(); const client = await ensureOAuthClientRegistration(metadata); const codeVerifier = createRandomString(96); const codeChallenge = await sha256(codeVerifier); const state = createRandomString(40); const redirectURI = getOAuthRedirectURI(); saveOAuthSession({ clientId: client.client_id, redirectURI, codeVerifier, state, createdAt: Date.now() }); const url = new URL(metadata.authorization_endpoint); url.searchParams.set('client_id', client.client_id); url.searchParams.set('redirect_uri', redirectURI); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', OAUTH_DEFAULT_SCOPE); url.searchParams.set('state', state); url.searchParams.set('code_challenge', codeChallenge); url.searchParams.set('code_challenge_method', 'S256'); return url.toString(); } export async function loginWithCredentials(username: string, password: string): Promise { const base = getPublicBaseURL(); const response = await fetch(`${base}/api/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: username, client_secret: password }) }); const payload = (await response.json()) as { access_token?: string; error?: string }; if (!response.ok || !payload.access_token) { throw new Error(payload.error || `Login failed (${response.status})`); } return payload.access_token; } export async function exchangeOAuthCode(code: string, returnedState: string): Promise { const session = readOAuthSession(); if (!session) { throw new Error('OAuth session is missing. Start login again.'); } if (session.state !== returnedState) { throw new Error('OAuth state mismatch. Start login again.'); } const metadata = await fetchOAuthMetadata(); const response = await fetch(metadata.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: session.redirectURI, client_id: session.clientId, code_verifier: session.codeVerifier }) }); const payload = (await response.json()) as { access_token?: string; error?: string }; if (!response.ok || !payload.access_token) { throw new Error(payload.error || `Token exchange failed (${response.status})`); } clearOAuthSession(); return payload.access_token; }