From f16e39bcaa5552372bbc6ff58dd2bd7e84136436 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Fri, 7 Aug 2026 14:16:12 +0530 Subject: [PATCH 1/2] docs: add public API audit and error taxonomy freeze design --- ...-api-audit-error-taxonomy-freeze-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md diff --git a/.design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md b/.design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md new file mode 100644 index 00000000..c90d5a5c --- /dev/null +++ b/.design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md @@ -0,0 +1,147 @@ +# Public API / Type Surface Audit + Error Taxonomy Freeze + +**Ticket:** [SDK-10043](https://auth0team.atlassian.net/browse/SDK-10043) (parent: SDK-9737 React Native V6) +**Date:** 2026-08-07 +**Branch:** `feat/android-ephemeral-session` (worktree), based on `v6-development` + +## Goal + +Before v6 GA, freeze the public surface so post-GA additions can't happen by accident, and give +consumers one predictable error contract across iOS, Android, and web. + +Two deliverables from the ticket: + +1. Audit every exported type/method in `src/index.ts`; remove or rename anything inconsistent. +2. Freeze a single normalized error taxonomy shared by native and web adapters. + +## Baseline + +Measured on `v6-development` (`ad16ebe`) via the TypeScript compiler, not by reading barrels: + +- **122 public exports** from `src/index.ts` +- `yarn typecheck` passes +- `ErrorCodes.spec.ts` passes (17 tests) + +`users()` / `IUsersClient` already removed on `v6-development` in `ad16ebe`, so the Management API +surface is out of scope here (SDK-10042 owns it). + +## Audit Findings + +### Error taxonomy + +| # | Finding | Evidence | +|---|---------|----------| +| 1 | `MyAccountErrorCodes` does not exist, yet `EXAMPLES.md:1822` imports it and switches on three of its members | `grep -rn MyAccountErrorCodes src` → 0 hits | +| 2 | `MyAccountError.type` is an RFC 7807 **URI**, not a normalized code, unlike all five siblings | `MyAccountError.ts:24` | +| 3 | `type` is declared `string` on all six classes — no exhaustiveness checking, no autocomplete | 6× `public readonly type: string` | +| 4 | No exported code-union aliases, so a consumer cannot type a parameter that accepts a code | `src/index.ts` | +| 5 | `TimeoutError` has no `type` at all, so it sits outside the taxonomy | `fetchWithTimeout.ts:3` | + +Finding 2 compounds finding 1: the documented `case MyAccountErrorCodes.ENROLLMENT_FAILED` could +never match a URI even if the constant existed. This is the single most consequential fix in this +spec, and the only intentional behavior change. + +### Type surface + +| # | Finding | Evidence | +|---|---------|----------| +| 6 | Snake_case HTTP wire types are public: `NativeCredentialsResponse`, `SSOCredentialsResponse` | used only by `Credentials.fromResponse` / `AuthenticationOrchestrator` | +| 7 | Adapter-constructor-only options are public: `NativeAuth0Options`, `WebAuth0Options` | only referenced in `platforms/*/adapters` + `factory/` | +| 8 | `src/exports/` (5 files) is dead **and** drifted — unreachable from `index.ts`, missing `MfaError`/`MyAccountError`/`MfaErrorCodes`, and imports from `'../index'` | `grep "exports/" src/index.ts package.json` → 0 hits | +| 9 | Only `IMfaClient` is exported; the five sibling interfaces returned by `Auth0` getters are unnameable | `src/index.ts:27` | +| 10 | `Auth0ContextInterface` / `AuthState` unexported, though `useAuth0()` returns the former | `hooks/Auth0Context.ts:50` | +| 11 | `Auth0` has no named export (default only), so `import { Auth0 }` fails | `src/index.ts:30` | +| 12 | `DPoPHeadersParams` breaks the `...Parameters` suffix used by all 70 sibling parameter types | `types/common.ts:436` | + +## Explicitly Rejected + +Recorded so they aren't re-litigated later. + +- **Consolidating "duplicate" type pairs.** `MfaChallengeResult`/`MfaChallengeResponse`, + `Factor`/`MfaFactor`, `EnrollmentChallenge`/`MfaEnrollmentChallenge` are *distinct domains* + (Flexible Factors Grant vs legacy `multifactorChallenge`; My Account API vs MFA grant). Merging + them would conflate unrelated wire shapes. `DeliveryMethod` + `PasswordlessDeliveryMethod` is an + intentional const + derived-union pair, already documented as such. +- **Renaming `PasskeyErrorCodes` keys to match values.** The `PASSKEY_*` value prefix is what keeps + passkey codes globally unique across the taxonomy. Renaming keys breaks + `PasskeyErrorCodes.NOT_AVAILABLE` for no functional gain. Documented instead. +- **Un-exporting `NativeAuthorizeOptions` / `NativeClearSessionOptions` / `WebAuthorizeOptions` / + `WebClearSessionOptions`.** Initially flagged as internal; verification showed the first two appear + in `useAuth0()`'s public signatures (`Auth0Context.ts:60,72`) and all four in + `IWebAuthProvider.authorize()` / `.clearSession()`. Un-exporting them would make the SDK's primary + auth call untypeable. They stay public. +- **Dropping the `I` interface prefix.** SDK-10045 owns that rename; doing it here would conflict. + +## Design + +### A. Taxonomy freeze + +1. **Add `MyAccountErrorCodes`** with normalized SCREAMING_SNAKE members, and map + `MyAccountError.type` onto it. Preserve the RFC 7807 URI on a new `typeUri` field so no + information is lost. Both native and web My Account adapters feed the same mapper. +2. **Export six code unions** — `WebAuthErrorCode`, `CredentialsManagerErrorCode`, `DPoPErrorCode`, + `MfaErrorCode`, `PasskeyErrorCode`, `MyAccountErrorCode` — each derived as + `(typeof XCodes)[keyof typeof XCodes]` so union and constants cannot drift. Plus an + `Auth0ErrorCode` umbrella union. +3. **Narrow each `type` to its own union** (finding 3). Technically breaking for anyone assigning an + arbitrary string to `.type`, which is not a supported use. +4. **Give `TimeoutError` a `type`** so it joins the taxonomy. Wire-level `code: 'timeout'` is left + alone — it is what the transport actually emits. + +### B. Surface cleanup + +5. Replace `export * from './types'` with an explicit public export list, dropping the 2 wire types + (finding 6) and 2 adapter-option types (finding 7). +6. Delete `src/exports/` (finding 8). +7. Add missing exports: `IAuth0Client`, `IWebAuthProvider`, `ICredentialsManager`, + `IAuthenticationProvider`, `IMyAccountClient`, `IPasswordlessClient`, `Auth0ContextInterface`, + `AuthState`, and a named `Auth0` (findings 9–11). +8. Rename `DPoPHeadersParams` → `DPoPHeadersParameters`, keeping a `@deprecated` alias (finding 12). + +### C. Freeze mechanism + +This is what actually satisfies "prevents accidental breaking changes post-GA" — the audit is a +one-time cleanup; the test is what holds the line. + +9. **Public-surface snapshot test** asserting the exact sorted export list of `src/index.ts`. Any + addition or removal fails CI until the snapshot is updated deliberately. +10. **Taxonomy invariant test**: every error class exposes `type`; every codes object has a matching + exported union; cross-class code values are unique except the documented `UNKNOWN_ERROR` + overlap; `PasskeyErrorCodes`' prefix asymmetry is asserted as intentional. + +### D. Documentation + +11. Fix the broken `EXAMPLES.md:1822` block. +12. Document the frozen taxonomy — the six code objects, their unions, and the `type` vs `code` vs + `typeUri` distinction — as the stable public contract. + +## Expected Outcome + +122 → ~123 exports: 4 removed, ~9 added, 1 renamed with alias. Every remaining export is +intentional and covered by the snapshot test. + +## Testing + +- Snapshot test for the export list (new) +- Taxonomy invariant test (new) +- `MyAccountError` mapping test covering both URI→code normalization and `typeUri` preservation (new; + the class currently has no spec file) +- Existing `ErrorCodes.spec.ts` extended for `MyAccountErrorCodes` +- `yarn typecheck` and full `yarn test` must pass + +## Risks + +- **`MyAccountError.type` change is breaking** for anyone switching on the URI. Mitigated by + `typeUri`, and by the fact that the only documented usage was already broken. +- **Narrowing `type` to a union** surfaces as a compile error in code that assigned arbitrary + strings. Intended — that is the freeze. +- Migration-guide entries belong to SDK-10047; this spec only notes what changed. + +## Out of Scope + +- Dropping the `I` prefix (SDK-10045) +- Full native delegation asymmetry (SDK-10041) +- Migration guide authoring (SDK-10047) +- `v6-development` does not yet contain master's deprecation commits `113a610` / `a47ad73`, so the + legacy MFA methods on `IAuthenticationProvider` remain undeprecated there. Pre-existing branch + gap, tracked separately. From edaf4a8c3221e248546972a885a1aa94a1e92055 Mon Sep 17 00:00:00 2001 From: Nandan Prabhu Date: Fri, 7 Aug 2026 16:12:13 +0530 Subject: [PATCH 2/2] refactor!: audit the public API surface and freeze the error taxonomy Prepares the v6 public contract so post-GA changes cannot break consumers accidentally (SDK-10043). Error taxonomy: - Add MyAccountErrorCodes, completing the set of six code objects. MyAccountError previously exposed a raw RFC 7807 type URI on `type`, unlike every sibling class; `type` is now a normalized code and the original URI is preserved on the new `typeUri` property. - Export a derived union per class (WebAuthErrorCode, ..., MyAccountErrorCode) computed from the constants object, so the runtime values and the type cannot drift, and narrow each class's `type` to its union. - Add Auth0ErrorCode as the umbrella union, and bring TimeoutError into the taxonomy with `type: 'TIMEOUT_ERROR'`. Surface cleanup (122 -> 136 exports): - Un-export four internal wire/config shapes: NativeAuth0Options, WebAuth0Options, NativeCredentialsResponse, SSOCredentialsResponse. - Export types that already appeared in public signatures but were unreachable from the entry point: IAuth0Client and its five sub-provider siblings, Auth0ContextInterface, AuthState, SafariViewControllerPresentationStyle. - Replace the blanket `export * from './types'` with explicit sectioned exports, and delete the dead, drifted src/exports/ barrel files. - Rename DPoPHeadersParams to DPoPHeadersParameters for consistency with the other `...Parameters` types, keeping a deprecated alias. Freeze mechanism: - publicApiSurface.spec.ts asserts the exact export list via the TypeScript compiler API, so type-only regressions are caught too. - errorTaxonomy.spec.ts asserts the structural invariants: every class carries a normalized `type`, falls back to a terminal unknown code, and keeps codes unique across classes except for three documented overlaps. Docs: document the taxonomy and the type/code/typeUri distinction as the stable contract, and fix two example blocks that referenced exports which never existed (MyAccountErrorCodes before this change, and AuthenticationException / AuthenticationErrorCodes, which do not exist at all). BREAKING CHANGE: MyAccountError.type is now a normalized MyAccountErrorCodes value rather than an RFC 7807 type URI; read `typeUri` for the raw URI. The internal types NativeAuth0Options, WebAuth0Options, NativeCredentialsResponse and SSOCredentialsResponse are no longer exported. --- ...-api-audit-error-taxonomy-freeze-design.md | 147 ------------ EXAMPLES.md | 62 ++--- README.md | 54 +++++ src/Auth0.ts | 4 +- src/__tests__/publicApiSurface.spec.ts | 213 ++++++++++++++++++ src/core/interfaces/IAuth0Client.ts | 6 +- src/core/models/CredentialsManagerError.ts | 13 +- src/core/models/DPoPError.ts | 13 +- src/core/models/MfaError.ts | 12 +- src/core/models/MyAccountError.ts | 126 ++++++++++- src/core/models/PasskeyError.ts | 21 +- src/core/models/WebAuthError.ts | 17 +- src/core/models/__tests__/ErrorCodes.spec.ts | 47 ++++ .../models/__tests__/MyAccountError.spec.ts | 151 +++++++++++++ .../models/__tests__/errorTaxonomy.spec.ts | 175 ++++++++++++++ src/core/models/errorCodes.ts | 33 +++ src/core/models/index.ts | 9 +- src/core/utils/fetchWithTimeout.ts | 13 ++ src/exports/classes.ts | 9 - src/exports/enums.ts | 16 -- src/exports/hooks.ts | 1 - src/exports/index.ts | 4 - src/exports/interface.ts | 4 - src/hooks/Auth0Context.ts | 4 +- src/hooks/Auth0Provider.tsx | 4 +- src/index.ts | 89 +++++++- .../native/adapters/NativeAuth0Client.ts | 8 +- src/platforms/native/bridge/INativeBridge.ts | 6 +- .../native/bridge/NativeBridgeManager.ts | 4 +- src/platforms/web/adapters/WebAuth0Client.ts | 8 +- .../__tests__/WebMyAccountClient.spec.ts | 6 +- src/types/common.ts | 9 +- 32 files changed, 1036 insertions(+), 252 deletions(-) delete mode 100644 .design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md create mode 100644 src/__tests__/publicApiSurface.spec.ts create mode 100644 src/core/models/__tests__/MyAccountError.spec.ts create mode 100644 src/core/models/__tests__/errorTaxonomy.spec.ts create mode 100644 src/core/models/errorCodes.ts delete mode 100644 src/exports/classes.ts delete mode 100644 src/exports/enums.ts delete mode 100644 src/exports/hooks.ts delete mode 100644 src/exports/index.ts delete mode 100644 src/exports/interface.ts diff --git a/.design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md b/.design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md deleted file mode 100644 index c90d5a5c..00000000 --- a/.design/specs/2026-08-07-public-api-audit-error-taxonomy-freeze-design.md +++ /dev/null @@ -1,147 +0,0 @@ -# Public API / Type Surface Audit + Error Taxonomy Freeze - -**Ticket:** [SDK-10043](https://auth0team.atlassian.net/browse/SDK-10043) (parent: SDK-9737 React Native V6) -**Date:** 2026-08-07 -**Branch:** `feat/android-ephemeral-session` (worktree), based on `v6-development` - -## Goal - -Before v6 GA, freeze the public surface so post-GA additions can't happen by accident, and give -consumers one predictable error contract across iOS, Android, and web. - -Two deliverables from the ticket: - -1. Audit every exported type/method in `src/index.ts`; remove or rename anything inconsistent. -2. Freeze a single normalized error taxonomy shared by native and web adapters. - -## Baseline - -Measured on `v6-development` (`ad16ebe`) via the TypeScript compiler, not by reading barrels: - -- **122 public exports** from `src/index.ts` -- `yarn typecheck` passes -- `ErrorCodes.spec.ts` passes (17 tests) - -`users()` / `IUsersClient` already removed on `v6-development` in `ad16ebe`, so the Management API -surface is out of scope here (SDK-10042 owns it). - -## Audit Findings - -### Error taxonomy - -| # | Finding | Evidence | -|---|---------|----------| -| 1 | `MyAccountErrorCodes` does not exist, yet `EXAMPLES.md:1822` imports it and switches on three of its members | `grep -rn MyAccountErrorCodes src` → 0 hits | -| 2 | `MyAccountError.type` is an RFC 7807 **URI**, not a normalized code, unlike all five siblings | `MyAccountError.ts:24` | -| 3 | `type` is declared `string` on all six classes — no exhaustiveness checking, no autocomplete | 6× `public readonly type: string` | -| 4 | No exported code-union aliases, so a consumer cannot type a parameter that accepts a code | `src/index.ts` | -| 5 | `TimeoutError` has no `type` at all, so it sits outside the taxonomy | `fetchWithTimeout.ts:3` | - -Finding 2 compounds finding 1: the documented `case MyAccountErrorCodes.ENROLLMENT_FAILED` could -never match a URI even if the constant existed. This is the single most consequential fix in this -spec, and the only intentional behavior change. - -### Type surface - -| # | Finding | Evidence | -|---|---------|----------| -| 6 | Snake_case HTTP wire types are public: `NativeCredentialsResponse`, `SSOCredentialsResponse` | used only by `Credentials.fromResponse` / `AuthenticationOrchestrator` | -| 7 | Adapter-constructor-only options are public: `NativeAuth0Options`, `WebAuth0Options` | only referenced in `platforms/*/adapters` + `factory/` | -| 8 | `src/exports/` (5 files) is dead **and** drifted — unreachable from `index.ts`, missing `MfaError`/`MyAccountError`/`MfaErrorCodes`, and imports from `'../index'` | `grep "exports/" src/index.ts package.json` → 0 hits | -| 9 | Only `IMfaClient` is exported; the five sibling interfaces returned by `Auth0` getters are unnameable | `src/index.ts:27` | -| 10 | `Auth0ContextInterface` / `AuthState` unexported, though `useAuth0()` returns the former | `hooks/Auth0Context.ts:50` | -| 11 | `Auth0` has no named export (default only), so `import { Auth0 }` fails | `src/index.ts:30` | -| 12 | `DPoPHeadersParams` breaks the `...Parameters` suffix used by all 70 sibling parameter types | `types/common.ts:436` | - -## Explicitly Rejected - -Recorded so they aren't re-litigated later. - -- **Consolidating "duplicate" type pairs.** `MfaChallengeResult`/`MfaChallengeResponse`, - `Factor`/`MfaFactor`, `EnrollmentChallenge`/`MfaEnrollmentChallenge` are *distinct domains* - (Flexible Factors Grant vs legacy `multifactorChallenge`; My Account API vs MFA grant). Merging - them would conflate unrelated wire shapes. `DeliveryMethod` + `PasswordlessDeliveryMethod` is an - intentional const + derived-union pair, already documented as such. -- **Renaming `PasskeyErrorCodes` keys to match values.** The `PASSKEY_*` value prefix is what keeps - passkey codes globally unique across the taxonomy. Renaming keys breaks - `PasskeyErrorCodes.NOT_AVAILABLE` for no functional gain. Documented instead. -- **Un-exporting `NativeAuthorizeOptions` / `NativeClearSessionOptions` / `WebAuthorizeOptions` / - `WebClearSessionOptions`.** Initially flagged as internal; verification showed the first two appear - in `useAuth0()`'s public signatures (`Auth0Context.ts:60,72`) and all four in - `IWebAuthProvider.authorize()` / `.clearSession()`. Un-exporting them would make the SDK's primary - auth call untypeable. They stay public. -- **Dropping the `I` interface prefix.** SDK-10045 owns that rename; doing it here would conflict. - -## Design - -### A. Taxonomy freeze - -1. **Add `MyAccountErrorCodes`** with normalized SCREAMING_SNAKE members, and map - `MyAccountError.type` onto it. Preserve the RFC 7807 URI on a new `typeUri` field so no - information is lost. Both native and web My Account adapters feed the same mapper. -2. **Export six code unions** — `WebAuthErrorCode`, `CredentialsManagerErrorCode`, `DPoPErrorCode`, - `MfaErrorCode`, `PasskeyErrorCode`, `MyAccountErrorCode` — each derived as - `(typeof XCodes)[keyof typeof XCodes]` so union and constants cannot drift. Plus an - `Auth0ErrorCode` umbrella union. -3. **Narrow each `type` to its own union** (finding 3). Technically breaking for anyone assigning an - arbitrary string to `.type`, which is not a supported use. -4. **Give `TimeoutError` a `type`** so it joins the taxonomy. Wire-level `code: 'timeout'` is left - alone — it is what the transport actually emits. - -### B. Surface cleanup - -5. Replace `export * from './types'` with an explicit public export list, dropping the 2 wire types - (finding 6) and 2 adapter-option types (finding 7). -6. Delete `src/exports/` (finding 8). -7. Add missing exports: `IAuth0Client`, `IWebAuthProvider`, `ICredentialsManager`, - `IAuthenticationProvider`, `IMyAccountClient`, `IPasswordlessClient`, `Auth0ContextInterface`, - `AuthState`, and a named `Auth0` (findings 9–11). -8. Rename `DPoPHeadersParams` → `DPoPHeadersParameters`, keeping a `@deprecated` alias (finding 12). - -### C. Freeze mechanism - -This is what actually satisfies "prevents accidental breaking changes post-GA" — the audit is a -one-time cleanup; the test is what holds the line. - -9. **Public-surface snapshot test** asserting the exact sorted export list of `src/index.ts`. Any - addition or removal fails CI until the snapshot is updated deliberately. -10. **Taxonomy invariant test**: every error class exposes `type`; every codes object has a matching - exported union; cross-class code values are unique except the documented `UNKNOWN_ERROR` - overlap; `PasskeyErrorCodes`' prefix asymmetry is asserted as intentional. - -### D. Documentation - -11. Fix the broken `EXAMPLES.md:1822` block. -12. Document the frozen taxonomy — the six code objects, their unions, and the `type` vs `code` vs - `typeUri` distinction — as the stable public contract. - -## Expected Outcome - -122 → ~123 exports: 4 removed, ~9 added, 1 renamed with alias. Every remaining export is -intentional and covered by the snapshot test. - -## Testing - -- Snapshot test for the export list (new) -- Taxonomy invariant test (new) -- `MyAccountError` mapping test covering both URI→code normalization and `typeUri` preservation (new; - the class currently has no spec file) -- Existing `ErrorCodes.spec.ts` extended for `MyAccountErrorCodes` -- `yarn typecheck` and full `yarn test` must pass - -## Risks - -- **`MyAccountError.type` change is breaking** for anyone switching on the URI. Mitigated by - `typeUri`, and by the fact that the only documented usage was already broken. -- **Narrowing `type` to a union** surfaces as a compile error in code that assigned arbitrary - strings. Intended — that is the freeze. -- Migration-guide entries belong to SDK-10047; this spec only notes what changed. - -## Out of Scope - -- Dropping the `I` prefix (SDK-10045) -- Full native delegation asymmetry (SDK-10041) -- Migration guide authoring (SDK-10047) -- `v6-development` does not yet contain master's deprecation commits `113a610` / `a47ad73`, so the - legacy MFA methods on `IAuthenticationProvider` remain undeprecated there. Pre-existing branch - gap, tracked separately. diff --git a/EXAMPLES.md b/EXAMPLES.md index 6f6b290f..29fccec9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -985,11 +985,7 @@ Custom Token Exchange allows you to exchange external identity provider tokens f ```typescript import React from 'react'; import { Button, Alert } from 'react-native'; -import { - useAuth0, - AuthenticationException, - AuthenticationErrorCodes, -} from 'react-native-auth0'; +import { useAuth0, AuthError } from 'react-native-auth0'; function TokenExchangeScreen() { const { customTokenExchange, user, error } = useAuth0(); @@ -1006,25 +1002,25 @@ function TokenExchangeScreen() { Alert.alert('Success', `Logged in as ${user?.name}`); } catch (e) { - if (e instanceof AuthenticationException) { - switch (e.type) { - case AuthenticationErrorCodes.INVALID_SUBJECT_TOKEN: - Alert.alert('Error', 'The external token is invalid or expired'); + if (e instanceof AuthError) { + // Custom Token Exchange surfaces the OAuth 2.0 error from the token + // endpoint on `code`. See the RFC 8693 error responses and your Action's + // own failure reasons. + switch (e.code) { + case 'invalid_request': + Alert.alert('Error', 'The external token or token type is invalid'); break; - case AuthenticationErrorCodes.UNSUPPORTED_TOKEN_TYPE: - Alert.alert('Error', 'The token type is not supported'); + case 'invalid_grant': + Alert.alert('Error', 'The external token was rejected or expired'); break; - case AuthenticationErrorCodes.TOKEN_EXCHANGE_NOT_CONFIGURED: + case 'unsupported_grant_type': Alert.alert( 'Error', - 'Custom Token Exchange is not configured for this tenant' + 'Custom Token Exchange is not enabled for this tenant' ); break; - case AuthenticationErrorCodes.TOKEN_VALIDATION_FAILED: - Alert.alert('Error', 'Token validation failed in Auth0 Action'); - break; - case AuthenticationErrorCodes.NETWORK_ERROR: - Alert.alert('Error', 'Network error. Please check your connection.'); + case 'access_denied': + Alert.alert('Error', 'Token validation failed in the Auth0 Action'); break; default: Alert.alert('Error', e.message); @@ -1042,10 +1038,7 @@ function TokenExchangeScreen() { ### Using Custom Token Exchange with Auth0 Class ```typescript -import Auth0, { - AuthenticationException, - AuthenticationErrorCodes, -} from 'react-native-auth0'; +import Auth0, { AuthError } from 'react-native-auth0'; const auth0 = new Auth0({ domain: 'YOUR_AUTH0_DOMAIN', @@ -1064,14 +1057,14 @@ async function exchangeExternalToken(externalToken: string) { console.log('Exchange successful:', credentials); return credentials; } catch (error) { - if (error instanceof AuthenticationException) { + if (error instanceof AuthError) { // Access the underlying error details - console.error('Error type:', error.type); + console.error('Error code:', error.code); console.error('Error message:', error.message); - console.error('Underlying error code:', error.underlyingError.code); + console.error('HTTP status:', error.status); - // Handle specific error types - if (error.type === AuthenticationErrorCodes.INVALID_SUBJECT_TOKEN) { + // Handle specific error codes + if (error.code === 'invalid_grant') { // Token is invalid or expired - prompt user to re-authenticate throw new Error('Please authenticate again with the external provider'); } @@ -1849,6 +1842,21 @@ try { } ``` +The My Account API reports failures as [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) +type URIs. `MyAccountError` normalizes those to a `MyAccountErrorCodes` value on `type` so your +error handling matches every other error class in the SDK, and preserves the original URI on +`typeUri` for logging or support tickets: + +```typescript +catch (e) { + if (e instanceof MyAccountError) { + console.log(e.type); // "UNAUTHORIZED" — normalized, switch on this + console.log(e.typeUri); // "https://auth0.com/api-errors/A0E-401" — raw, log this + console.log(e.statusCode); // 401 + } +} +``` + ### Platform Support | Platform | Support | Notes | diff --git a/README.md b/README.md index 4455fc7a..70f64271 100644 --- a/README.md +++ b/README.md @@ -668,6 +668,60 @@ The options for configuring the display of local authentication prompt, authenti > :warning: You need a real device to test Local Authentication for iOS. Local Authentication is not available in simulators. +### Error taxonomy + +Every error the SDK throws extends `AuthError` and carries a **normalized, platform-agnostic** +`type`. Switch on `type` — never on `code` — and your error handling behaves identically on iOS, +Android, and web. + +| Property | Use it for | +| --------- | ---------------------------------------------------------------------------------------------------------- | +| `type` | **Control flow.** A normalized code, stable across platforms. Compare against the `…ErrorCodes` constants. | +| `code` | **Diagnostics.** The raw code from the underlying platform SDK or wire response. Varies by platform. | +| `message` | Human-readable description. Not stable — do not parse it. | +| `status` | HTTP status, when the failure came from an HTTP response (`0` otherwise). | + +Each error class ships a companion constants object and a matching TypeScript union, so a `switch` +on `type` is exhaustively checked at compile time: + +| Error class | Constants | Type union | Thrown by | +| ------------------------- | ------------------------------ | ---------------------------------- | ----------------------------------------------- | +| `WebAuthError` | `WebAuthErrorCodes` | `WebAuthErrorCode` | `webAuth.authorize()`, `webAuth.clearSession()` | +| `CredentialsManagerError` | `CredentialsManagerErrorCodes` | `CredentialsManagerErrorCode` | `credentialsManager.*` | +| `MfaError` | `MfaErrorCodes` | `MfaErrorCode` | `mfa.*` | +| `PasskeyError` | `PasskeyErrorCodes` | `PasskeyErrorCode` | passkey signup/login and passkey enrollment | +| `MyAccountError` | `MyAccountErrorCodes` | `MyAccountErrorCode` | `myAccount.*` | +| `DPoPError` | `DPoPErrorCodes` | `DPoPErrorCode` | `getDPoPHeaders()` and DPoP key handling | +| `TimeoutError` | — | `type` is always `'TIMEOUT_ERROR'` | HTTP requests exceeding `timeout` | + +```typescript +import { WebAuthError, WebAuthErrorCodes } from 'react-native-auth0'; +import type { WebAuthErrorCode } from 'react-native-auth0'; + +function describe(type: WebAuthErrorCode): string { + switch (type) { + case WebAuthErrorCodes.USER_CANCELLED: + return 'Cancelled'; + case WebAuthErrorCodes.NETWORK_ERROR: + return 'Offline'; + default: + return 'Login failed'; + } +} +``` + +`Auth0ErrorCode` is the union of all of the above. Prefer the specific union when handling one error +class — it keeps `switch` statements exhaustive and rejects codes that cannot occur there. Reach for +`Auth0ErrorCode` only in generic code such as logging or telemetry. + +> **Stability.** These constants, their unions, and the `type` values they contain are the public +> error contract. Values will not be removed or renamed outside a major version. + +`MyAccountError` is the one class with an extra property: the My Account API reports failures as +[RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) type URIs, so `type` holds the normalized +code while `typeUri` preserves the original URI (e.g. `https://auth0.com/api-errors/A0E-401`) for +logging and support tickets. + ### Credentials Manager errors The Credentials Manager will only throw `CredentialsManagerError` exceptions. You can find more information in the details property of the exception. diff --git a/src/Auth0.ts b/src/Auth0.ts index 7c4c68f8..9029bf7c 100644 --- a/src/Auth0.ts +++ b/src/Auth0.ts @@ -3,7 +3,7 @@ import type { IMfaClient } from './core/interfaces/IMfaClient'; import { Auth0ClientFactory } from './factory/Auth0ClientFactory'; import type { Auth0Options, - DPoPHeadersParams, + DPoPHeadersParameters, CustomTokenExchangeParameters, PasskeySignupChallengeParameters, PasskeyLoginChallengeParameters, @@ -122,7 +122,7 @@ class Auth0 { * } * ``` */ - getDPoPHeaders(params: DPoPHeadersParams) { + getDPoPHeaders(params: DPoPHeadersParameters) { return this.client.getDPoPHeaders(params); } diff --git a/src/__tests__/publicApiSurface.spec.ts b/src/__tests__/publicApiSurface.spec.ts new file mode 100644 index 00000000..fd7aaefe --- /dev/null +++ b/src/__tests__/publicApiSurface.spec.ts @@ -0,0 +1,213 @@ +import * as path from 'path'; +import * as ts from 'typescript'; + +/** + * The frozen public API surface of `src/index.ts`. + * + * This list is the stable public contract for v6. Adding an entry is a minor + * change; **removing or renaming an entry is a breaking change** and must be + * treated as such (major version, deprecation cycle, changelog entry). + * + * If this test fails, do not "fix" it by regenerating the list. Confirm the + * change to the surface is intentional and versioned appropriately first. + */ +const FROZEN_PUBLIC_API = [ + 'ApiCredentials', + 'Auth0', + 'Auth0ContextInterface', + 'Auth0ErrorCode', + 'Auth0Options', + 'Auth0Provider', + 'AuthError', + 'AuthState', + 'AuthenticationMethod', + 'AuthenticationMethodType', + 'AuthenticationMethodTypes', + 'AuthorizeUrlParameters', + 'BiometricPolicy', + 'ClearSessionParameters', + 'ConfirmOTPEnrollmentParameters', + 'ConfirmPushNotificationEnrollmentParameters', + 'ConfirmRecoveryCodeEnrollmentParameters', + 'CreateUserParameters', + 'Credentials', + 'CredentialsManagerError', + 'CredentialsManagerErrorCode', + 'CredentialsManagerErrorCodes', + 'CustomTokenExchangeParameters', + 'DPoPError', + 'DPoPErrorCode', + 'DPoPErrorCodes', + 'DPoPHeadersParameters', + 'DPoPHeadersParams', + 'DeleteAuthenticationMethodByIdParameters', + 'DeliveryMethod', + 'EnrollEmailParameters', + 'EnrollPasskeyParameters', + 'EnrollPhoneParameters', + 'EnrollPushNotificationParameters', + 'EnrollRecoveryCodeParameters', + 'EnrollTOTPParameters', + 'EnrollmentChallenge', + 'ExchangeNativeSocialParameters', + 'ExchangeParameters', + 'Factor', + 'GetAuthenticationMethodByIdParameters', + 'GetAuthenticationMethodsParameters', + 'GetFactorsParameters', + 'GetTokenByPasskeyParameters', + 'IAuth0Client', + 'IAuthenticationProvider', + 'ICredentialsManager', + 'IMfaClient', + 'IMyAccountClient', + 'IPasswordlessClient', + 'IWebAuthProvider', + 'LocalAuthenticationLevel', + 'LocalAuthenticationOptions', + 'LocalAuthenticationStrategy', + 'LoginEmailParameters', + 'LoginSmsParameters', + 'LogoutUrlParameters', + 'MfaAuthenticator', + 'MfaChallengeResult', + 'MfaChallengeWithAuthenticatorParameters', + 'MfaEnrollEmailParameters', + 'MfaEnrollOtpParameters', + 'MfaEnrollParameters', + 'MfaEnrollPushParameters', + 'MfaEnrollSmsParameters', + 'MfaEnrollVoiceParameters', + 'MfaEnrollmentChallenge', + 'MfaError', + 'MfaErrorCode', + 'MfaErrorCodes', + 'MfaFactor', + 'MfaFactorType', + 'MfaGetAuthenticatorsParameters', + 'MfaOobEnrollmentChallenge', + 'MfaPushEnrollmentChallenge', + 'MfaRecoveryCodeEnrollmentChallenge', + 'MfaRequiredErrorPayload', + 'MfaRequirements', + 'MfaTotpEnrollmentChallenge', + 'MfaVerifyOobParameters', + 'MfaVerifyOtpParameters', + 'MfaVerifyParameters', + 'MfaVerifyRecoveryCodeParameters', + 'MyAccountError', + 'MyAccountErrorCode', + 'MyAccountErrorCodes', + 'NativeAuthorizeOptions', + 'NativeClearSessionOptions', + 'PasskeyAuthenticationMethod', + 'PasskeyChallengeResponse', + 'PasskeyEnrollmentChallengeParameters', + 'PasskeyEnrollmentChallengeResponse', + 'PasskeyError', + 'PasskeyErrorCode', + 'PasskeyErrorCodes', + 'PasskeyLoginChallengeParameters', + 'PasskeySignupChallengeParameters', + 'PasswordRealmParameters', + 'PasswordlessChallenge', + 'PasswordlessChallengeEmailParameters', + 'PasswordlessChallengePhoneParameters', + 'PasswordlessDeliveryMethod', + 'PasswordlessEmailParameters', + 'PasswordlessLoginOtpParameters', + 'PasswordlessSmsParameters', + 'PreferredAuthenticationMethods', + 'RecoveryCodeEnrollmentChallenge', + 'RefreshTokenParameters', + 'ResetPasswordParameters', + 'RevokeOptions', + 'SSOExchangeParameters', + 'SafariViewControllerPresentationStyle', + 'SessionTransferCredentials', + 'TOTPEnrollmentChallenge', + 'TimeoutError', + 'TokenType', + 'UpdateAuthenticationMethodByIdParameters', + 'User', + 'UserInfoParameters', + 'WebAuthError', + 'WebAuthErrorCode', + 'WebAuthErrorCodes', + 'WebAuthorizeOptions', + 'WebAuthorizeParameters', + 'WebClearSessionOptions', + 'default', + 'parseIdToken', + 'useAuth0', +]; + +/** + * Resolves the actual exports of `src/index.ts` through the TypeScript compiler. + * + * A runtime `import * from '../index'` cannot be used here: most of the surface + * is type-only and erased at runtime, so it would silently miss regressions in + * the exported types — which are just as breaking as a missing class. + */ +function resolvePublicExports(): string[] { + const projectRoot = path.resolve(__dirname, '..', '..'); + const configPath = path.join(projectRoot, 'tsconfig.json'); + const entryPoint = path.join(projectRoot, 'src', 'index.ts'); + + const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ); + }, + } as ts.ParseConfigFileHost); + + if (!config) { + throw new Error(`Unable to parse ${configPath}`); + } + + const program = ts.createProgram([entryPoint], config.options); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(entryPoint); + + if (!sourceFile) { + throw new Error(`Unable to load ${entryPoint}`); + } + + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + + if (!moduleSymbol) { + throw new Error(`${entryPoint} is not a module`); + } + + return checker + .getExportsOfModule(moduleSymbol) + .map((symbol) => symbol.getName()) + .sort(); +} + +describe('public API surface', () => { + // Building a full TS program is slower than a normal unit test. + const actual = resolvePublicExports(); + + it('matches the frozen v6 contract exactly', () => { + expect(actual).toEqual([...FROZEN_PUBLIC_API].sort()); + }); + + it('exports nothing that has been removed from the contract', () => { + const frozen = new Set(FROZEN_PUBLIC_API); + expect(actual.filter((name) => !frozen.has(name))).toEqual([]); + }); + + it('still exports everything the contract promises', () => { + const exported = new Set(actual); + expect(FROZEN_PUBLIC_API.filter((name) => !exported.has(name))).toEqual([]); + }); + + it('exposes the default export under a named alias', () => { + // `default` alone is awkward for consumers doing `import { Auth0 }`. + expect(actual).toContain('default'); + expect(actual).toContain('Auth0'); + }); +}); diff --git a/src/core/interfaces/IAuth0Client.ts b/src/core/interfaces/IAuth0Client.ts index a1bcdc54..c4c52e04 100644 --- a/src/core/interfaces/IAuth0Client.ts +++ b/src/core/interfaces/IAuth0Client.ts @@ -5,7 +5,7 @@ import type { IMyAccountClient } from './IMyAccountClient'; import type { IPasswordlessClient } from './IPasswordlessClient'; import type { IMfaClient } from './IMfaClient'; import type { - DPoPHeadersParams, + DPoPHeadersParameters, CustomTokenExchangeParameters, PasskeySignupChallengeParameters, PasskeyLoginChallengeParameters, @@ -70,7 +70,9 @@ export interface IAuth0Client { * fetch('https://api.example.com/data', { headers }); * ``` */ - getDPoPHeaders(params: DPoPHeadersParams): Promise>; + getDPoPHeaders( + params: DPoPHeadersParameters + ): Promise>; /** * Performs a Custom Token Exchange using RFC 8693. diff --git a/src/core/models/CredentialsManagerError.ts b/src/core/models/CredentialsManagerError.ts index ed34289c..a601c94b 100644 --- a/src/core/models/CredentialsManagerError.ts +++ b/src/core/models/CredentialsManagerError.ts @@ -80,7 +80,16 @@ export const CredentialsManagerErrorCodes = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', } as const; -const ERROR_CODE_MAP: Record = { +/** + * A normalized Credentials Manager error code. + * + * Derived from {@link CredentialsManagerErrorCodes} so the union and the runtime + * constants cannot drift apart. + */ +export type CredentialsManagerErrorCode = + (typeof CredentialsManagerErrorCodes)[keyof typeof CredentialsManagerErrorCodes]; + +const ERROR_CODE_MAP: Record = { // --- Core CredentialsManager error codes --- INVALID_CREDENTIALS: CredentialsManagerErrorCodes.INVALID_CREDENTIALS, NO_CREDENTIALS: CredentialsManagerErrorCodes.NO_CREDENTIALS, @@ -337,7 +346,7 @@ export class CredentialsManagerError extends AuthError { * - `CREDENTIAL_MANAGER_ERROR`: Generic credentials manager error * - `UNKNOWN_ERROR`: Unknown error type */ - public readonly type: string; + public readonly type: CredentialsManagerErrorCode; constructor(originalError: AuthError) { super(originalError.name, originalError.message, { diff --git a/src/core/models/DPoPError.ts b/src/core/models/DPoPError.ts index 1c513fa5..98b01c9c 100644 --- a/src/core/models/DPoPError.ts +++ b/src/core/models/DPoPError.ts @@ -58,7 +58,16 @@ export const DPoPErrorCodes = { UNKNOWN_DPOP_ERROR: 'UNKNOWN_DPOP_ERROR', } as const; -const ERROR_CODE_MAP: Record = { +/** + * A normalized DPoP error code. + * + * Derived from {@link DPoPErrorCodes} so the union and the runtime constants + * cannot drift apart. + */ +export type DPoPErrorCode = + (typeof DPoPErrorCodes)[keyof typeof DPoPErrorCodes]; + +const ERROR_CODE_MAP: Record = { // --- DPoP-specific error codes --- DPOP_GENERATION_FAILED: DPoPErrorCodes.DPOP_GENERATION_FAILED, DPOP_PROOF_FAILED: DPoPErrorCodes.DPOP_PROOF_FAILED, @@ -129,7 +138,7 @@ export class DPoPError extends AuthError { * A normalized error type that is consistent across platforms. * This can be used for reliable error handling in application code. */ - public readonly type: string; + public readonly type: DPoPErrorCode; /** * Constructs a new DPoPError instance from an AuthError. diff --git a/src/core/models/MfaError.ts b/src/core/models/MfaError.ts index cf15470f..69d2838d 100644 --- a/src/core/models/MfaError.ts +++ b/src/core/models/MfaError.ts @@ -67,7 +67,15 @@ export const MfaErrorCodes = { UNKNOWN_MFA_ERROR: 'UNKNOWN_MFA_ERROR', } as const; -const ERROR_CODE_MAP: Record = { +/** + * A normalized MFA error code. + * + * Derived from {@link MfaErrorCodes} so the union and the runtime constants + * cannot drift apart. + */ +export type MfaErrorCode = (typeof MfaErrorCodes)[keyof typeof MfaErrorCodes]; + +const ERROR_CODE_MAP: Record = { // --- Auth0 API error codes (returned by both native SDKs and web) --- invalid_otp: MfaErrorCodes.INVALID_OTP, invalid_oob_code: MfaErrorCodes.INVALID_OOB_CODE, @@ -145,7 +153,7 @@ export class MfaError extends AuthError { * A normalized error type that is consistent across platforms. * This can be used for reliable error handling in application code. */ - public readonly type: string; + public readonly type: MfaErrorCode; constructor(originalError: AuthError) { super(originalError.name, originalError.message, { diff --git a/src/core/models/MyAccountError.ts b/src/core/models/MyAccountError.ts index 16f703cd..7ea6be56 100644 --- a/src/core/models/MyAccountError.ts +++ b/src/core/models/MyAccountError.ts @@ -1,18 +1,120 @@ import { AuthError } from './AuthError'; +/** + * Platform-agnostic error code constants for My Account API operations. + * + * Use these constants for type-safe error handling when enrolling, confirming, + * or managing authentication methods. Each constant corresponds to a specific + * error type in the {@link MyAccountError.type} property. + * + * @remarks + * The My Account API reports failures as RFC 7807 type URIs (e.g. + * `"https://auth0.com/api-errors/A0E-401-0001"`). Those URIs are normalized to + * these codes so error handling matches every other error class in the SDK. The + * original URI remains available on {@link MyAccountError.typeUri}. + * + * @example + * ```typescript + * import { MyAccountError, MyAccountErrorCodes } from 'react-native-auth0'; + * + * try { + * await myAccount.enrollPhone({ accessToken, phoneNumber: '+1234567890' }); + * } catch (e) { + * if (e instanceof MyAccountError) { + * switch (e.type) { + * case MyAccountErrorCodes.UNAUTHORIZED: + * // Access token expired or missing the required scopes + * break; + * case MyAccountErrorCodes.ENROLLMENT_FAILED: + * // The factor could not be enrolled + * break; + * } + * } + * } + * ``` + * + * @see {@link MyAccountError} + */ +export const MyAccountErrorCodes = { + /** The access token is missing, expired, or lacks the required scopes */ + UNAUTHORIZED: 'UNAUTHORIZED', + /** The request was rejected as malformed or invalid by the API */ + INVALID_REQUEST: 'INVALID_REQUEST', + /** Enrolling the authentication method failed */ + ENROLLMENT_FAILED: 'ENROLLMENT_FAILED', + /** Confirming or verifying the enrollment failed */ + VERIFICATION_FAILED: 'VERIFICATION_FAILED', + /** The requested authentication method does not exist */ + NOT_FOUND: 'NOT_FOUND', + /** The authentication method already exists or conflicts with an existing one */ + CONFLICT: 'CONFLICT', + /** Too many requests - rate limited */ + TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS', + /** Generic My Account API error */ + MY_ACCOUNT_ERROR: 'MY_ACCOUNT_ERROR', + /** Unknown or uncategorized My Account error */ + UNKNOWN_MY_ACCOUNT_ERROR: 'UNKNOWN_MY_ACCOUNT_ERROR', +} as const; + +/** + * A normalized My Account API error code. + * + * Derived from {@link MyAccountErrorCodes} so the union and the runtime + * constants cannot drift apart. + */ +export type MyAccountErrorCode = + (typeof MyAccountErrorCodes)[keyof typeof MyAccountErrorCodes]; + +const ERROR_CODE_MAP: Record = { + // --- Native bridge local validation errors (iOS MyAccount.swift, Android MyAccount.kt) --- + MY_ACCOUNT_ERROR: MyAccountErrorCodes.MY_ACCOUNT_ERROR, + MY_ACCOUNT_ENROLLMENT_FAILED: MyAccountErrorCodes.ENROLLMENT_FAILED, + MY_ACCOUNT_VERIFICATION_FAILED: MyAccountErrorCodes.VERIFICATION_FAILED, +}; + +/** + * Maps an HTTP status code to a normalized error code. + * + * The My Account API's RFC 7807 type URIs embed the status (`A0E-401`, + * `A0E-400-0001`), and the granular suffixes are not a closed set, so the + * status is the only stable signal to normalize on. + */ +function fromStatusCode(status: number): MyAccountErrorCode | undefined { + switch (status) { + case 400: + return MyAccountErrorCodes.INVALID_REQUEST; + case 401: + case 403: + return MyAccountErrorCodes.UNAUTHORIZED; + case 404: + return MyAccountErrorCodes.NOT_FOUND; + case 409: + return MyAccountErrorCodes.CONFLICT; + case 429: + return MyAccountErrorCodes.TOO_MANY_REQUESTS; + default: + return undefined; + } +} + /** * Represents an error from the My Account API, mirroring the properties * exposed by the native Auth0 SDKs (Auth0.swift and Auth0.Android). * + * The `type` property provides a normalized, platform-agnostic error code that + * applications can use for consistent error handling across iOS, Android, and + * Web. The raw RFC 7807 type URI is preserved on {@link MyAccountError.typeUri}. + * * @example * ```typescript - * import { MyAccountError } from 'react-native-auth0'; + * import { MyAccountError, MyAccountErrorCodes } from 'react-native-auth0'; * * try { * await myAccount.enrollPhone({ accessToken, phoneNumber: '+1234567890' }); * } catch (error) { * if (error instanceof MyAccountError) { - * console.log(error.type); // e.g. "https://auth0.com/api-errors/A0E-401-0001" + * console.log(error.type); // e.g. "UNAUTHORIZED" + * console.log(error.typeUri); // e.g. "https://auth0.com/api-errors/A0E-401-0001" * console.log(error.statusCode); // e.g. 401 * console.log(error.title); // e.g. "Unauthorized" * console.log(error.detail); // e.g. "The access token is invalid or has expired" @@ -21,8 +123,17 @@ import { AuthError } from './AuthError'; * ``` */ export class MyAccountError extends AuthError { - /** Error type URI from the API (e.g., "https://auth0.com/api-errors/A0E-401-0001") */ - public readonly type: string; + /** + * A normalized error type that is consistent across platforms. + * This can be used for reliable error handling in application code. + */ + public readonly type: MyAccountErrorCode; + /** + * The raw RFC 7807 error type URI from the API + * (e.g., "https://auth0.com/api-errors/A0E-401-0001"), when the API supplied + * one. Falls back to the originating error code otherwise. + */ + public readonly typeUri: string; /** Human-readable error title (e.g., "Unauthorized", "Bad Request") */ public readonly title: string; /** Detailed error description from the API */ @@ -44,9 +155,14 @@ export class MyAccountError extends AuthError { // message is not JSON — fall back to raw values } - this.type = (parsed?.type as string) ?? originalError.code; + this.typeUri = (parsed?.type as string) ?? originalError.code; this.title = (parsed?.title as string) ?? ''; this.detail = (parsed?.detail as string) ?? originalError.message; this.statusCode = (parsed?.statusCode as number) ?? originalError.status; + + this.type = + ERROR_CODE_MAP[originalError.code] ?? + fromStatusCode(this.statusCode) ?? + MyAccountErrorCodes.UNKNOWN_MY_ACCOUNT_ERROR; } } diff --git a/src/core/models/PasskeyError.ts b/src/core/models/PasskeyError.ts index e2046836..71340fda 100644 --- a/src/core/models/PasskeyError.ts +++ b/src/core/models/PasskeyError.ts @@ -64,7 +64,22 @@ export const PasskeyErrorCodes = { UNKNOWN_ERROR: 'PASSKEY_UNKNOWN_ERROR', } as const; -const ERROR_CODE_MAP: Record = { +/** + * A normalized passkey error code. + * + * Derived from {@link PasskeyErrorCodes} so the union and the runtime constants + * cannot drift apart. + * + * @remarks + * Unlike the other code objects in the SDK, the keys here are deliberately + * shorter than their values (`NOT_AVAILABLE` → `'PASSKEY_NOT_AVAILABLE'`). The + * `PASSKEY_` value prefix keeps passkey codes globally unique across the + * taxonomy while keeping the constants readable at the call site. + */ +export type PasskeyErrorCode = + (typeof PasskeyErrorCodes)[keyof typeof PasskeyErrorCodes]; + +const ERROR_CODE_MAP: Record = { PASSKEY_NOT_AVAILABLE: PasskeyErrorCodes.NOT_AVAILABLE, PASSKEY_CHALLENGE_FAILED: PasskeyErrorCodes.CHALLENGE_FAILED, PASSKEY_EXCHANGE_FAILED: PasskeyErrorCodes.EXCHANGE_FAILED, @@ -130,7 +145,7 @@ export class PasskeyError extends AuthError { * A normalized error type that is consistent across platforms. * This can be used for reliable error handling in application code. */ - public readonly type: string; + public readonly type: PasskeyErrorCode; /** * @param originalError An `AuthError` from an SDK method (challenge/exchange @@ -145,7 +160,7 @@ export class PasskeyError extends AuthError { */ constructor( originalError: AuthError | Error, - fallbackType: string = PasskeyErrorCodes.UNKNOWN_ERROR + fallbackType: PasskeyErrorCode = PasskeyErrorCodes.UNKNOWN_ERROR ) { const isAuthError = originalError instanceof AuthError; const code = isAuthError ? originalError.code : originalError.name; diff --git a/src/core/models/WebAuthError.ts b/src/core/models/WebAuthError.ts index f0fc88d7..d5115b3a 100644 --- a/src/core/models/WebAuthError.ts +++ b/src/core/models/WebAuthError.ts @@ -67,7 +67,16 @@ export const WebAuthErrorCodes = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', } as const; -const ERROR_CODE_MAP: Record = { +/** + * A normalized WebAuth error code. + * + * Derived from {@link WebAuthErrorCodes} so the union and the runtime constants + * cannot drift apart. + */ +export type WebAuthErrorCode = + (typeof WebAuthErrorCodes)[keyof typeof WebAuthErrorCodes]; + +const ERROR_CODE_MAP: Record = { // --- Common Codes --- 'a0.session.user_cancelled': WebAuthErrorCodes.USER_CANCELLED, 'USER_CANCELLED': WebAuthErrorCodes.USER_CANCELLED, @@ -104,7 +113,11 @@ const ERROR_CODE_MAP: Record = { }; export class WebAuthError extends AuthError { - public readonly type: string; + /** + * A normalized error type that is consistent across platforms. + * This can be used for reliable error handling in application code. + */ + public readonly type: WebAuthErrorCode; constructor(originalError: AuthError) { super(originalError.name, originalError.message, { diff --git a/src/core/models/__tests__/ErrorCodes.spec.ts b/src/core/models/__tests__/ErrorCodes.spec.ts index 11000e14..96370fe3 100644 --- a/src/core/models/__tests__/ErrorCodes.spec.ts +++ b/src/core/models/__tests__/ErrorCodes.spec.ts @@ -3,6 +3,7 @@ import { CredentialsManagerErrorCodes, DPoPErrorCodes, MfaErrorCodes, + MyAccountErrorCodes, } from '../'; describe('Error Code Constants', () => { @@ -213,6 +214,52 @@ describe('Error Code Constants', () => { }); }); + describe('MyAccountErrorCodes', () => { + it('should export all expected error code constants', () => { + expect(MyAccountErrorCodes.UNAUTHORIZED).toBe('UNAUTHORIZED'); + expect(MyAccountErrorCodes.INVALID_REQUEST).toBe('INVALID_REQUEST'); + expect(MyAccountErrorCodes.ENROLLMENT_FAILED).toBe('ENROLLMENT_FAILED'); + expect(MyAccountErrorCodes.VERIFICATION_FAILED).toBe( + 'VERIFICATION_FAILED' + ); + expect(MyAccountErrorCodes.NOT_FOUND).toBe('NOT_FOUND'); + expect(MyAccountErrorCodes.CONFLICT).toBe('CONFLICT'); + expect(MyAccountErrorCodes.TOO_MANY_REQUESTS).toBe('TOO_MANY_REQUESTS'); + expect(MyAccountErrorCodes.MY_ACCOUNT_ERROR).toBe('MY_ACCOUNT_ERROR'); + expect(MyAccountErrorCodes.UNKNOWN_MY_ACCOUNT_ERROR).toBe( + 'UNKNOWN_MY_ACCOUNT_ERROR' + ); + }); + + it('should have exactly 9 error codes', () => { + const keys = Object.keys(MyAccountErrorCodes); + expect(keys).toHaveLength(9); + }); + + it('should be immutable (as const)', () => { + expect(MyAccountErrorCodes).toBeDefined(); + expect(typeof MyAccountErrorCodes).toBe('object'); + }); + + it('should be usable in switch statements', () => { + const testErrorType = 'UNAUTHORIZED'; + let result = ''; + + switch (testErrorType) { + case MyAccountErrorCodes.UNAUTHORIZED: + result = 'unauthorized'; + break; + case MyAccountErrorCodes.NOT_FOUND: + result = 'not_found'; + break; + default: + result = 'unknown'; + } + + expect(result).toBe('unauthorized'); + }); + }); + describe('Cross-Error-Code Uniqueness', () => { it('should not have overlapping error codes between WebAuth and CredentialsManager', () => { const webAuthCodes = new Set(Object.values(WebAuthErrorCodes)); diff --git a/src/core/models/__tests__/MyAccountError.spec.ts b/src/core/models/__tests__/MyAccountError.spec.ts new file mode 100644 index 00000000..8d7701ac --- /dev/null +++ b/src/core/models/__tests__/MyAccountError.spec.ts @@ -0,0 +1,151 @@ +import { AuthError } from '../AuthError'; +import { MyAccountError, MyAccountErrorCodes } from '../MyAccountError'; + +/** + * Builds the AuthError the platform adapters hand to MyAccountError: the RFC + * 7807 problem-details document arrives JSON-encoded in `message`, and the web + * adapter surfaces the opaque type URI as `code`. + */ +function problemDetails( + details: { + type?: string; + title?: string; + detail?: string; + statusCode?: number; + }, + code = details.type ?? '', + status = 0 +) { + return new AuthError('AuthError', JSON.stringify(details), { status, code }); +} + +describe('MyAccountError', () => { + describe('RFC 7807 parsing', () => { + it('extracts title, detail, and statusCode from the problem document', () => { + const error = new MyAccountError( + problemDetails({ + type: 'https://auth0.com/api-errors/A0E-401', + title: 'Unauthorized', + detail: 'The access token is invalid or has expired', + statusCode: 401, + }) + ); + + expect(error.title).toBe('Unauthorized'); + expect(error.detail).toBe('The access token is invalid or has expired'); + expect(error.statusCode).toBe(401); + }); + + it('preserves the raw type URI on typeUri', () => { + const error = new MyAccountError( + problemDetails({ + type: 'https://auth0.com/api-errors/A0E-400-0001', + statusCode: 400, + }) + ); + + expect(error.typeUri).toBe('https://auth0.com/api-errors/A0E-400-0001'); + }); + + it('falls back to raw values when the message is not JSON', () => { + const error = new MyAccountError( + new AuthError('AuthError', 'Network request failed', { + status: 0, + code: 'a0.network_error', + }) + ); + + expect(error.typeUri).toBe('a0.network_error'); + expect(error.title).toBe(''); + expect(error.detail).toBe('Network request failed'); + expect(error.statusCode).toBe(0); + }); + }); + + describe('type normalization', () => { + // The API's type URIs embed the HTTP status (A0E-401, A0E-400-0001) and the + // granular suffixes are not a closed set, so `type` normalizes on status. + it.each([ + [400, MyAccountErrorCodes.INVALID_REQUEST], + [401, MyAccountErrorCodes.UNAUTHORIZED], + [403, MyAccountErrorCodes.UNAUTHORIZED], + [404, MyAccountErrorCodes.NOT_FOUND], + [409, MyAccountErrorCodes.CONFLICT], + [429, MyAccountErrorCodes.TOO_MANY_REQUESTS], + ])('normalizes HTTP %i to %s', (statusCode, expected) => { + const error = new MyAccountError( + problemDetails({ + type: `https://auth0.com/api-errors/A0E-${statusCode}`, + statusCode, + }) + ); + + expect(error.type).toBe(expected); + }); + + it('normalizes an unmapped status to UNKNOWN_MY_ACCOUNT_ERROR', () => { + const error = new MyAccountError( + problemDetails({ + type: 'https://auth0.com/api-errors/A0E-500', + statusCode: 500, + }) + ); + + expect(error.type).toBe(MyAccountErrorCodes.UNKNOWN_MY_ACCOUNT_ERROR); + }); + + it.each([ + ['MY_ACCOUNT_ENROLLMENT_FAILED', MyAccountErrorCodes.ENROLLMENT_FAILED], + [ + 'MY_ACCOUNT_VERIFICATION_FAILED', + MyAccountErrorCodes.VERIFICATION_FAILED, + ], + ['MY_ACCOUNT_ERROR', MyAccountErrorCodes.MY_ACCOUNT_ERROR], + ])('maps the native bridge code %s to %s', (code, expected) => { + const error = new MyAccountError( + new AuthError('AuthError', 'enrollment failed', { status: 0, code }) + ); + + expect(error.type).toBe(expected); + }); + + it('prefers the native bridge code over the HTTP status', () => { + // A native enrollment failure carrying a 400 must stay ENROLLMENT_FAILED + // rather than degrading to the generic INVALID_REQUEST. + const error = new MyAccountError( + problemDetails({ statusCode: 400 }, 'MY_ACCOUNT_ENROLLMENT_FAILED', 400) + ); + + expect(error.type).toBe(MyAccountErrorCodes.ENROLLMENT_FAILED); + }); + + it('falls back to UNKNOWN_MY_ACCOUNT_ERROR with neither signal', () => { + const error = new MyAccountError( + new AuthError('AuthError', 'something broke', { + status: 0, + code: 'unrecognized', + }) + ); + + expect(error.type).toBe(MyAccountErrorCodes.UNKNOWN_MY_ACCOUNT_ERROR); + }); + }); + + it('keeps the originating wire code alongside the normalized type', () => { + const error = new MyAccountError( + problemDetails( + { type: 'https://auth0.com/api-errors/A0E-401', statusCode: 401 }, + 'https://auth0.com/api-errors/A0E-401', + 401 + ) + ); + + expect(error.type).toBe(MyAccountErrorCodes.UNAUTHORIZED); + expect(error.code).toBe('https://auth0.com/api-errors/A0E-401'); + expect(error.typeUri).toBe('https://auth0.com/api-errors/A0E-401'); + }); + + it('is an AuthError', () => { + expect(new MyAccountError(problemDetails({}))).toBeInstanceOf(AuthError); + }); +}); diff --git a/src/core/models/__tests__/errorTaxonomy.spec.ts b/src/core/models/__tests__/errorTaxonomy.spec.ts new file mode 100644 index 00000000..64a30e84 --- /dev/null +++ b/src/core/models/__tests__/errorTaxonomy.spec.ts @@ -0,0 +1,175 @@ +import { AuthError } from '../AuthError'; +import { CredentialsManagerError, CredentialsManagerErrorCodes } from '../'; +import { DPoPError, DPoPErrorCodes } from '../'; +import { MfaError, MfaErrorCodes } from '../'; +import { MyAccountError, MyAccountErrorCodes } from '../'; +import { PasskeyError, PasskeyErrorCodes } from '../'; +import { WebAuthError, WebAuthErrorCodes } from '../'; +import { TimeoutError } from '../../utils/fetchWithTimeout'; + +/** + * Structural invariants of the frozen error taxonomy. + * + * These assertions exist so the taxonomy cannot drift as new error classes and + * codes are added: every class carries a normalized `type`, every code object is + * self-consistent, and codes stay unique across classes so a `type` value maps + * back to exactly one domain. + */ + +function authError(code: string, status = 0, message = 'boom') { + return new AuthError('AuthError', message, { status, code }); +} + +/** + * Every error class in the taxonomy, with its companion codes object. + * + * `fromCode` builds an instance from an arbitrary wire code, and `recognized` is + * a code that class is known to map, so the same assertions can cover both the + * happy path and the unknown-code fallback. + */ +const TAXONOMY = [ + { + name: 'WebAuthError', + codes: WebAuthErrorCodes, + fromCode: (code: string) => new WebAuthError(authError(code)), + recognized: 'access_denied', + unknown: WebAuthErrorCodes.UNKNOWN_ERROR, + }, + { + name: 'CredentialsManagerError', + codes: CredentialsManagerErrorCodes, + fromCode: (code: string) => new CredentialsManagerError(authError(code)), + recognized: 'NO_CREDENTIALS', + unknown: CredentialsManagerErrorCodes.UNKNOWN_ERROR, + }, + { + name: 'DPoPError', + codes: DPoPErrorCodes, + fromCode: (code: string) => new DPoPError(authError(code)), + recognized: 'DPOP_PROOF_FAILED', + unknown: DPoPErrorCodes.UNKNOWN_DPOP_ERROR, + }, + { + name: 'MfaError', + codes: MfaErrorCodes, + fromCode: (code: string) => new MfaError(authError(code)), + recognized: 'invalid_otp', + unknown: MfaErrorCodes.UNKNOWN_MFA_ERROR, + }, + { + name: 'PasskeyError', + codes: PasskeyErrorCodes, + fromCode: (code: string) => new PasskeyError(authError(code)), + recognized: 'PASSKEY_CHALLENGE_FAILED', + unknown: PasskeyErrorCodes.UNKNOWN_ERROR, + }, + { + name: 'MyAccountError', + codes: MyAccountErrorCodes, + fromCode: (code: string) => new MyAccountError(authError(code)), + recognized: 'MY_ACCOUNT_ERROR', + unknown: MyAccountErrorCodes.UNKNOWN_MY_ACCOUNT_ERROR, + }, +] as const; + +describe('error taxonomy invariants', () => { + describe.each(TAXONOMY)( + '$name', + ({ codes, fromCode, recognized, unknown }) => { + it('extends AuthError', () => { + expect(fromCode(recognized)).toBeInstanceOf(AuthError); + }); + + it('exposes a normalized `type` drawn from its codes object', () => { + const values = Object.values(codes) as string[]; + expect(values).toContain(fromCode(recognized).type); + }); + + it('preserves the originating wire code separately from `type`', () => { + // `code` is the raw platform/wire value; `type` is the normalized code. + // Conflating them would break the cross-platform contract. + expect(fromCode(recognized).code).toBe(recognized); + }); + + it('has a terminal unknown code for unrecognized input', () => { + const values = Object.values(codes) as string[]; + expect(values).toContain(unknown); + }); + + it('falls back to the unknown code for an unrecognized wire code', () => { + expect(fromCode('totally-unrecognized-code').type).toBe(unknown); + }); + + it('declares every code value as a non-empty SCREAMING_SNAKE_CASE string', () => { + for (const value of Object.values(codes)) { + expect(typeof value).toBe('string'); + expect(value).toMatch(/^[A-Z][A-Z0-9_]*$/); + } + }); + } + ); + + describe('code uniqueness across classes', () => { + it('maps each normalized code to exactly one error class, except documented overlaps', () => { + // Each entry is shared on purpose. Adding to this set is a taxonomy + // decision, not a test fix: a code may only appear in two classes when it + // denotes the *same* condition in both. + const DOCUMENTED_OVERLAPS = new Set([ + // WebAuthErrorCodes and CredentialsManagerErrorCodes both use this as + // their terminal fallback; renaming either would be breaking. + 'UNKNOWN_ERROR', + // A web-authentication timeout and an HTTP timeout are the same + // condition to a consumer, so they normalize to one code. + 'TIMEOUT_ERROR', + // MfaError and MyAccountError both report "enrolling the authenticator + // / authentication method failed" — one condition, two entry points. + 'ENROLLMENT_FAILED', + ]); + + const owners = new Map(); + for (const { name, codes } of TAXONOMY) { + for (const value of Object.values(codes) as string[]) { + owners.set(value, [...(owners.get(value) ?? []), name]); + } + } + + const collisions = [...owners.entries()].filter( + ([code, names]) => names.length > 1 && !DOCUMENTED_OVERLAPS.has(code) + ); + + expect(collisions).toEqual([]); + }); + + it('prefixes every PasskeyErrorCodes value with PASSKEY_', () => { + // The keys are deliberately unprefixed for ergonomics + // (`PasskeyErrorCodes.NOT_AVAILABLE`) while the values carry the + // `PASSKEY_` prefix to stay globally unique. This asymmetry is + // intentional — do not "fix" it by aligning key and value. + for (const value of Object.values(PasskeyErrorCodes)) { + expect(value).toMatch(/^PASSKEY_/); + } + for (const key of Object.keys(PasskeyErrorCodes)) { + expect(key).not.toMatch(/^PASSKEY_/); + } + }); + + it('uses matching key and value for every other codes object', () => { + const others = TAXONOMY.filter( + ({ codes }) => codes !== PasskeyErrorCodes + ); + for (const { codes } of others) { + for (const [key, value] of Object.entries(codes)) { + expect(value).toBe(key); + } + } + }); + }); + + describe('TimeoutError', () => { + it('participates in the taxonomy with a normalized type', () => { + const error = new TimeoutError('Request timed out'); + expect(error).toBeInstanceOf(AuthError); + expect(error.type).toBe('TIMEOUT_ERROR'); + }); + }); +}); diff --git a/src/core/models/errorCodes.ts b/src/core/models/errorCodes.ts new file mode 100644 index 00000000..d5894316 --- /dev/null +++ b/src/core/models/errorCodes.ts @@ -0,0 +1,33 @@ +import type { CredentialsManagerErrorCode } from './CredentialsManagerError'; +import type { DPoPErrorCode } from './DPoPError'; +import type { MfaErrorCode } from './MfaError'; +import type { MyAccountErrorCode } from './MyAccountError'; +import type { PasskeyErrorCode } from './PasskeyError'; +import type { WebAuthErrorCode } from './WebAuthError'; + +/** + * Every normalized error code the SDK can report, across all platforms. + * + * This is the union of the per-domain code unions. Prefer the specific union + * (e.g. {@link WebAuthErrorCode}) when handling a single error class — narrowing + * to one domain keeps `switch` statements exhaustive and rejects codes that + * cannot occur there. Use this umbrella union only for code that handles errors + * generically, such as logging or telemetry. + * + * @example + * ```typescript + * import type { Auth0ErrorCode } from 'react-native-auth0'; + * + * function report(code: Auth0ErrorCode, message: string) { + * analytics.track('auth0_error', { code, message }); + * } + * ``` + */ +export type Auth0ErrorCode = + | WebAuthErrorCode + | CredentialsManagerErrorCode + | DPoPErrorCode + | MfaErrorCode + | PasskeyErrorCode + | MyAccountErrorCode + | 'TIMEOUT_ERROR'; diff --git a/src/core/models/index.ts b/src/core/models/index.ts index bea5c815..e841ff23 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -7,8 +7,15 @@ export { CredentialsManagerError, CredentialsManagerErrorCodes, } from './CredentialsManagerError'; +export type { CredentialsManagerErrorCode } from './CredentialsManagerError'; export { WebAuthError, WebAuthErrorCodes } from './WebAuthError'; +export type { WebAuthErrorCode } from './WebAuthError'; export { DPoPError, DPoPErrorCodes } from './DPoPError'; +export type { DPoPErrorCode } from './DPoPError'; export { MfaError, MfaErrorCodes } from './MfaError'; +export type { MfaErrorCode } from './MfaError'; export { PasskeyError, PasskeyErrorCodes } from './PasskeyError'; -export { MyAccountError } from './MyAccountError'; +export type { PasskeyErrorCode } from './PasskeyError'; +export { MyAccountError, MyAccountErrorCodes } from './MyAccountError'; +export type { MyAccountErrorCode } from './MyAccountError'; +export type { Auth0ErrorCode } from './errorCodes'; diff --git a/src/core/utils/fetchWithTimeout.ts b/src/core/utils/fetchWithTimeout.ts index a0138aba..d3aebd60 100644 --- a/src/core/utils/fetchWithTimeout.ts +++ b/src/core/utils/fetchWithTimeout.ts @@ -1,6 +1,19 @@ import { AuthError } from '../models'; +/** + * Thrown when a request exceeds the configured timeout. + * + * Carries a normalized {@link TimeoutError.type} so it can be handled alongside + * the other error classes in the SDK's taxonomy. The wire-level {@link + * AuthError.code} remains `'timeout'`, which is what the transport emits. + */ export class TimeoutError extends AuthError { + /** + * A normalized error type that is consistent across platforms. + * Always `'TIMEOUT_ERROR'`. + */ + public readonly type = 'TIMEOUT_ERROR' as const; + constructor(message: string) { super('TimeoutError', message, { code: 'timeout' }); } diff --git a/src/exports/classes.ts b/src/exports/classes.ts deleted file mode 100644 index 845597ef..00000000 --- a/src/exports/classes.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - AuthError, - CredentialsManagerError, - WebAuthError, - DPoPError, - PasskeyError, -} from '../core/models'; -export { default as Auth0 } from '../Auth0'; -export { TimeoutError } from '../core/utils/fetchWithTimeout'; diff --git a/src/exports/enums.ts b/src/exports/enums.ts deleted file mode 100644 index 67dccfd9..00000000 --- a/src/exports/enums.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { SafariViewControllerPresentationStyle } from '../index'; -export { - LocalAuthenticationLevel, - LocalAuthenticationStrategy, - BiometricPolicy, -} from '../types/platform-specific'; - -/** - * Error code constants for type-safe error handling. - */ -export { - WebAuthErrorCodes, - CredentialsManagerErrorCodes, - DPoPErrorCodes, - PasskeyErrorCodes, -} from '../core/models'; diff --git a/src/exports/hooks.ts b/src/exports/hooks.ts deleted file mode 100644 index 054ce10b..00000000 --- a/src/exports/hooks.ts +++ /dev/null @@ -1 +0,0 @@ -export { Auth0Provider, useAuth0 } from '../hooks'; diff --git a/src/exports/index.ts b/src/exports/index.ts deleted file mode 100644 index 0b6450c2..00000000 --- a/src/exports/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * as Classes from './classes'; -export * as Hooks from './hooks'; -export * as Interface from './interface'; -export * as Enums from './enums'; diff --git a/src/exports/interface.ts b/src/exports/interface.ts deleted file mode 100644 index 75840965..00000000 --- a/src/exports/interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { type Auth0ContextInterface } from '../hooks/Auth0Context'; -export * from '../core/interfaces'; -export { type AuthState } from '../hooks/reducer'; -export * from '../types'; diff --git a/src/hooks/Auth0Context.ts b/src/hooks/Auth0Context.ts index d6aea524..0d3ee93b 100644 --- a/src/hooks/Auth0Context.ts +++ b/src/hooks/Auth0Context.ts @@ -20,7 +20,7 @@ import type { SSOExchangeParameters, RevokeOptions, ResetPasswordParameters, - DPoPHeadersParams, + DPoPHeadersParameters, SessionTransferCredentials, } from '../types'; import type { IMfaClient } from '../core/interfaces/IMfaClient'; @@ -378,7 +378,7 @@ export interface Auth0ContextInterface extends AuthState { * ``` */ getDPoPHeaders: ( - params: DPoPHeadersParams + params: DPoPHeadersParameters ) => Promise>; /** diff --git a/src/hooks/Auth0Provider.tsx b/src/hooks/Auth0Provider.tsx index c5099d0d..623ab7fa 100644 --- a/src/hooks/Auth0Provider.tsx +++ b/src/hooks/Auth0Provider.tsx @@ -26,7 +26,7 @@ import type { SSOExchangeParameters, RevokeOptions, ResetPasswordParameters, - DPoPHeadersParams, + DPoPHeadersParameters, PasswordlessChallengeEmailParameters, PasswordlessChallengePhoneParameters, PasswordlessLoginOtpParameters, @@ -428,7 +428,7 @@ export const Auth0Provider = ({ ); const getDPoPHeaders = useCallback( - async (params: DPoPHeadersParams): Promise> => { + async (params: DPoPHeadersParameters): Promise> => { try { return await client.getDPoPHeaders(params); } catch (e) { diff --git a/src/index.ts b/src/index.ts index cce9cddc..1c6eb7e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +// ========= Error taxonomy ========= +// The error classes, their normalized code constants, and the code unions that +// make `switch (error.type)` exhaustive. This is the SDK's stable public error +// contract — see EXAMPLES.md for the full guide. export { AuthError, CredentialsManagerError, @@ -11,20 +15,91 @@ export { PasskeyError, PasskeyErrorCodes, MyAccountError, + MyAccountErrorCodes, +} from './core/models'; +export type { + Auth0ErrorCode, + CredentialsManagerErrorCode, + WebAuthErrorCode, + DPoPErrorCode, + MfaErrorCode, + PasskeyErrorCode, + MyAccountErrorCode, } from './core/models'; export { TimeoutError } from './core/utils/fetchWithTimeout'; + +// ========= Utilities ========= export { parseIdToken } from './core/utils'; + +// ========= Enums and runtime constants ========= export { TokenType, MfaFactorType } from './types/common'; -export { Auth0Provider } from './hooks/Auth0Provider'; -export { useAuth0 } from './hooks/useAuth0'; -export * from './types'; export { BiometricPolicy, LocalAuthenticationLevel, LocalAuthenticationStrategy, + SafariViewControllerPresentationStyle, +} from './types/platform-specific'; + +// ========= React bindings ========= +export { Auth0Provider } from './hooks/Auth0Provider'; +export { useAuth0 } from './hooks/useAuth0'; +export type { Auth0ContextInterface } from './hooks/Auth0Context'; +export type { AuthState } from './hooks/reducer'; + +// ========= Client interfaces ========= +// The contracts behind the `Auth0` facade's getters, so consumers can name the +// types they receive (e.g. when wrapping or mocking a client). +export type { + IAuth0Client, + IWebAuthProvider, + ICredentialsManager, + IAuthenticationProvider, + IMyAccountClient, + IPasswordlessClient, + IMfaClient, +} from './core/interfaces'; + +// ========= Core models and options ========= +export type { + ApiCredentials, + Auth0Options, + Credentials, + DPoPHeadersParameters, + DPoPHeadersParams, + PasswordlessChallenge, + SessionTransferCredentials, + User, +} from './types/common'; + +// ========= MFA types ========= +export type { + MfaAuthenticator, + MfaChallengeResult, + MfaEnrollmentChallenge, + MfaFactor, + MfaOobEnrollmentChallenge, + MfaPushEnrollmentChallenge, + MfaRecoveryCodeEnrollmentChallenge, + MfaRequiredErrorPayload, + MfaRequirements, + MfaTotpEnrollmentChallenge, +} from './types/common'; + +// ========= Platform-specific options ========= +// `NativeAuthorizeOptions` / `NativeClearSessionOptions` appear directly in the +// `authorize()` and `clearSession()` signatures, so they are part of the public +// surface. The adapter-construction options (`NativeAuth0Options`, +// `WebAuth0Options`) are internal and deliberately not exported. +export type { + LocalAuthenticationOptions, + NativeAuthorizeOptions, + NativeClearSessionOptions, + WebAuthorizeOptions, + WebClearSessionOptions, } from './types/platform-specific'; -export type { LocalAuthenticationOptions } from './types/platform-specific'; -export type { IMfaClient } from './core/interfaces/IMfaClient'; -// Re-export Auth0 as default -export { default } from './Auth0'; +// ========= Method parameters and API payloads ========= +export * from './types/parameters'; + +// ========= Auth0 client ========= +export { default, default as Auth0 } from './Auth0'; diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index edb962e4..66af99e8 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -7,7 +7,7 @@ import type { } from '../../../core/interfaces'; import type { NativeAuth0Options } from '../../../types/platform-specific'; import type { - DPoPHeadersParams, + DPoPHeadersParameters, CustomTokenExchangeParameters, PasskeySignupChallengeParameters, PasskeyLoginChallengeParameters, @@ -68,7 +68,9 @@ export class NativeAuth0Client implements IAuth0Client { this.bridge = bridge; // Create a bound getDPoPHeaders function for the orchestrator - const getDPoPHeadersForOrchestrator = async (params: DPoPHeadersParams) => { + const getDPoPHeadersForOrchestrator = async ( + params: DPoPHeadersParameters + ) => { await this.ready; return this.bridge.getDPoPHeaders(params); }; @@ -144,7 +146,7 @@ export class NativeAuth0Client implements IAuth0Client { readonly myAccount: IMyAccountClient; async getDPoPHeaders( - params: DPoPHeadersParams + params: DPoPHeadersParameters ): Promise> { await this.ready; try { diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts index bb4e7f35..bfe4c99f 100644 --- a/src/platforms/native/bridge/INativeBridge.ts +++ b/src/platforms/native/bridge/INativeBridge.ts @@ -3,7 +3,7 @@ import type { ApiCredentials, WebAuthorizeParameters, ClearSessionParameters, - DPoPHeadersParams, + DPoPHeadersParameters, SessionTransferCredentials, MfaAuthenticator, MfaEnrollmentChallenge, @@ -168,7 +168,9 @@ export interface INativeBridge { * @param params Parameters including the URL, HTTP method, access token, and token type. * @returns A promise that resolves to an object containing the required headers. */ - getDPoPHeaders(params: DPoPHeadersParams): Promise>; + getDPoPHeaders( + params: DPoPHeadersParameters + ): Promise>; /** * Clears the DPoP key from secure storage. diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index b99c7886..1d667fe8 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -5,7 +5,7 @@ import type { WebAuthorizeParameters, ClearSessionParameters, NativeClearSessionOptions, - DPoPHeadersParams, + DPoPHeadersParameters, SessionTransferCredentials, MfaAuthenticator, MfaEnrollmentChallenge, @@ -213,7 +213,7 @@ export class NativeBridgeManager implements INativeBridge { } async getDPoPHeaders( - params: DPoPHeadersParams + params: DPoPHeadersParameters ): Promise> { return this.a0_call( Auth0NativeModule.getDPoPHeaders.bind(Auth0NativeModule), diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index 0cedbcfd..91f9fbd7 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -12,7 +12,7 @@ import type { } from '../../../core/interfaces'; import type { WebAuth0Options } from '../../../types/platform-specific'; import type { - DPoPHeadersParams, + DPoPHeadersParameters, CustomTokenExchangeParameters, PasskeySignupChallengeParameters, PasskeyLoginChallengeParameters, @@ -104,7 +104,9 @@ export class WebAuth0Client implements IAuth0Client { this.client = client; // Create a bound getDPoPHeaders function for the orchestrator - const getDPoPHeadersForOrchestrator = async (params: DPoPHeadersParams) => { + const getDPoPHeadersForOrchestrator = async ( + params: DPoPHeadersParameters + ) => { return this.getDPoPHeaders(params); }; @@ -154,7 +156,7 @@ export class WebAuth0Client implements IAuth0Client { } async getDPoPHeaders( - params: DPoPHeadersParams + params: DPoPHeadersParameters ): Promise> { // For web platform, we need to get the access token and use the underlying // auth0-spa-js DPoP utilities to generate the headers diff --git a/src/platforms/web/adapters/__tests__/WebMyAccountClient.spec.ts b/src/platforms/web/adapters/__tests__/WebMyAccountClient.spec.ts index 9dfb45f7..5dd031c0 100644 --- a/src/platforms/web/adapters/__tests__/WebMyAccountClient.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebMyAccountClient.spec.ts @@ -1,6 +1,7 @@ import { WebMyAccountClient } from '../WebMyAccountClient'; import { MyAccountError, + MyAccountErrorCodes, PasskeyError, PasskeyErrorCodes, } from '../../../../core/models'; @@ -362,7 +363,10 @@ describe('WebMyAccountClient', () => { expect(err.statusCode).toBe(401); expect(err.title).toBe('Unauthorized'); expect(err.detail).toBe('Token expired'); - expect(err.type).toBe('https://auth0.com/api-errors/A0E-401'); + // `type` is the normalized, cross-platform code; the raw RFC 7807 type + // URI stays available on `typeUri`. + expect(err.type).toBe(MyAccountErrorCodes.UNAUTHORIZED); + expect(err.typeUri).toBe('https://auth0.com/api-errors/A0E-401'); } }); diff --git a/src/types/common.ts b/src/types/common.ts index 14b7d52c..8c386ddd 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -412,7 +412,7 @@ export enum TokenType { * Parameters required to generate DPoP headers for custom API requests. * These headers cryptographically bind the access token to the specific HTTP request. */ -export interface DPoPHeadersParams { +export interface DPoPHeadersParameters { /** The full URL of the API endpoint being called. */ url: string; /** The HTTP method of the request (e.g., 'GET', 'POST'). */ @@ -424,3 +424,10 @@ export interface DPoPHeadersParams { /** Optional nonce value */ nonce?: string; } + +/** + * @deprecated Renamed to {@link DPoPHeadersParameters} for consistency with the + * other `...Parameters` types. This alias will be removed in a future major + * version. + */ +export type DPoPHeadersParams = DPoPHeadersParameters;