180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
|
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
|
import type { WorksSquareTokenPayload } from './works-square-session';
|
|
|
|
const DEFAULT_CLIENT_ID = 'makelore';
|
|
const DEFAULT_SCOPE = 'openid profile phone offline_access';
|
|
const REDIRECT_URI = 'niancode://auth/callback';
|
|
const STATE_TTL_MS = 10 * 60_000;
|
|
|
|
type PlatformAuthMetadata = {
|
|
authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
};
|
|
|
|
type PendingAuthorization = {
|
|
state: string;
|
|
codeVerifier: string;
|
|
expiresAt: number;
|
|
};
|
|
|
|
const pendingAuthorizations = new Map<string, PendingAuthorization>();
|
|
let metadataPromise: Promise<PlatformAuthMetadata> | null = null;
|
|
|
|
function configuredIssuer(): string {
|
|
const raw = (process.env.PLATFORM_AUTH_ISSUER || WORKS_SQUARE_CONFIG.apiBaseUrl).trim().replace(/\/+$/, '');
|
|
const parsed = new URL(raw);
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
throw new Error('PLATFORM_AUTH_ISSUER must be an absolute HTTP(S) URL without credentials or query parameters');
|
|
}
|
|
return parsed.toString().replace(/\/$/, '');
|
|
}
|
|
|
|
function configuredClientId(): string {
|
|
return process.env.PLATFORM_AUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID;
|
|
}
|
|
|
|
function configuredScope(): string {
|
|
return process.env.PLATFORM_AUTH_SCOPE?.trim() || DEFAULT_SCOPE;
|
|
}
|
|
|
|
function absoluteHttpUrl(value: unknown, field: string): string {
|
|
if (typeof value !== 'string' || !value.trim()) throw new Error(`OIDC discovery missing ${field}`);
|
|
const parsed = new URL(value);
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
throw new Error(`OIDC discovery returned an invalid ${field}`);
|
|
}
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function loadMetadata(): Promise<PlatformAuthMetadata> {
|
|
const issuer = configuredIssuer();
|
|
const response = await proxyAwareFetch(`${issuer}/.well-known/openid-configuration`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) throw new Error(`OIDC discovery failed (${response.status})`);
|
|
const payload = await response.json() as Record<string, unknown>;
|
|
return {
|
|
authorization_endpoint: absoluteHttpUrl(payload.authorization_endpoint, 'authorization_endpoint'),
|
|
token_endpoint: absoluteHttpUrl(payload.token_endpoint, 'token_endpoint'),
|
|
};
|
|
}
|
|
|
|
async function getMetadata(): Promise<PlatformAuthMetadata> {
|
|
if (!metadataPromise) {
|
|
metadataPromise = loadMetadata().catch((error) => {
|
|
metadataPromise = null;
|
|
throw error;
|
|
});
|
|
}
|
|
return metadataPromise;
|
|
}
|
|
|
|
function takePendingAuthorization(
|
|
requestId: string | null,
|
|
returnedState: string,
|
|
): PendingAuthorization | null {
|
|
if (requestId) {
|
|
const pending = pendingAuthorizations.get(requestId);
|
|
pendingAuthorizations.delete(requestId);
|
|
return pending ?? null;
|
|
}
|
|
|
|
// The platform redirect is a fixed niancode:// URI and does not append our
|
|
// local request id. The state value is the OIDC correlation handle instead.
|
|
for (const [candidateId, pending] of pendingAuthorizations) {
|
|
if (pending.state === returnedState) {
|
|
pendingAuthorizations.delete(candidateId);
|
|
return pending;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cleanupPendingAuthorizations(now = Date.now()): void {
|
|
for (const [requestId, pending] of pendingAuthorizations) {
|
|
if (pending.expiresAt <= now) pendingAuthorizations.delete(requestId);
|
|
}
|
|
}
|
|
|
|
function codeChallengeS256(verifier: string): string {
|
|
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
}
|
|
|
|
function readTokenPayload(value: unknown): WorksSquareTokenPayload {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('OIDC token endpoint returned an invalid response');
|
|
}
|
|
const payload = value as WorksSquareTokenPayload;
|
|
if (typeof payload.access_token !== 'string' || !payload.access_token.trim()) {
|
|
throw new Error('OIDC token endpoint did not return access_token');
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export async function startPlatformAuthorization(): Promise<{
|
|
requestId: string;
|
|
authorizationUrl: string;
|
|
}> {
|
|
cleanupPendingAuthorizations();
|
|
const metadata = await getMetadata();
|
|
const requestId = randomUUID();
|
|
const state = randomBytes(32).toString('base64url');
|
|
const codeVerifier = randomBytes(64).toString('base64url');
|
|
pendingAuthorizations.set(requestId, {
|
|
state,
|
|
codeVerifier,
|
|
expiresAt: Date.now() + STATE_TTL_MS,
|
|
});
|
|
|
|
const url = new URL(metadata.authorization_endpoint);
|
|
url.searchParams.set('client_id', configuredClientId());
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
url.searchParams.set('scope', configuredScope());
|
|
url.searchParams.set('state', state);
|
|
url.searchParams.set('nonce', randomBytes(32).toString('base64url'));
|
|
url.searchParams.set('code_challenge', codeChallengeS256(codeVerifier));
|
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
|
|
return { requestId, authorizationUrl: url.toString() };
|
|
}
|
|
|
|
export async function completePlatformAuthorization(
|
|
requestId: string | null,
|
|
code: string,
|
|
returnedState: string,
|
|
): Promise<WorksSquareTokenPayload> {
|
|
cleanupPendingAuthorizations();
|
|
const pending = takePendingAuthorization(requestId, returnedState);
|
|
if (!pending || pending.expiresAt <= Date.now()) throw new Error('OIDC authorization request expired');
|
|
if (pending.state !== returnedState) throw new Error('OIDC authorization state mismatch');
|
|
|
|
const metadata = await getMetadata();
|
|
const response = await proxyAwareFetch(metadata.token_endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: configuredClientId(),
|
|
code_verifier: pending.codeVerifier,
|
|
}).toString(),
|
|
});
|
|
if (!response.ok) {
|
|
const detail = await response.text();
|
|
throw new Error(`OIDC token exchange failed (${response.status}): ${detail.slice(0, 180)}`);
|
|
}
|
|
return readTokenPayload(await response.json());
|
|
}
|
|
|
|
export function resetPlatformAuthorizationForTests(): void {
|
|
pendingAuthorizations.clear();
|
|
metadataPromise = null;
|
|
}
|
|
|
|
export const PLATFORM_AUTH_REDIRECT_URI = REDIRECT_URI;
|