From 01d66a94637ca24154f3230e504f5910c0274558 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 25 Mar 2026 17:31:42 +0100 Subject: [PATCH] Change challange expiration based on purpose --- app/javascript/lib/action_pack/passkey.js | 16 ++++++--- .../passkey/challenges_controller.rb | 16 +++++++-- .../my/passkey_challenges_controller_test.rb | 34 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js index bdea35698..7ee20cb84 100644 --- a/app/javascript/lib/action_pack/passkey.js +++ b/app/javascript/lib/action_pack/passkey.js @@ -63,7 +63,7 @@ class PasskeyButton extends HTMLElement { if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") if (!options) throw new Error("Missing passkey options") - await refreshChallenge(options, this.challengeUrl) + await refreshChallenge(options, this.challengeUrl, this.purpose) const passkey = await this.perform(options) this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) @@ -93,6 +93,8 @@ class PasskeyButton extends HTMLElement { } class PasskeyRegistrationButton extends PasskeyButton { + get purpose() { return "registration" } + async perform(options) { return await register(options) } @@ -103,6 +105,8 @@ class PasskeyRegistrationButton extends PasskeyButton { } class PasskeySignInButton extends PasskeyButton { + get purpose() { return "authentication" } + connectedCallback() { super.connectedCallback() if (this.mediation === "conditional") this.#attemptConditionalMediation() @@ -127,7 +131,7 @@ class PasskeySignInButton extends PasskeyButton { this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { - await refreshChallenge(options, this.challengeUrl) + await refreshChallenge(options, this.challengeUrl, this.purpose) const passkey = await this.perform(options, { mediation: this.mediation }) this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) @@ -157,17 +161,21 @@ function passkeysAvailable() { return !!window.PublicKeyCredential } -async function refreshChallenge(options, challengeUrl) { +async function refreshChallenge(options, challengeUrl, purpose) { if (!challengeUrl) throw new Error("Missing passkey challenge URL") const token = document.querySelector('meta[name="csrf-token"]')?.content + const body = new URLSearchParams() + if (purpose) body.append("purpose", purpose) + const response = await fetch(challengeUrl, { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": token, "Accept": "application/json" - } + }, + body }) if (!response.ok) throw new Error("Failed to refresh challenge") diff --git a/lib/action_pack/passkey/challenges_controller.rb b/lib/action_pack/passkey/challenges_controller.rb index 3eca28236..39e8bbf32 100644 --- a/lib/action_pack/passkey/challenges_controller.rb +++ b/lib/action_pack/passkey/challenges_controller.rb @@ -18,7 +18,9 @@ class ActionPack::Passkey::ChallengesController < ActionController::Base include ActionPack::Passkey::Request - # Generates a fresh challenge and returns it as JSON. + # Generates a fresh challenge and returns it as JSON. Accepts an optional + # +purpose+ parameter ("registration" or "authentication") to select the + # appropriate challenge expiration. Defaults to "authentication". def create render json: { challenge: create_passkey_challenge } end @@ -26,7 +28,17 @@ class ActionPack::Passkey::ChallengesController < ActionController::Base private def create_passkey_challenge ActionPack::WebAuthn::PublicKeyCredential::Options.new( - challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration + challenge_expiration: challenge_expiration ).challenge end + + def challenge_expiration + config = Rails.configuration.action_pack.web_authn + + if params[:purpose] == "registration" + config.creation_challenge_expiration + else + config.request_challenge_expiration + end + end end diff --git a/test/controllers/my/passkey_challenges_controller_test.rb b/test/controllers/my/passkey_challenges_controller_test.rb index 43e0fb5fb..caf0c5fa1 100644 --- a/test/controllers/my/passkey_challenges_controller_test.rb +++ b/test/controllers/my/passkey_challenges_controller_test.rb @@ -21,4 +21,38 @@ class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest assert_not_equal first_challenge, second_challenge end end + + test "uses registration challenge expiration for registration purpose" do + untenanted do + post my_passkey_challenge_url, params: { purpose: "registration" } + + assert_response :success + + challenge = response.parsed_body["challenge"] + signed_message = Base64.urlsafe_decode64(challenge) + + travel Rails.configuration.action_pack.web_authn.creation_challenge_expiration - 1.second + assert ActionPack::WebAuthn.challenge_verifier.verified(signed_message) + + travel 2.seconds + assert_nil ActionPack::WebAuthn.challenge_verifier.verified(signed_message) + end + end + + test "uses authentication challenge expiration by default" do + untenanted do + post my_passkey_challenge_url + + assert_response :success + + challenge = response.parsed_body["challenge"] + signed_message = Base64.urlsafe_decode64(challenge) + + travel Rails.configuration.action_pack.web_authn.request_challenge_expiration - 1.second + assert ActionPack::WebAuthn.challenge_verifier.verified(signed_message) + + travel 2.seconds + assert_nil ActionPack::WebAuthn.challenge_verifier.verified(signed_message) + end + end end