From 99821f4a1f6fdb3a222cd0f660210016e6cc823e Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Tue, 26 Mar 2024 14:07:11 +0700 Subject: [PATCH] feat: add support for error codes (#855) Adds support for error codes. All `AuthError` descendants will now have a `code` property which will encode (when present and supported by the server) the reason why the error occurred. To support this, the library will now advertise `X-Supabase-Api-Version: 2024-01-01` which is the first version of a new versioning strategy that supports a different encoding for error responses. See: - https://github.com/supabase/gotrue/pull/1377 --- 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 | 24 +++++++++++ test/fetch.test.ts | 97 +++++++++++++++++++++++++++++++++++++++++- test/helpers.test.ts | 30 ++++++++++++- 7 files changed, 299 insertions(+), 40 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..010f751aa 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, @@ -35,7 +36,7 @@ const _getErrorMessage = (err: any): string => const NETWORK_ERROR_CODES = [502, 503, 504] -async function handleError(error: unknown) { +export async function handleError(error: unknown) { if (!looksLikeFetchResponse(error)) { throw new AuthRetryableFetchError(_getErrorMessage(error), 0) } @@ -52,23 +53,47 @@ async function handleError(error: unknown) { throw new AuthUnknownError(_getErrorMessage(e), e) } + let errorCode: string | undefined = undefined + + const responseAPIVersion = parseResponseAPIVersion(error) if ( + responseAPIVersion && + responseAPIVersion.getTime() >= API_VERSIONS['2024-01-01'].timestamp && 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) + typeof data.code === 'string' ) { + errorCode = data.code + } else if (typeof data === 'object' && data && 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 +130,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 8c9b4cb37..22e22c978 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 @@ -320,3 +322,25 @@ export async function getCodeChallengeAndMethod( const codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256' return [codeChallenge, codeChallengeMethod] } + +/** Parses the API version which is 2YYY-MM-DD. */ +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 + } +} diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 394c22999..c6ca89d90 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -1,8 +1,9 @@ import { MockServer } from 'jest-mock-server' // @ts-ignore import fetch from '@supabase/node-fetch' +import { API_VERSION_HEADER_NAME } from '../src/lib/constants' import { AuthUnknownError, AuthApiError, AuthRetryableFetchError } from '../src/lib/errors' -import { _request } from '../src/lib/fetch' +import { _request, handleError } from '../src/lib/fetch' describe('fetch', () => { const server = new MockServer() @@ -113,6 +114,11 @@ describe('fetch', () => { json: async () => { return { message: 'invalid params' } }, + headers: { + get: () => { + return null + }, + }, } }) as unknown as typeof fetch @@ -122,3 +128,92 @@ describe('fetch', () => { }) }) }) + +describe('handleError', () => { + ;[ + { + name: 'without API version and error code', + code: 'error_code', + ename: 'AuthApiError', + response: new Response( + JSON.stringify({ + code: 400, + msg: 'Error code message', + error_code: 'error_code', + }), + { + status: 400, + statusText: 'Bad Request', + } + ), + }, + { + name: 'without API version and weak password error code with payload', + code: 'weak_password', + ename: 'AuthWeakPasswordError', + response: new Response( + JSON.stringify({ + code: 400, + msg: 'Error code message', + error_code: 'weak_password', + weak_password: { + reasons: ['characters'], + }, + }), + { + status: 400, + statusText: 'Bad Request', + } + ), + }, + { + name: 'without API version, no error code and weak_password payload', + code: 'weak_password', + ename: 'AuthWeakPasswordError', + response: new Response( + JSON.stringify({ + msg: 'Error code message', + weak_password: { + reasons: ['characters'], + }, + }), + { + status: 400, + statusText: 'Bad Request', + } + ), + }, + { + name: 'with API version 2024-01-01 and error code', + code: 'error_code', + ename: 'AuthApiError', + response: new Response( + JSON.stringify({ + code: 'error_code', + message: 'Error code message', + }), + { + status: 400, + statusText: 'Bad Request', + headers: { + [API_VERSION_HEADER_NAME]: '2024-01-01', + }, + } + ), + }, + ].forEach((example) => { + it(`should handle error response ${example.name}`, async () => { + let error: any = null + + try { + await handleError(example.response) + } catch (e: any) { + error = e + } + + expect(error).not.toBeNull() + expect(error.name).toEqual(example.ename) + expect(error.code).toEqual(example.code) + }) + }) +}) diff --git a/test/helpers.test.ts b/test/helpers.test.ts index f98503d06..4cc0cfc83 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -1,4 +1,4 @@ -import { parseParametersFromURL } from '../src/lib/helpers' +import { parseParametersFromURL, parseResponseAPIVersion } from '../src/lib/helpers' describe('parseParametersFromURL', () => { it('should parse parameters from a URL with query params only', () => { @@ -43,3 +43,31 @@ describe('parseParametersFromURL', () => { }) }) }) + +describe('parseResponseAPIVersion', () => { + it('should parse valid dates', () => { + expect( + parseResponseAPIVersion({ + headers: { + get: () => { + return '2024-01-01' + }, + }, + } as any) + ).toEqual(new Date('2024-01-01T00:00:00.0Z')) + }) + + it('should return null on invalid dates', () => { + ;['2024-01-32', '', 'notadate', 'Sat Feb 24 2024 17:59:17 GMT+0100'].forEach((example) => { + expect( + parseResponseAPIVersion({ + headers: { + get: () => { + return example + }, + }, + } as any) + ).toBeNull() + }) + }) +})