Skip to content

feat(auth0-server-js): keep the passkey authSession on the server - #312

Draft
nandan-bhat wants to merge 2 commits into
mainfrom
feat/passkey-auth-session-transaction-store
Draft

nandan-bhat wants to merge 2 commits into
mainfrom
feat/passkey-auth-session-transaction-store

Conversation

@nandan-bhat

@nandan-bhat nandan-bhat commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

passkey.register() and passkey.challenge() return the Auth0 authSession, and the app has to pass it back to passkey.getToken() together with the same realm and organization. So apps either send the authSession to the browser and back, or build their own storage for it. This PR adds three methods that keep the whole flow on the server, in the transaction store that the redirect login already uses. The browser only gets the WebAuthn options and sends back the credential.

New methods

  • passkey.startSignup(options, storeOptions?, requestOptions?) requests a signup challenge, stores the flow and returns { authnParamsPublicKey, verificationRequired? }.
  • passkey.startLogin(options?, storeOptions?, requestOptions?) does the same for a login and returns { authnParamsPublicKey }.
  • passkey.complete({ credential, verification?, scope?, audience? }, storeOptions?, requestOptions?) exchanges the credential and persists the session, like getToken(). It takes the authSession, realm, organization and domain from the stored flow, never from the request.

register(), challenge() and getToken() are unchanged. They stay documented as the lower-level path for apps that keep the authSession themselves.

How the stored flow works

  • It is kept under a new identifier, __a0_passkey_tx by default (new option passkeyTransactionIdentifier). With CookieTransactionStore it is an encrypted httpOnly cookie next to __a0_tx.
  • It expires with the Auth0 auth session. The SDK uses the timeout Auth0 returns with the challenge (60 seconds, or 15 minutes with identifier verification), up to 1 hour. CookieTransactionStore now sets the cookie's Max-Age and the expiry of the encrypted value from the new optional TransactionData.expiresAt. Entries without it keep the usual hour.
  • One flow runs per browser at a time: a new start replaces the stored one.
  • Before calling Auth0, complete() checks that the credential was created for the stored challenge (read from clientDataJSON). A credential from an older start, for example in another tab, gets a MissingTransactionError, and the current flow is not used up. A credential without a clientDataJSON gets a MissingRequiredArgumentError.
  • For a signup that needs one-time codes, the stored flow also keeps the verificationRequired list. When none of the listed identifiers has a code, complete() throws a MissingRequiredArgumentError without calling Auth0, instead of sending an empty verification. The flow is kept, so the app can send the codes.
  • The stored flow is deleted once Auth0 has used the auth session: on success (before the session is written, as completeInteractiveLogin() does), on mfa_required, on an invalid_grant that is not retryable, and on an organization mismatch. It is kept on every other failure (a wrong or missing code, 429, 5xx, a network error), so the app can call complete() again with the same credential.
  • Store errors after the token exchange are not reported. After a success the login still succeeds, and after a failure the original error is thrown. The TransactionStore docs tell custom stores to log their own failures.
  • Errors thrown by complete() never carry the authSession: it is removed from the PasskeyGetTokenError and its cause. getToken() errors are unchanged.
  • Cookie-backed stores find their cookies by prefix, so passkeyTransactionIdentifier must not share a prefix with transactionIdentifier, stateIdentifier or, when an anonymousStore is configured, anonymousSessionIdentifier. The new methods check this and throw InvalidConfigurationError before calling Auth0. Apps that do not call them are not affected.
  • completeInteractiveLogin() and completePasswordlessMagicLink() treat a passkey entry as missing. This only matters for a custom store that ignores the identifier.

Other changes

  • Raises @auth0/auth0-auth-js to ^1.17.0, for isRetryable and authSession on PasskeyGetTokenError. feat(auth0-server-js): support identifier verification in passkey signup #307 raises it the same way.
  • Exports the PasskeyStartSignupResult, PasskeyStartLoginResult and PasskeyCompleteOptions types.
  • JSDoc for the new methods and the new option, a description of what a TransactionStore must do, and pointers from the old methods.
  • EXAMPLES.md: the passkey section now uses the new methods, with error handling and retries, StoreOptions, using a custom transaction store, and the old methods under "Keeping the authSession yourself". The anonymous sessions note in README.md lists passkey.complete().

