-
Notifications
You must be signed in to change notification settings - Fork 242
refactor(v6): audit the public API surface and freeze the error taxonomy #1634
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v6-development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | | ||
|
Comment on lines
+673
to
+680
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Limit the Custom Token Exchange throws State that consumers must use 🤖 Prompt for AI Agents |
||
| | `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'; | ||
| } | ||
| } | ||
|
Comment on lines
+684
to
+710
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '675,715p' README.md
printf '\n--- related error-code definitions and examples ---\n'
rg -n -C 3 "WebAuthErrorCode|WebAuthErrorCodes|exhaustively|switch.*type" src README.md EXAMPLES.mdRepository: auth0/react-native-auth0 Length of output: 35642 🏁 Script executed: command -v tsc || true
command -v npx || true
if command -v tsc >/dev/null 2>&1; then
tmpdir="$(mktemp -d)"
cat >"$tmpdir/exhaustiveness.ts" <<'TS'
type Code = 'USER_CANCELLED' | 'NETWORK_ERROR' | 'ACCESS_DENIED';
function withDefault(type: Code): string {
switch (type) {
case 'USER_CANCELLED':
return 'Cancelled';
default:
return 'Fallback';
}
}
function withNever(type: Code): string {
switch (type) {
case 'USER_CANCELLED':
return 'Cancelled';
case 'NETWORK_ERROR':
return 'Offline';
case 'ACCESS_DENIED':
return 'Denied';
default: {
const unreachable: never = type;
return unreachable;
}
}
}
TS
tsc --strict --noEmit "$tmpdir/exhaustiveness.ts"
rm -rf "$tmpdir"
fiRepository: auth0/react-native-auth0 Length of output: 342 🏁 Script executed: tmpdir="$(mktemp -d)"
cat >"$tmpdir/exhaustiveness.ts" <<'TS'
type Code = 'USER_CANCELLED' | 'NETWORK_ERROR' | 'ACCESS_DENIED';
function withDefault(type: Code): string {
switch (type) {
case 'USER_CANCELLED':
return 'Cancelled';
default:
return 'Fallback';
}
}
function withNever(type: Code): string {
switch (type) {
case 'USER_CANCELLED':
return 'Cancelled';
case 'NETWORK_ERROR':
return 'Offline';
case 'ACCESS_DENIED':
return 'Denied';
default: {
const unreachable: never = type;
return unreachable;
}
}
}
TS
tsc --ignoreConfig --strict --noEmit "$tmpdir/exhaustiveness.ts"
rm -rf "$tmpdir"Repository: auth0/react-native-auth0 Length of output: 162 Do not describe this The 🤖 Prompt for AI Agents |
||
| ``` | ||
|
|
||
| `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. | ||
|
Comment on lines
+720
to
+723
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Use the full opaque My Account type value in both examples. My Account API error types use the
Based on learnings, web My Account errors use opaque 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Learnings |
||
|
|
||
| ### Credentials Manager errors | ||
|
|
||
| The Credentials Manager will only throw `CredentialsManagerError` exceptions. You can find more information in the details property of the exception. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
|
Comment on lines
+208
to
+212
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target file outline ---'
ast-grep outline src/__tests__/publicApiSurface.spec.ts || true
printf '%s\n' '--- target test context ---'
sed -n '1,250p' src/__tests__/publicApiSurface.spec.ts
printf '%s\n' '--- Auth0 export declarations and related tests ---'
rg -n --glob '!lib/**' --glob '!docs/**' --glob '!coverage/**' \
"export .*Auth0|default as Auth0|class Auth0|const Auth0|interface .*Auth0|publicApiSurface|typeChecker|getAliasedSymbol" \
src package.json tsconfig*.jsonRepository: auth0/react-native-auth0 Length of output: 9982 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- relevant source declarations ---'
sed -n '1,125p' src/index.ts
sed -n '1,55p' src/Auth0.ts
sed -n '205,225p' src/Auth0.ts
printf '%s\n' '--- TypeScript/test configuration ---'
cat package.json
printf '%s\n' '--- tsconfig ---'
cat tsconfig.json
printf '%s\n' '--- compiler API availability ---'
node - <<'JS'
try {
const ts = require('typescript');
console.log(JSON.stringify({ available: true, version: ts.version }));
} catch (error) {
console.log(JSON.stringify({ available: false, message: String(error) }));
}
JSRepository: auth0/react-native-auth0 Length of output: 11914 🏁 Script executed: #!/bin/bash
set -eu
node - <<'JS'
const path = require('path');
const ts = require('typescript');
const projectRoot = process.cwd();
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'));
},
});
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('Entry point is not a module');
const exports = new Map(
checker.getExportsOfModule(moduleSymbol).map((symbol) => [symbol.getName(), symbol])
);
const defaultExport = exports.get('default');
const namedAuth0Export = exports.get('Auth0');
function describe(symbol) {
if (!symbol) return null;
const aliased = symbol.flags & ts.SymbolFlags.Alias
? checker.getAliasedSymbol(symbol)
: symbol;
return {
name: symbol.getName(),
flags: symbol.flags,
isAlias: Boolean(symbol.flags & ts.SymbolFlags.Alias),
aliasTargetName: aliased.getName(),
aliasTargetId: aliased.id,
declarations: (aliased.declarations ?? []).map((declaration) => ({
file: path.relative(projectRoot, declaration.getSourceFile().fileName),
kind: ts.SyntaxKind[declaration.kind],
text: declaration.getText(),
})),
};
}
console.log(JSON.stringify({
defaultExport: describe(defaultExport),
namedAuth0Export: describe(namedAuth0Export),
resolvedSymbolsIdentical:
defaultExport && namedAuth0Export &&
checker.getAliasedSymbol(defaultExport) === checker.getAliasedSymbol(namedAuth0Export),
}, null, 2));
JSRepository: auth0/react-native-auth0 Length of output: 12684 Assert that The current assertions only check that both names exist. Resolve both symbols with the TypeScript checker and compare their aliased symbols. 🤖 Prompt for AI Agents |
||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Align the handler with the documented Custom Token Exchange codes.
This handler checks
unsupported_grant_type. The reference at lines 1209-1226 listsunsupported_token_typeandunauthorized_clientinstead. The documented alerts for unsupported token types and disabled clients will not run for the listed codes.Align the handler and reference with the same emitted code set.
🤖 Prompt for AI Agents