Files
eden/src/lib/server/oauth-diagnostics.ts
T
2026-09-18 23:15:05 -04:00

50 lines
4.5 KiB
TypeScript

/** Only fixed categories/allowlisted protocol codes may enter authentication logs. */
export type OAuthPhase = 'state_input' | 'configuration' | 'state_consumption' | 'provider_response' | 'proof_decryption' | 'session_binding' | 'exchange' | 'token_encryption' | 'login_persistence' | 'session_revocation' | 'cookie_issue';
type SsoDetails = { httpStatus?: number; oauthError?: unknown; joseCode?: unknown; claim?: unknown; claimShape?: unknown; validation?: unknown };
export class SsoDiagnosticError extends Error {
readonly details: SsoDetails;
constructor(message: string, readonly category: 'token_request' | 'jwt_validation', input: SsoDetails = {}) {
super(message);
// Do not retain unsafe provider/JOSE strings even inside an Error object.
this.details = {};
if (Number.isInteger(input.httpStatus) && input.httpStatus! >= 400 && input.httpStatus! <= 599) this.details.httpStatus = input.httpStatus;
if (typeof input.oauthError === 'string' && oauthErrors.has(input.oauthError)) this.details.oauthError = input.oauthError;
if (typeof input.joseCode === 'string' && joseCodes.has(input.joseCode)) this.details.joseCode = input.joseCode;
if (typeof input.claim === 'string' && claims.has(input.claim)) this.details.claim = input.claim;
if (typeof input.claimShape === 'string' && claimShapes.has(input.claimShape)) this.details.claimShape = input.claimShape;
if (typeof input.validation === 'string' && fixedErrors.has(input.validation)) this.details.validation = input.validation;
}
}
const oauthErrors = new Set(['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'access_denied', 'server_error', 'temporarily_unavailable']);
const joseCodes = new Set(['ERR_JWT_EXPIRED', 'ERR_JWT_CLAIM_VALIDATION_FAILED', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', 'ERR_JWKS_NO_MATCHING_KEY', 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', 'ERR_JWKS_TIMEOUT', 'ERR_JWS_INVALID', 'ERR_JWT_INVALID', 'ERR_JOSE_ALG_NOT_ALLOWED', 'ERR_JWK_INVALID', 'ERR_JWKS_INVALID']);
const claims = new Set(['aud', 'iss', 'exp', 'nbf', 'iat', 'scp']);
const claimShapes = new Set(['absent', 'null', 'array', 'string', 'number', 'boolean', 'object']);
const fixedErrors = new Map([
['Missing OAuth state', 'state_missing'], ['Invalid OAuth state', 'state_invalid'],
['Missing OAuth binding cookie', 'binding_cookie_missing'], ['Expired OAuth state', 'state_expired_or_consumed'],
['Authorization denied', 'authorization_denied'], ['Invalid encrypted envelope', 'invalid_envelope'],
['Encrypted credential could not be opened', 'decryption_failed'],
['Login session changed', 'login_session_changed'], ['Linking session changed', 'linking_session_changed'],
['EVE SSO discovery failed', 'discovery_failed'], ['Invalid EVE SSO token type', 'token_type_invalid'],
['Invalid EVE SSO access token', 'access_token_missing'], ['Invalid EVE SSO refresh token', 'refresh_token_missing'],
['Invalid EVE SSO expiration', 'expiration_invalid'], ['Invalid EVE SSO subject', 'subject_invalid'],
['Invalid EVE SSO character name', 'name_missing'], ['Invalid EVE SSO owner', 'owner_missing'],
['Invalid EVE SSO granted scopes', 'scopes_invalid'], ['Invalid EVE SSO audience', 'audience_invalid'],
['Invalid EVE SSO authorized party', 'authorized_party_invalid'], ['Invalid EVE SSO issued-at time', 'issued_at_invalid'],
]);
export function oauthFailureDiagnostic(phase: OAuthPhase, error: unknown): Record<string, string | number> {
const result: Record<string, string | number> = { event: 'eve_oauth_callback_failed', phase, reason: 'unclassified' };
if (error instanceof Error) result.reason = fixedErrors.get(error.message) ?? 'unclassified';
if (error instanceof SsoDiagnosticError) {
result.reason = error.category === 'token_request' || error.category === 'jwt_validation' ? error.category : 'unclassified';
const { httpStatus, oauthError, joseCode, claim, claimShape, validation } = error.details;
if (Number.isInteger(httpStatus) && httpStatus! >= 400 && httpStatus! <= 599) result.httpStatus = httpStatus!;
if (typeof oauthError === 'string' && oauthErrors.has(oauthError)) result.oauthError = oauthError;
if (typeof joseCode === 'string' && joseCodes.has(joseCode)) result.joseCode = joseCode;
if (typeof claim === 'string' && claims.has(claim)) result.claim = claim;
if (typeof claimShape === 'string' && claimShapes.has(claimShape)) result.claimShape = claimShape;
if (typeof validation === 'string' && fixedErrors.has(validation)) result.validation = fixedErrors.get(validation)!;
}
return result;
}