feat(add-factor): challenge API for universal add-factor - #232
Conversation
Distinguish own factor-id retries from concurrent same-identity winners on ambiguous S3 puts, add exact-match key-only upgrades with ambiguous reconcile, and expose consistent DynamoDB factor lookup after races.
Add optional existingFactorKind, PasskeyRegistration new-factor challenges (with registration_hash binding material), and Android platform support.
Trim redundant commentary and correct resolve_put_object_outcome docs so Inserted requires a matching factor id for every ambiguous put error.
| /// existing-factor token prevents swapping a different registration ceremony after the old | ||
| /// factor has signed. | ||
| #[serde(rename_all = "camelCase")] | ||
| PasskeyRegistration { registration_hash: String }, |
There was a problem hiding this comment.
wouldn't it be easier to encode just the public key? I wonder if there's a recommendation in the webauthn standard for this, not sure anything else needs to be bound
paolodamico
left a comment
There was a problem hiding this comment.
no blockers but I strongly recommend addressing the feedback
| pub enum NewFactorType { | ||
| /// Registering a new passkey (`WebAuthn` credential creation). | ||
| /// | ||
| /// `registration_hash` is the hex-encoded SHA-256 digest of the `WebAuthn` registration-state |
There was a problem hiding this comment.
if we do keep the registration_hash, let's simply link in the doc comment to the other method, let's avoid duplicating the same content as it can become outdated
| let (new_factor_type, new_factor_challenge_value, new_factor_token) = match &request.new_factor | ||
| { | ||
| NewFactor::PasskeyRegistration { platform } => { | ||
| let (challenge, registration) = match platform { |
There was a problem hiding this comment.
this code seems duplicated (create_challenge_passkey), should we refactor it?
| rand::thread_rng().fill_bytes(&mut new_factor_challenge); | ||
| let token = challenge_manager | ||
| .create_challenge_token( | ||
| ChallengeType::Keypair, |
There was a problem hiding this comment.
I know this predates this change, but we should add a small comment on why this uses a Keypair challenge instead of OIDC
| use serde_json::json; | ||
|
|
||
| #[tokio::test] | ||
| async fn test_add_factor_challenge_shapes() { |
There was a problem hiding this comment.
nit. name of the test and the module is confusing. shouldn't this live in add_factor_integration?
| assert!(value["newFactorChallenge"].is_object()); | ||
| assert!(value["newFactorChallenge"]["publicKey"].is_object()); | ||
| assert!(value["newFactorToken"].is_string()); | ||
|
|
There was a problem hiding this comment.
wouldn't we want to verify here that the challenge token was created correctly? e.g. checking on redis, decrypting the token and verifying it's contents?
|
cursor review |
|
@codex review xhigh, particularly analyze the challenge token binding on registration. I worry that this doesn't necessarily mean the user is "committing" to their new passkey, couldn't a registration request still be MITM and a different passkey registered. also check for any webauthn spec flags that may affect this. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 1c6fd87. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1c6fd87ec7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }; | ||
| let challenge_json: serde_json::Value = serde_json::to_value(&challenge)?; | ||
| let registration_json = serde_json::to_string(®istration)?; | ||
| let registration_hash = registration_state_hash(registration_json.as_bytes()); |
There was a problem hiding this comment.
Bind authorization to the resulting passkey
Hashing registration_json commits only to the server-generated ceremony state (challenge and policy), not to the credential ID or public key subsequently returned by navigator.credentials.create(). A compromised same-origin client or relay that obtains the paired challenges can therefore have the victim approve the existing-factor ceremony, complete the new-factor challenge with the attacker's authenticator, and submit that valid registration response; the WebAuthn UP/UV flags only prove interaction and verification on whichever authenticator produced that response, so they do not link it to the existing-factor user. Finish and validate registration before old-factor authorization, then bind the old-factor token to the resulting credential ID and public key (or a canonical digest of the validated credential response).
Useful? React with 👍 / 👎.
| { | ||
| NewFactor::PasskeyRegistration { platform } => { | ||
| let (challenge, registration) = match platform { | ||
| Platform::Ios => environment.webauthn_config().start_passkey_registration( |
There was a problem hiding this comment.
Require a discoverable credential for the iOS path
For an iOS request completed with a security key or another non-resident authenticator, webauthn-rs 0.5.2's start_passkey_registration emits residentKey: discouraged with no authenticator attachment constraint, so registration can succeed for a non-discoverable credential. The service later starts only a discoverable authentication ceremony in retrieve_challenge_passkey.rs, without an allowCredentials list, leaving that newly added factor impossible to select for recovery. Use registration options that require a resident/discoverable credential (and, if IOS specifically means the platform authenticator, constrain the attachment accordingly).
Useful? React with 👍 / 👎.
|
|
||
| // Challenge for the new factor | ||
| new_factor_challenge: String, | ||
| new_factor_challenge: serde_json::Value, |
There was a problem hiding this comment.
Preserve the response schema as a typed union
Because this response derives JsonSchema and is published through /openapi.json, replacing the former String with serde_json::Value changes newFactorChallenge into unconstrained JSON. Whenever existing OIDC consumers regenerate their clients, they lose the established string type even though that runtime branch still returns a string, while passkey consumers receive no schema for the WebAuthn creation options either. Represent the field as a typed oneOf/untagged union of the string challenge and CreationChallengeResponse so generated clients retain both contracts.
Useful? React with 👍 / 👎.
Summary
existingFactorKindon/v1/add-factor/challenge(default Passkey).PASSKEY_REGISTRATION(iOS/Android) withregistration_hashin existing-factor challenge context.Split from #136 (PR 2/4). Stacked on #231.
Test plan
cargo test --test add_factor_challenge_shapescargo clippy --all-targets -- -D warningsNote
Medium Risk
Touches main-factor challenge issuance and token binding for backup factor changes; defaults preserve existing OIDC-new clients, but new paths affect authentication ceremony state and must stay consistent with downstream
/add-factorverification (stacked PR).Overview
Extends
/v1/add-factor/challengeso add-factor flows can use either passkey or OIDC as the existing main factor and can register a new passkey (not only OIDC).Clients may send optional
existingFactorKind(PASSKEYorOIDC_ACCOUNT); it defaults to passkey so current callers keep the same behavior. The existing-factor challenge token now uses the matching challenge type (PasskeyvsKeypair).For
PASSKEY_REGISTRATION(iOS/Android), the handler starts the same WebAuthn registration paths as create-passkey, returnsnewFactorChallengeas a JSON object (not a base64 string), and stores registration state in the new-factor token. Aregistration_hash(SHA-256 of that state) is embedded inNewFactorTypeon the existing-factor token so the old factor cannot authorize a swapped registration ceremony—mirroring OIDC token binding.OIDC-new-factor requests are unchanged:
newFactorChallengestays a string and tokens/context for that path match prior clients.PlatformisCloneso add-factor can reuse it from the passkey challenge route.Adds integration tests asserting response shapes for OIDC, iOS/Android passkey registration, and that Android challenges complete with a mock WebAuthn client.
Reviewed by Cursor Bugbot for commit 1c6fd87. Bugbot is set up for automated code reviews on this repo. Configure here.