Skip to content

Commit b340b42

Browse files
authored
feat(auth0-auth-js): add passkeys API support (#177)
* review: addressing the review comments * cleanup: removed un-used property * update: moved 'userMetadata' outside of 'userProfile' * review: addressing review feedbacks
1 parent e685e8d commit b340b42

10 files changed

Lines changed: 2030 additions & 1 deletion

File tree

packages/auth0-auth-js/EXAMPLES.md

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
- [Listing Authenticators](#listing-authenticators)
3434
- [Challenging an Authenticator](#challenging-an-authenticator)
3535
- [Deleting an Authenticator](#deleting-an-authenticator)
36+
- [Using Passkeys](#using-passkeys)
37+
- [Requesting a Signup Challenge](#requesting-a-signup-challenge)
38+
- [Requesting a Login Challenge](#requesting-a-login-challenge)
39+
- [Exchanging a Credential for Tokens](#exchanging-a-credential-for-tokens)
40+
- [Error Handling](#error-handling)
3641

3742
## Configuration
3843

@@ -726,3 +731,276 @@ const authenticatorId = 'totp|dev_abc123';
726731

727732
await authClient.mfa.deleteAuthenticator({ authenticatorId, mfaToken });
728733
```
734+
735+
## Using Passkeys
736+
737+
The SDK provides a passkey client for native WebAuthn-based authentication. The passkey client is accessible via the `passkey` property on the `AuthClient` instance.
738+
739+
> [!IMPORTANT]
740+
> Passkeys require the following prerequisites:
741+
> - A [custom domain](https://auth0.com/docs/customize/custom-domains) configured on your Auth0 tenant (e.g., `auth.example.com`, not `example.auth0.com`). The custom domain serves as the WebAuthn Relying Party (RP) ID.
742+
> - A database connection with the `passkey` authentication method enabled.
743+
> - Your application must be served over HTTPS on a domain that matches or is a subdomain of the configured RP ID.
744+
745+
The SDK is platform-agnostic — it does not call WebAuthn browser APIs directly. Your application is responsible for calling `navigator.credentials.create()` or `navigator.credentials.get()` and serializing the credential response before passing it to the SDK.
746+
747+
Learn more: [Passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys) | [Native Passkeys API](https://auth0.com/docs/authenticate/database-connections/passkeys/native-passkeys-api)
748+
749+
### Requesting a Signup Challenge
750+
751+
To register a new passkey for a user, request a signup challenge. The response contains WebAuthn public key creation options that should be passed to `navigator.credentials.create()`:
752+
753+
```ts
754+
import { AuthClient } from '@auth0/auth0-auth-js';
755+
756+
const authClient = new AuthClient({
757+
domain: '<AUTH0_CUSTOM_DOMAIN>',
758+
clientId: '<AUTH0_CLIENT_ID>',
759+
});
760+
761+
const challenge = await authClient.passkey.register({
762+
email: 'user@example.com',
763+
name: 'Jane Doe',
764+
});
765+
766+
// challenge.authSession — session identifier needed for the token exchange step
767+
// challenge.authnParamsPublicKey — pass to navigator.credentials.create({ publicKey: ... })
768+
```
769+
770+
#### Parameters
771+
772+
| Parameter | Required | Type | Description |
773+
|-----------|----------|------|-------------|
774+
| `email` | Optional | `string` | User's email address. Include if `email` is configured as an identifier in your database connection's [user attributes](https://auth0.com/docs/authenticate/database-connections/passkeys). |
775+
| `username` | Optional | `string` | User's username. Include if `username` is configured as an identifier in your database connection's user attributes. |
776+
| `phoneNumber` | Optional | `string` | User's phone number. Include if `phone` is configured as an identifier in your database connection's user attributes. |
777+
| `name` | Optional | `string` | User's full display name. |
778+
| `givenName` | Optional | `string` | User's given (first) name. |
779+
| `familyName` | Optional | `string` | User's family (last) name. |
780+
| `nickname` | Optional | `string` | User's nickname. |
781+
| `picture` | Optional | `string` | URL to the user's profile picture. |
782+
| `userMetadata` | Optional | `Record<string, string>` | Arbitrary metadata stored in the user's `user_metadata` field. |
783+
| `realm` | Optional | `string` | Database connection name. If not provided, the tenant's default database connection is used. |
784+
| `organization` | Optional | `string` | Organization ID or name. Scopes the user to the specified organization context. |
785+
786+
> [!NOTE]
787+
> Which identifiers (`email`, `username`, `phoneNumber`) you should provide depends on what's enabled in your Auth0 tenant's database connection attributes. Provide the identifiers that match your connection's configuration.
788+
789+
You can include additional user profile fields when [Flexible Identifiers](https://auth0.com/docs/authenticate/database-connections/passkeys) is enabled on your database connection:
790+
791+
```ts
792+
const challenge = await authClient.passkey.register({
793+
email: 'user@example.com',
794+
name: 'Jane Doe',
795+
givenName: 'Jane',
796+
familyName: 'Doe',
797+
phoneNumber: '+1234567890',
798+
username: 'janedoe',
799+
userMetadata: { preferred_language: 'en' },
800+
});
801+
```
802+
803+
To specify a database connection:
804+
805+
```ts
806+
const challenge = await authClient.passkey.register({
807+
email: 'user@example.com',
808+
realm: 'Username-Password-Authentication',
809+
});
810+
```
811+
812+
To register within an organization context:
813+
814+
```ts
815+
const challenge = await authClient.passkey.register({
816+
email: 'user@example.com',
817+
organization: 'org_abc123',
818+
});
819+
```
820+
821+
### Requesting a Login Challenge
822+
823+
To authenticate with an existing passkey, request a login challenge. The response contains WebAuthn public key request options that should be passed to `navigator.credentials.get()`:
824+
825+
```ts
826+
const challenge = await authClient.passkey.challenge();
827+
828+
// challenge.authSession — session identifier needed for the token exchange step
829+
// challenge.authnParamsPublicKey — pass to navigator.credentials.get({ publicKey: ... })
830+
```
831+
832+
#### Parameters
833+
834+
| Parameter | Required | Type | Description |
835+
|-----------|----------|------|-------------|
836+
| `realm` | Optional | `string` | Database connection name. If not provided, the tenant's default database connection is used. |
837+
| `organization` | Optional | `string` | Organization ID or name. Scopes the authentication to the specified organization context. |
838+
839+
To specify a database connection:
840+
841+
```ts
842+
const challenge = await authClient.passkey.challenge({
843+
realm: 'Username-Password-Authentication',
844+
});
845+
```
846+
847+
To authenticate within an organization context:
848+
849+
```ts
850+
const challenge = await authClient.passkey.challenge({
851+
organization: 'org_abc123',
852+
});
853+
```
854+
855+
### Exchanging a Credential for Tokens
856+
857+
After the user completes the WebAuthn ceremony (either signup or login), exchange the credential response for Auth0 tokens.
858+
859+
The WebAuthn API returns binary `ArrayBuffer` fields. These must be converted to base64url-encoded strings before passing to this method. Here is a helper function you can use:
860+
861+
```ts
862+
function bufferToBase64url(buffer: ArrayBuffer): string {
863+
const bytes = new Uint8Array(buffer);
864+
let binary = '';
865+
for (let i = 0; i < bytes.byteLength; i++) {
866+
binary += String.fromCharCode(bytes[i]);
867+
}
868+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
869+
}
870+
```
871+
872+
For a **signup** (registration) ceremony, the credential response includes `attestationObject`:
873+
874+
```ts
875+
const credential = await navigator.credentials.create({
876+
publicKey: challenge.authnParamsPublicKey,
877+
});
878+
879+
const tokens = await authClient.passkey.getTokenByPasskey({
880+
authSession: challenge.authSession,
881+
credential: {
882+
id: credential.id,
883+
rawId: bufferToBase64url(credential.rawId),
884+
type: credential.type,
885+
authenticatorAttachment: credential.authenticatorAttachment,
886+
response: {
887+
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
888+
attestationObject: bufferToBase64url(credential.response.attestationObject),
889+
},
890+
},
891+
});
892+
```
893+
894+
For a **login** (authentication) ceremony, the credential response includes `authenticatorData`, `signature`, and `userHandle`:
895+
896+
```ts
897+
const credential = await navigator.credentials.get({
898+
publicKey: challenge.authnParamsPublicKey,
899+
});
900+
901+
const tokens = await authClient.passkey.getTokenByPasskey({
902+
authSession: challenge.authSession,
903+
credential: {
904+
id: credential.id,
905+
rawId: bufferToBase64url(credential.rawId),
906+
type: credential.type,
907+
authenticatorAttachment: credential.authenticatorAttachment,
908+
response: {
909+
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
910+
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
911+
signature: bufferToBase64url(credential.response.signature),
912+
userHandle: bufferToBase64url(credential.response.userHandle),
913+
},
914+
},
915+
});
916+
```
917+
918+
#### Parameters
919+
920+
| Parameter | Required | Type | Description |
921+
|-----------|----------|------|-------------|
922+
| `authSession` | Required | `string` | The session identifier returned from `register()` or `challenge()`. |
923+
| `credential` | Required | `PasskeyCredentialResponse` | The serialized WebAuthn credential. For signup: include `attestationObject`. For login: include `authenticatorData`, `signature`, and `userHandle`. |
924+
| `realm` | Optional | `string` | Database connection name. If not provided, the tenant's default database connection is used. |
925+
| `scope` | Optional | `string` | OAuth scopes to request (e.g., `'openid profile email'`). |
926+
| `audience` | Optional | `string` | API identifier for the access token. Without this, an opaque token is returned instead of a JWT. |
927+
| `organization` | Optional | `string` | Organization ID or name. Scopes tokens to the specified organization context. |
928+
929+
You can specify audience and scope to control the access token:
930+
931+
```ts
932+
const tokens = await authClient.passkey.getTokenByPasskey({
933+
authSession: challenge.authSession,
934+
credential: serializedCredential,
935+
audience: 'https://api.example.com',
936+
scope: 'openid profile email',
937+
});
938+
```
939+
940+
To specify a database connection:
941+
942+
```ts
943+
const tokens = await authClient.passkey.getTokenByPasskey({
944+
authSession: challenge.authSession,
945+
credential: serializedCredential,
946+
realm: 'Username-Password-Authentication',
947+
});
948+
```
949+
950+
To exchange within an organization context:
951+
952+
```ts
953+
const tokens = await authClient.passkey.getTokenByPasskey({
954+
authSession: challenge.authSession,
955+
credential: serializedCredential,
956+
organization: 'org_abc123',
957+
});
958+
```
959+
960+
### Error Handling
961+
962+
All passkey methods throw typed errors that can be caught and handled individually:
963+
964+
```ts
965+
import {
966+
AuthClient,
967+
PasskeyRegisterError,
968+
PasskeyChallengeError,
969+
PasskeyGetTokenError,
970+
} from '@auth0/auth0-auth-js';
971+
972+
try {
973+
const challenge = await authClient.passkey.register({
974+
email: 'user@example.com',
975+
});
976+
} catch (error) {
977+
if (error instanceof PasskeyRegisterError) {
978+
console.error(error.message); // Human-readable error message
979+
console.error(error.code); // 'passkey_register_error'
980+
console.error(error.cause?.error); // API error code (e.g., 'invalid_request')
981+
console.error(error.cause?.error_description); // API error detail
982+
}
983+
}
984+
985+
try {
986+
const challenge = await authClient.passkey.challenge();
987+
} catch (error) {
988+
if (error instanceof PasskeyChallengeError) {
989+
console.error(error.message);
990+
console.error(error.code); // 'passkey_challenge_error'
991+
}
992+
}
993+
994+
try {
995+
const tokens = await authClient.passkey.getTokenByPasskey({
996+
authSession: challenge.authSession,
997+
credential: serializedCredential,
998+
});
999+
} catch (error) {
1000+
if (error instanceof PasskeyGetTokenError) {
1001+
console.error(error.message);
1002+
console.error(error.code); // 'passkey_get_token_error'
1003+
console.error(error.cause?.error); // e.g., 'invalid_grant', 'access_denied'
1004+
}
1005+
}
1006+
```

packages/auth0-auth-js/README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,49 @@ await authClient.mfa.deleteAuthenticator({
259259

260260
For detailed MFA examples including SMS enrollment, OOB challenges, and more, see the [MFA section in EXAMPLES.md](https://github.com/auth0/auth0-auth-js/blob/main/packages/auth0-auth-js/EXAMPLES.md#using-multi-factor-authentication-mfa).
261261

262-
### 8. More Examples
262+
### 8. Passkeys
263+
264+
The SDK provides native support for passkey-based authentication using the WebAuthn protocol. You can register new passkeys, authenticate with existing passkeys, and exchange passkey credentials for tokens — all without redirect-based flows.
265+
266+
```ts
267+
import { AuthClient, PasskeyGetTokenError } from '@auth0/auth0-auth-js';
268+
269+
const authClient = new AuthClient({
270+
domain: '<AUTH0_CUSTOM_DOMAIN>',
271+
clientId: '<AUTH0_CLIENT_ID>',
272+
});
273+
274+
// 1. Register a new passkey (signup)
275+
const signupChallenge = await authClient.passkey.register({
276+
email: 'user@example.com',
277+
name: 'Jane Doe',
278+
});
279+
// Pass signupChallenge.authnParamsPublicKey to navigator.credentials.create()
280+
281+
// 2. Authenticate with an existing passkey (login)
282+
const loginChallenge = await authClient.passkey.challenge();
283+
// Pass loginChallenge.authnParamsPublicKey to navigator.credentials.get()
284+
285+
// 3. Exchange the serialized credential response for tokens
286+
const tokens = await authClient.passkey.getTokenByPasskey({
287+
authSession: signupChallenge.authSession,
288+
credential: serializedCredential,
289+
audience: 'https://api.example.com',
290+
scope: 'openid profile email',
291+
});
292+
```
293+
294+
> [!IMPORTANT]
295+
> Passkeys require the following prerequisites:
296+
> - A [custom domain](https://auth0.com/docs/customize/custom-domains) configured on your Auth0 tenant (e.g., `auth.example.com`, not `example.auth0.com`). The custom domain serves as the WebAuthn Relying Party (RP) ID, which must match or be a registrable domain suffix of your application's origin.
297+
> - A database connection with the `passkey` authentication method enabled.
298+
> - Your application must be served over HTTPS on a domain that aligns with the configured RP ID.
299+
300+
For detailed passkey examples including credential serialization, all parameter options, and error handling, see the [Passkeys section in EXAMPLES.md](https://github.com/auth0/auth0-auth-js/blob/main/packages/auth0-auth-js/EXAMPLES.md#using-passkeys).
301+
302+
Learn more: [Passkeys](https://auth0.com/docs/authenticate/database-connections/passkeys) | [Native Passkeys API](https://auth0.com/docs/authenticate/database-connections/passkeys/native-passkeys-api)
303+
304+
### 9. More Examples
263305

264306
A full overview of examples can be found in [EXAMPLES.md](https://github.com/auth0/auth0-auth-js/blob/main/packages/auth0-auth-js/EXAMPLES.md).
265307

packages/auth0-auth-js/src/auth-client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from './errors.js';
2121
import { stripUndefinedProperties } from './utils.js';
2222
import { MfaClient } from './mfa/mfa-client.js';
23+
import { PasskeyClient } from './passkey/passkey-client.js';
2324
import { createTelemetryFetch, getTelemetryConfig } from './telemetry.js';
2425
import {
2526
AuthClientOptions,
@@ -225,6 +226,7 @@ export class AuthClient {
225226
readonly #inFlightDiscovery: Map<string, Promise<DiscoveryCacheEntry>>;
226227
readonly #jwksCache: JWKSCacheInput;
227228
public mfa: MfaClient;
229+
public passkey: PasskeyClient;
228230

229231
constructor(options: AuthClientOptions) {
230232
this.#options = options;
@@ -253,6 +255,17 @@ export class AuthClient {
253255
clientId: this.#options.clientId,
254256
customFetch: this.#customFetch,
255257
});
258+
259+
this.passkey = new PasskeyClient({
260+
domain: this.#options.domain,
261+
clientId: this.#options.clientId,
262+
customFetch: this.#customFetch,
263+
grantRequest: async (grantType, params) => {
264+
const { configuration } = await this.#discover();
265+
const tokenEndpointResponse = await client.genericGrantRequest(configuration, grantType, params);
266+
return TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse);
267+
},
268+
});
256269
}
257270

258271
#getDiscoveryCacheKey(): string {

packages/auth0-auth-js/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { AuthClient } from './auth-client.js';
22
export * from './errors.js';
33
export * from './types.js';
44
export * from './mfa/index.js';
5+
export * from './passkey/index.js';

0 commit comments

Comments
 (0)