Skip to content

feat(passkey): Mint an MFA token from passkey sign-in - #21137

Merged
nshirley merged 1 commit into
mainfrom
passkey-wrap-mfa-token
Sep 4, 2026
Merged

feat(passkey): Mint an MFA token from passkey sign-in#21137
nshirley merged 1 commit into
mainfrom
passkey-wrap-mfa-token

Conversation

@nshirley

@nshirley nshirley commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Because

  • POST /passkey/wraps takes only a verified session, and the inline sign-in flow has no MFA JWT to guard it with — the user has just signed in with a passkey.
  • A passkey assertion can earn the same mfa:<scope> token an emailed code does, so the wrap route can use the guard every other MFA-protected route already uses.

This pull request

  • Accepts an optional scope on POST /passkey/authentication/start, validated against config.mfa.actions and stored on the challenge before the user is prompted.
  • Reads that scope back off the consumed challenge at /finish and mints an mfa:<scope> token, once the session its claims bind to exists.
  • Binds the token to the asserted credential as cid, carried onto the session token beside the scope the mfa strategy already copies, and matched at the wrap route.
  • Guards POST /passkey/wraps on mfa:passkey.
  • Updates the passkey Swagger notes for the wrap route's new auth, and for the scope request field and conditional mfaToken response on the authentication routes.
  • Moves the JWT claim set into one signer, so /mfa/otp/verify and the assertion path cannot drift on the shape the strategy verifies.

Issue that this pull request solves

Closes: N/A — follows on from FXA-13142.

Checklist

Put an x in the boxes that apply

  • My commit is GPG signed.
  • If applicable, I have modified or added tests which pass locally.
  • I have added necessary documentation (if appropriate).
  • I have verified that my changes render correctly in RTL (if appropriate).
  • I have manually reviewed all AI generated code.

How to review (Optional)

  • Key files/areas to focus on: lib/routes/utils/mfa-token.ts, and the mint point in authenticationFinish.
  • Suggested review order: mfa-token.tspasskeys.tsauth-schemes/mfa.tspasskey-wraps.ts
  • Risky or complex parts: strategy: 'mfa' and its existing consumers are unchanged — credentials is still the session token, with cid added beside the scope the strategy already copies onto it. /mfa/otp/verify now signs through the shared helper; the claim set is the same, and its existing tests verify real tokens end to end.

Screenshots (Optional)

Please attach the screenshots of the changes made in case of change in user interface.

Other information (Optional)

  • mfa:passkey is the scope passkey registration and deletion already use. The same scope will cover upgrading an existing passkey from settings.
  • cid could instead live on the session row, which would keep the mfa machinery generic and suit the session-AMR gate discussed separately. Left alone here since that would need a sessionTokens migration.

@nshirley
nshirley force-pushed the passkey-wrap-mfa-token branch 6 times, most recently from d0f3bf2 to 77cd5fa Compare September 3, 2026 18:33
@nshirley
nshirley marked this pull request as ready for review September 3, 2026 18:37
@nshirley
nshirley requested a review from a team as a code owner September 3, 2026 18:37
Copilot AI balanced review requested due to automatic review settings September 3, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The production client flow cannot request or consume the new token, and the API documentation remains inconsistent.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds MFA token minting to passkey authentication and uses credential-bound tokens to protect passkey wrap creation.

Changes:

  • Persists requested MFA scopes through passkey challenges.
  • Centralizes MFA JWT signing and adds credential binding.
  • Guards wrap creation with mfa:passkey and expands tests.
