diff --git a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts index 088fb2485..03333f3a4 100644 --- a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts +++ b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts @@ -8,7 +8,7 @@ import GatewayConnection from '@app/features/gateway/transport/GatewayConnection import {http} from '@app/features/platform/transport/RestTransport'; import {HttpError} from '@app/features/platform/types/EndpointError'; import {Logger} from '@app/features/platform/utils/AppLogger'; -import {failureCode} from '@app/features/platform/utils/ResponseInspection'; +import {failureCode, ipAuthorizationRequiredResponseFromError} from '@app/features/platform/utils/ResponseInspection'; import UserSettings from '@app/features/user/state/UserSettings'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import type {ValueOf} from '@fluxer/constants/src/ValueOf'; @@ -210,17 +210,28 @@ function tokenBody(token: string): {token: string} { return {token}; } +export class MalformedIpAuthorizationChallengeError extends HttpError { + constructor(error: HttpError) { + super({ + method: error.method, + path: error.path, + status: error.status, + body: error.body, + responseHeaders: error.responseHeaders, + }); + this.name = 'MalformedIpAuthorizationChallengeError'; + } +} + function loginIpAuthorizationResponse(error: HttpError): IpAuthorizationRequiredResponse | null { if (error.status !== 403 || failureCode(error) !== APIErrorCodes.IP_AUTHORIZATION_REQUIRED) { return null; } - const body = error.body as Record | undefined; - return { - ip_authorization_required: true, - ticket: body?.ticket as string, - email: body?.email as string, - resend_available_in: (body?.resend_available_in as number) ?? 30, - }; + const challenge = ipAuthorizationRequiredResponseFromError(error); + if (challenge === null) { + throw new MalformedIpAuthorizationChallengeError(error); + } + return challenge; } function verificationResultFromError( diff --git a/fluxer_app/src/features/platform/utils/ResponseInspection.ts b/fluxer_app/src/features/platform/utils/ResponseInspection.ts index f51c059dc..bfaa264d3 100644 --- a/fluxer_app/src/features/platform/utils/ResponseInspection.ts +++ b/fluxer_app/src/features/platform/utils/ResponseInspection.ts @@ -1,45 +1,126 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {HttpError} from '@app/features/platform/types/EndpointError'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {HttpStatus} from '@fluxer/constants/src/HttpConstants'; + +const DEFAULT_IP_AUTHORIZATION_RESEND_SECONDS = 30; + +const NON_CONTEXT_KEYS = new Set(['code', 'details', 'errors', 'message', 'request_id', 'retry_after', 'status']); interface ValidationFault { path: string; + code?: string; message: string; } +export interface ApiErrorResponse { + status: number | undefined; + code: string | undefined; + message: string | undefined; + requestId: string | undefined; + fields: ReadonlyArray | undefined; + retryAfterSeconds: number | undefined; + context: Record | undefined; +} + +export interface IpAuthorizationRequiredResponse { + ip_authorization_required: true; + ticket: string; + email: string; + resend_available_in: number; +} + +export function parseAPIErrorResponse(body: unknown, httpStatus?: number): ApiErrorResponse | null { + if (!isRecord(body)) return null; + const details = isRecord(body.details) ? body.details : undefined; + const retry = details != null && isRecord(details.retry) ? details.retry : undefined; + return { + status: httpStatus ?? readNumber(body, 'status'), + code: readString(body, 'code'), + message: readString(body, 'message'), + requestId: readString(body, 'request_id'), + fields: readValidationFaults(details?.fields) ?? readValidationFaults(body.errors), + retryAfterSeconds: readNumber(retry, 'after_seconds') ?? readNumber(body, 'retry_after'), + context: readErrorContext(body, details), + }; +} + export function replyCode(body: unknown): string | undefined { - return readString(body, 'code'); + return parseAPIErrorResponse(body)?.code; } export function replyMessage(body: unknown): string | undefined { - return readString(body, 'message'); + return parseAPIErrorResponse(body)?.message; } export function replyRetryAfter(body: unknown): number | undefined { - return readNumber(body, 'retry_after'); + return parseAPIErrorResponse(body)?.retryAfterSeconds; } export function failureCode(error: unknown): string | undefined { - return replyCode(failureBody(error)); + return failureResponse(error)?.code; } export function failureMessage(error: unknown): string | undefined { - return replyMessage(failureBody(error)); + return failureResponse(error)?.message; } export function failureRetryAfter(error: unknown): number | undefined { - return replyRetryAfter(failureBody(error)); + return failureResponse(error)?.retryAfterSeconds; } export function failureValidationErrors(error: unknown): ReadonlyArray | undefined { - const body = failureBody(error); - if (!isRecord(body)) return undefined; - const errors = body.errors; - return Array.isArray(errors) ? (errors as ReadonlyArray) : undefined; + return failureResponse(error)?.fields; } -function failureBody(error: unknown): unknown { - return error instanceof HttpError ? error.body : undefined; +export function ipAuthorizationRequiredResponseFromError(error: unknown): IpAuthorizationRequiredResponse | null { + const response = failureResponse(error); + if (response == null || response.status !== HttpStatus.FORBIDDEN) return null; + if (response.code !== APIErrorCodes.IP_AUTHORIZATION_REQUIRED) return null; + const context = response.context; + if (context == null) return null; + const ticket = readString(context, 'ticket'); + const email = readString(context, 'email'); + if (ticket == null || ticket.length === 0 || email == null || email.length === 0) return null; + const resendAvailableIn = readNumber(context, 'resend_available_in'); + if (resendAvailableIn != null && (!Number.isSafeInteger(resendAvailableIn) || resendAvailableIn < 0)) return null; + return { + ip_authorization_required: true, + ticket, + email, + resend_available_in: resendAvailableIn ?? DEFAULT_IP_AUTHORIZATION_RESEND_SECONDS, + }; +} + +function failureResponse(error: unknown): ApiErrorResponse | null { + return error instanceof HttpError ? parseAPIErrorResponse(error.body, error.status) : null; +} + +function readErrorContext( + body: Record, + details: Record | undefined, +): Record | undefined { + if (details != null && isRecord(details.context)) return details.context; + const context: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (NON_CONTEXT_KEYS.has(key)) continue; + context[key] = value; + } + return Object.keys(context).length > 0 ? context : undefined; +} + +function readValidationFaults(value: unknown): ReadonlyArray | undefined { + if (!Array.isArray(value)) return undefined; + const faults: Array = []; + for (const entry of value) { + const path = readString(entry, 'path'); + const message = readString(entry, 'message'); + if (path == null || message == null) continue; + const code = readString(entry, 'code'); + faults.push(code == null ? {path, message} : {path, code, message}); + } + return faults.length > 0 ? faults : undefined; } function isRecord(value: unknown): value is Record {