/** 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 { const result: Record = { 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; }