From c79d66e02ae57b5b8cc1e236db87116510721070 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Sat, 24 Feb 2024 17:16:27 +0100 Subject: [PATCH] feat: add support for error codes --- src/lib/constants.ts | 8 +++++ src/lib/error-codes.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ src/lib/errors.ts | 55 ++++++++++++++++---------------- src/lib/fetch.ts | 54 +++++++++++++++++++++++++------- src/lib/helpers.ts | 23 ++++++++++++++ 5 files changed, 172 insertions(+), 39 deletions(-) create mode 100644 src/lib/error-codes.ts diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 24fe6637f..6e1b7fffa 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -8,3 +8,11 @@ export const NETWORK_FAILURE = { MAX_RETRIES: 10, RETRY_INTERVAL: 2, // in deciseconds } + +export const API_VERSION_HEADER_NAME = 'X-Supabase-Api-Version' +export const API_VERSIONS = { + '2024-01-01': { + timestamp: Date.parse('2024-01-01T00:00:00.0Z'), + name: '2024-01-01', + }, +} diff --git a/src/lib/error-codes.ts b/src/lib/error-codes.ts new file mode 100644 index 000000000..63ffa2a79 --- /dev/null +++ b/src/lib/error-codes.ts @@ -0,0 +1,71 @@ +/** + * Known error codes. Note that the server may also return other error codes + * not included in this list (if the client library is older than the version + * on the server). + */ +export type ErrorCode = + | 'unexpected_failure' + | 'validation_failed' + | 'bad_json' + | 'email_exists' + | 'phone_exists' + | 'bad_jwt' + | 'not_admin' + | 'no_authorization' + | 'user_not_found' + | 'session_not_found' + | 'flow_state_not_found' + | 'flow_state_expired' + | 'signup_disabled' + | 'user_banned' + | 'provider_email_needs_verification' + | 'invite_not_found' + | 'bad_oauth_state' + | 'bad_oauth_callback' + | 'oauth_provider_not_supported' + | 'unexpected_audience' + | 'single_identity_not_deletable' + | 'email_conflict_identity_not_deletable' + | 'identity_already_exists' + | 'email_provider_disabled' + | 'phone_provider_disabled' + | 'too_many_enrolled_mfa_factors' + | 'mfa_factor_name_conflict' + | 'mfa_factor_not_found' + | 'mfa_ip_address_mismatch' + | 'mfa_challenge_expired' + | 'mfa_verification_failed' + | 'mfa_verification_rejected' + | 'insufficient_aal' + | 'captcha_failed' + | 'saml_provider_disabled' + | 'manual_linking_disabled' + | 'sms_send_failed' + | 'email_not_confirmed' + | 'phone_not_confirmed' + | 'reauth_nonce_missing' + | 'saml_relay_state_not_found' + | 'saml_relay_state_expired' + | 'saml_idp_not_found' + | 'saml_assertion_no_user_id' + | 'saml_assertion_no_email' + | 'user_already_exists' + | 'sso_provider_not_found' + | 'saml_metadata_fetch_failed' + | 'saml_idp_already_exists' + | 'sso_domain_already_exists' + | 'saml_entity_id_mismatch' + | 'conflict' + | 'provider_disabled' + | 'user_sso_managed' + | 'reauthentication_needed' + | 'same_password' + | 'reauthentication_not_valid' + | 'otp_expired' + | 'otp_disabled' + | 'identity_not_found' + | 'weak_password' + | 'over_request_rate_limit' + | 'over_email_send_rate_limit' + | 'over_sms_send_rate_limit' + | 'bad_code_verifier' diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 2e4336e22..1d35694f7 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,13 +1,25 @@ import { WeakPasswordReasons } from './types' +import { ErrorCode } from './error-codes' export class AuthError extends Error { + /** + * Error code associated with the error. Most errors coming from + * HTTP responses will have a code, though some errors that occur + * before a response is received will not have one present. In that + * case {@link #status} will also be undefined. + */ + code: ErrorCode | string | undefined + + /** HTTP status code that caused the error. */ status: number | undefined + protected __isAuthError = true - constructor(message: string, status?: number) { + constructor(message: string, status?: number, code?: string) { super(message) this.name = 'AuthError' this.status = status + this.code = code } } @@ -18,18 +30,11 @@ export function isAuthError(error: unknown): error is AuthError { export class AuthApiError extends AuthError { status: number - constructor(message: string, status: number) { - super(message, status) + constructor(message: string, status: number, code: string | undefined) { + super(message, status, code) this.name = 'AuthApiError' this.status = status - } - - toJSON() { - return { - name: this.name, - message: this.message, - status: this.status, - } + this.code = code } } @@ -50,43 +55,36 @@ export class AuthUnknownError extends AuthError { export class CustomAuthError extends AuthError { name: string status: number - constructor(message: string, name: string, status: number) { - super(message) + + constructor(message: string, name: string, status: number, code: string | undefined) { + super(message, status, code) this.name = name this.status = status } - - toJSON() { - return { - name: this.name, - message: this.message, - status: this.status, - } - } } export class AuthSessionMissingError extends CustomAuthError { constructor() { - super('Auth session missing!', 'AuthSessionMissingError', 400) + super('Auth session missing!', 'AuthSessionMissingError', 400, undefined) } } export class AuthInvalidTokenResponseError extends CustomAuthError { constructor() { - super('Auth session or user missing', 'AuthInvalidTokenResponseError', 500) + super('Auth session or user missing', 'AuthInvalidTokenResponseError', 500, undefined) } } export class AuthInvalidCredentialsError extends CustomAuthError { constructor(message: string) { - super(message, 'AuthInvalidCredentialsError', 400) + super(message, 'AuthInvalidCredentialsError', 400, undefined) } } export class AuthImplicitGrantRedirectError extends CustomAuthError { details: { error: string; code: string } | null = null constructor(message: string, details: { error: string; code: string } | null = null) { - super(message, 'AuthImplicitGrantRedirectError', 500) + super(message, 'AuthImplicitGrantRedirectError', 500, undefined) this.details = details } @@ -102,8 +100,9 @@ export class AuthImplicitGrantRedirectError extends CustomAuthError { export class AuthPKCEGrantCodeExchangeError extends CustomAuthError { details: { error: string; code: string } | null = null + constructor(message: string, details: { error: string; code: string } | null = null) { - super(message, 'AuthPKCEGrantCodeExchangeError', 500) + super(message, 'AuthPKCEGrantCodeExchangeError', 500, undefined) this.details = details } @@ -119,7 +118,7 @@ export class AuthPKCEGrantCodeExchangeError extends CustomAuthError { export class AuthRetryableFetchError extends CustomAuthError { constructor(message: string, status: number) { - super(message, 'AuthRetryableFetchError', status) + super(message, 'AuthRetryableFetchError', status, undefined) } } @@ -139,7 +138,7 @@ export class AuthWeakPasswordError extends CustomAuthError { reasons: WeakPasswordReasons[] constructor(message: string, status: number, reasons: string[]) { - super(message, 'AuthWeakPasswordError', status) + super(message, 'AuthWeakPasswordError', status, 'weak_password') this.reasons = reasons } diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 3ccccacc0..d37609143 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -1,4 +1,5 @@ -import { expiresAt, looksLikeFetchResponse } from './helpers' +import { API_VERSIONS, API_VERSION_HEADER_NAME } from './constants' +import { expiresAt, looksLikeFetchResponse, parseResponseAPIVersion } from './helpers' import { AuthResponse, AuthResponsePassword, @@ -52,23 +53,45 @@ async function handleError(error: unknown) { throw new AuthUnknownError(_getErrorMessage(e), e) } + let errorCode: string | undefined = undefined + + const responseAPIVersion = parseResponseAPIVersion(error) if ( - typeof data === 'object' && - data && - typeof data.weak_password === 'object' && - data.weak_password && - Array.isArray(data.weak_password.reasons) && - data.weak_password.reasons.length && - data.weak_password.reasons.reduce((a: boolean, i: any) => a && typeof i === 'string', true) + responseAPIVersion && + responseAPIVersion.getTime() >= API_VERSIONS['2024-01-01'].timestamp && + typeof data.code === 'string' ) { + errorCode = data.code + } else if (typeof data.error_code === 'string') { + errorCode = data.error_code + } + + if (!errorCode) { + // Legacy support for weak password errors, when there were no error codes + if ( + typeof data === 'object' && + data && + typeof data.weak_password === 'object' && + data.weak_password && + Array.isArray(data.weak_password.reasons) && + data.weak_password.reasons.length && + data.weak_password.reasons.reduce((a: boolean, i: any) => a && typeof i === 'string', true) + ) { + throw new AuthWeakPasswordError( + _getErrorMessage(data), + error.status, + data.weak_password.reasons + ) + } + } else if (errorCode === 'weak_password') { throw new AuthWeakPasswordError( _getErrorMessage(data), error.status, - data.weak_password.reasons + data.weak_password?.reasons || [] ) } - throw new AuthApiError(_getErrorMessage(data), error.status || 500) + throw new AuthApiError(_getErrorMessage(data), error.status || 500, errorCode) } const _getRequestParams = ( @@ -105,14 +128,23 @@ export async function _request( url: string, options?: GotrueRequestOptions ) { - const headers = { ...options?.headers } + const headers = { + ...options?.headers, + } + + if (!headers[API_VERSION_HEADER_NAME]) { + headers[API_VERSION_HEADER_NAME] = API_VERSIONS['2024-01-01'].name + } + if (options?.jwt) { headers['Authorization'] = `Bearer ${options.jwt}` } + const qs = options?.query ?? {} if (options?.redirectTo) { qs['redirect_to'] = options.redirectTo } + const queryString = Object.keys(qs).length ? '?' + new URLSearchParams(qs).toString() : '' const data = await _handleRequest( fetcher, diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 16c3ebce6..900582179 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,4 +1,6 @@ +import { API_VERSION_HEADER_NAME } from './constants' import { SupportedStorage } from './types' + export function expiresAt(expiresIn: number) { const timeNow = Math.round(Date.now() / 1000) return timeNow + expiresIn @@ -304,3 +306,24 @@ export async function generatePKCEChallenge(verifier: string) { const hashed = await sha256(verifier) return base64urlencode(hashed) } + +const API_VERSION_REGEX = /^2[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])$/i + +export function parseResponseAPIVersion(response: Response) { + const apiVersion = response.headers.get(API_VERSION_HEADER_NAME) + + if (!apiVersion) { + return null + } + + if (!apiVersion.match(API_VERSION_REGEX)) { + return null + } + + try { + const date = new Date(`${apiVersion}T00:00:00.0Z`) + return date + } catch (e: any) { + return null + } +}