Files
amcs/ui/src/shellState.ts
T
warkanum da7220ad64
CI / build-and-test (push) Failing after -31m47s
fix: address logic error in user authentication flow
* Corrected condition for user role validation
* Improved error handling for failed login attempts
2026-04-26 10:37:38 +02:00

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;
}