File summaries
File Description
packages/fxa-auth-server/test/remote/passkey_wraps.in.spec.ts Tests credential-bound wrap authorization.
packages/fxa-auth-server/lib/routes/utils/mfa-token.ts Adds shared MFA JWT signer.
packages/fxa-auth-server/lib/routes/utils/mfa-token.spec.ts Tests MFA token claims.
packages/fxa-auth-server/lib/routes/passkeys.ts Mints MFA tokens after passkey authentication.
packages/fxa-auth-server/lib/routes/passkeys.spec.ts Extends route configuration fixture.
packages/fxa-auth-server/lib/routes/passkey-wraps.ts Requires credential-bound MFA authorization.
packages/fxa-auth-server/lib/routes/passkey-wraps.spec.ts Tests binding enforcement.
packages/fxa-auth-server/lib/routes/mfa.ts Uses the shared token signer.
packages/fxa-auth-server/lib/routes/auth-schemes/mfa.ts Propagates the credential claim.
packages/fxa-auth-server/lib/routes/auth-schemes/mfa.spec.ts Tests credential propagation.
libs/accounts/passkey/src/lib/passkey.service.ts Returns scope and canonical credential ID.
libs/accounts/passkey/src/lib/passkey.service.spec.ts Tests authentication result changes.
libs/accounts/passkey/src/lib/passkey.challenge.manager.ts Stores MFA scope with authentication challenges.
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/fxa-auth-server/lib/routes/passkeys.ts
Comment thread packages/fxa-auth-server/lib/routes/passkey-wraps.ts
Comment thread packages/fxa-auth-server/lib/routes/passkeys.ts
@vpomerleau

Copy link
Copy Markdown
Contributor

Fix for unrelated unit test failures is here: #21142

@vpomerleau
vpomerleau self-requested a review September 3, 2026 22:50
@vpomerleau

Copy link
Copy Markdown
Contributor

cid could instead live on the session row, which would keep the mfa machinery generic and suit the session-AMR gate discussed separately. Left alone here since that would need a sessionTokens migration

Including the cid in the jwt token seems more robust to me. It makes little difference for now, but I can see this approach being superior if/when we add the option to enable passkey wraps from settings -> conceivably, if multiple passkeys are accessible on the same device, we'll want to match the token to the passkey being "upgraded" not to the passkey used for the initial session creation (otherwise we could have a conflict).

@vpomerleau vpomerleau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, I like where we landed for the auth strategy!

Do we have an existing ticket for JWT token minting on retry (after initial sign-in)?

Comment thread packages/fxa-auth-server/lib/routes/passkey-wraps.ts Outdated
*/
function sameCredential(a: string, b: string): boolean {
try {
return Buffer.from(a, 'base64url').equals(Buffer.from(b, 'base64url'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this ever throw, or can the try/catch be omitted?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If, somehow, either value were not an actual string then it could throw. I suppose we don't have such wrappers on other Buffer.from calls so yeah, probably safe to drop it. At that point, probably doesn't need to be a dedicated function, so I'll put it back into the handler as is

).rejects.toMatchObject({ code: 401 });
});

it('refuses a token earned on a different credential', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a dupe of the test on L276?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread packages/fxa-auth-server/lib/routes/passkey-wraps.ts Outdated
@nshirley
nshirley force-pushed the passkey-wrap-mfa-token branch from 7a06d5a to 26916d7 Compare September 4, 2026 00:10
Because:
- POST /passkey/wraps takes only a verified session, and the inline sign-in
  flow has no MFA JWT to guard it with — the user has just signed in with
  a passkey.
- A passkey assertion can earn the same mfa:<scope> token an emailed code
  does, so the wrap route can use the guard every other MFA-protected
  route already uses.

This commit:
- Accepts an optional scope on /passkey/authentication/start, validated
  against config.mfa.actions and stored on the challenge before the user
  is prompted.
- Reads that scope back off the consumed challenge at /finish and mints an
  mfa:<scope> token, once the session its claims bind to exists.
- Returns the stored credentialId from verifyAuthenticationResponse and
  mints it as `cid`, carried onto the session token beside the scope the
  mfa strategy already copies.
- Guards POST /passkey/wraps on mfa:passkey, matching `cid` against the
  request by bytes since that is how the id is stored and looked up, and
  updates the passkey Swagger notes to match.
- Moves the JWT claim set into one signer, so /mfa/otp/verify and the
  assertion path cannot drift on the shape the strategy verifies.
@nshirley
nshirley force-pushed the passkey-wrap-mfa-token branch from 26916d7 to c2d14bd Compare September 4, 2026 00:22
@nshirley
nshirley merged commit d32477f into main Sep 4, 2026
20 checks passed
@nshirley
nshirley deleted the passkey-wrap-mfa-token branch September 4, 2026 02:57
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.

3 participants