Choices to check

  • The method names startSignup, startLogin and complete.
  • One passkey flow per browser. Apps that need several at once can use the lower-level methods.
  • The lower-level methods are kept, not deprecated.

Impact on other packages

  • Not a breaking change. Everything new is additive, and CookieTransactionStore only changes the cookie lifetime of entries that carry expiresAt, which only the new methods write.
  • auth0-express, auth0-fastify, auth0-nuxt and auth0-tanstack-start-react build on CookieTransactionStore and keep working without changes. They can expose the new methods in follow-up PRs. Against a build of this branch, the auth0-fastify (69/69) and auth0-tanstack-start-react (311/311) tests pass, and auth0-express gives the same result as with main (349/350).

References

Testing

New tests:

  • server-passkey-client.transaction.spec.ts: signup and login end to end with a cookie jar; what is stored and what reaches the browser; each error outcome and whether the flow is kept or deleted; retries after a wrong code; expiry; a credential from another start; a missing clientDataJSON; a signup without one-time codes; the prefix check; separation from the redirect login; resolver mode; storeOptions and per-request options; store errors after the exchange; and that errors carry no authSession.
  • passkey-transaction.spec.ts: the shape check of the stored entry, the one-time code check, the challenge comparison (base64 and base64url, with or without padding), and that it runs in linear time on hostile input.
  • transaction-slot.spec.ts and cookie-transaction-store.spec.ts: reading, expiry, and the cookie lifetime from expiresAt.
  • server-passkey-client.workers.spec.ts: the flow against the built package in the workers test.

I checked that the tests fail when the code is wrong. I made 58 deliberate mistakes in the code, one at a time, and the tests catch each of them. For example: skipping the challenge check or the one-time code check, keeping the flow after invalid_grant, always deleting it, ignoring expiresAt, a one-way prefix check, letting values from the request replace the stored ones, deleting the flow after writing the session, leaving the authSession on the error, and bringing back a regex that is slow on long input.

A consumer type check compiles the new methods and types against the built package. It also confirms that the start results have no authSession and that complete() accepts neither a realm nor an authSession.

Run locally on Node 22.21.1 with auth0-auth-js built first: build, lint and test:workers pass for auth0-server-js, and 767 of its 771 tests pass. The 4 that fail are the "forwards an already-aborted per-request signal" tests for passkey (3) and database sign-up (1). They also fail on a clean checkout of main in my environment, so this change does not cause them.

  • This change adds test coverage for new/changed/fixed functionality
  • Manual check on a tenant: passkey signup with and without identifier verification, a wrong code and a retry, passkey login, and a credential from a second tab.

Checklist

  • I have added documentation for new/changed functionality in this PR or in auth0.com/docs
  • All active GitHub checks for tests, formatting, and security are passing
  • The correct base branch is being used, if not the default branch

🤖 Generated with Claude Code

Add passkey.startSignup(), passkey.startLogin() and passkey.complete().
The start methods keep the Auth0 authSession, the challenge, the domain,
the realm and the organization in the transaction store under
__a0_passkey_tx (option passkeyTransactionIdentifier), and return only
what the browser needs. complete() takes those values from the store,
checks that the credential was created for the stored challenge,
exchanges it and persists the session. register(), challenge() and
getToken() are unchanged.

- Expire the entry with the Auth0 auth session. CookieTransactionStore
  sets Max-Age and the JWE exp from the new TransactionData.expiresAt.
- Delete the entry once Auth0 has used the auth session and keep it on
  every other failure, so complete() can be retried. Store errors after
  the exchange are not reported.
- Remove the authSession from errors thrown by complete().
- Reject a passkeyTransactionIdentifier that shares a prefix with
  another store identifier, and treat a passkey entry as missing in the
  redirect-login and magic-link callbacks.
- Raise @auth0/auth0-auth-js to ^1.17.0.
- Document the new methods, the TransactionStore contract and the flow
  in EXAMPLES.md, and add tests.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

When passkey.startSignup() returns verificationRequired, keep the list
with the stored flow. passkey.complete() then throws a
MissingRequiredArgumentError without calling Auth0 when none of the
listed identifiers has a code, instead of sending an empty verification
payload. The flow is kept, so the app can send the codes. A code that
is empty once trimmed counts as missing, as it does for Auth0.

Also reword the comment on removing the authSession from complete()
errors: the auth session stays inside the SDK, it is not a secret.

Co-Authored-By: Claude <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant