da7220ad64
CI / build-and-test (push) Failing after -31m47s
* Corrected condition for user role validation * Improved error handling for failed login attempts
294 lines
8.5 KiB
TypeScript
294 lines
8.5 KiB
TypeScript
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<string> {
|
|
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<OAuthServerMetadata> {
|
|
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<OAuthClientRegistration> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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;
|
|
}
|