Skip to content

Commit

Permalink
feat: add support for error codes (#855)
Browse files Browse the repository at this point in the history
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:
- supabase/auth#1377
  • Loading branch information
hf authored Mar 26, 2024
1 parent 9a02980 commit 99821f4
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 40 deletions.
8 changes: 8 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
}
71 changes: 71 additions & 0 deletions src/lib/error-codes.ts
Original file line number Diff line number Diff line change
@@ -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'
55 changes: 27 additions & 28 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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
}
}

Expand All @@ -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
}
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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)
}
}

Expand All @@ -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
}
Expand Down
54 changes: 44 additions & 10 deletions src/lib/fetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 = (
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
}
Loading

0 comments on commit 99821f4

Please sign in to comment.