This commit is contained in:
@@ -23,6 +23,39 @@ const resolveApiURL = (envURL?: string): string => {
|
||||
|
||||
export { GlobalStateStore };
|
||||
|
||||
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 '';
|
||||
@@ -35,6 +68,67 @@ export function ensureApiURL(envURL?: string): string {
|
||||
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 ?? {};
|
||||
@@ -44,3 +138,139 @@ export function setCurrentPath(pathname: string): void {
|
||||
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 apiURL = ensureApiURL();
|
||||
const response = await fetch(`${apiURL}/.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 existing = readOAuthClient();
|
||||
if (existing?.client_id && existing.redirect_uris?.includes(redirectURI)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
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> {
|
||||
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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user