Change challange expiration based on purpose

This commit is contained in:
Stanko K.R.
2026-03-25 17:31:42 +01:00
parent 6ba1814f26
commit 01d66a9463
3 changed files with 60 additions and 6 deletions
+12 -4
View File
@@ -63,7 +63,7 @@ class PasskeyButton extends HTMLElement {
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
if (!options) throw new Error("Missing passkey options") 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) const passkey = await this.perform(options)
this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
@@ -93,6 +93,8 @@ class PasskeyButton extends HTMLElement {
} }
class PasskeyRegistrationButton extends PasskeyButton { class PasskeyRegistrationButton extends PasskeyButton {
get purpose() { return "registration" }
async perform(options) { async perform(options) {
return await register(options) return await register(options)
} }
@@ -103,6 +105,8 @@ class PasskeyRegistrationButton extends PasskeyButton {
} }
class PasskeySignInButton extends PasskeyButton { class PasskeySignInButton extends PasskeyButton {
get purpose() { return "authentication" }
connectedCallback() { connectedCallback() {
super.connectedCallback() super.connectedCallback()
if (this.mediation === "conditional") this.#attemptConditionalMediation() if (this.mediation === "conditional") this.#attemptConditionalMediation()
@@ -127,7 +131,7 @@ class PasskeySignInButton extends PasskeyButton {
this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try { try {
await refreshChallenge(options, this.challengeUrl) await refreshChallenge(options, this.challengeUrl, this.purpose)
const passkey = await this.perform(options, { mediation: this.mediation }) const passkey = await this.perform(options, { mediation: this.mediation })
this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
@@ -157,17 +161,21 @@ function passkeysAvailable() {
return !!window.PublicKeyCredential return !!window.PublicKeyCredential
} }
async function refreshChallenge(options, challengeUrl) { async function refreshChallenge(options, challengeUrl, purpose) {
if (!challengeUrl) throw new Error("Missing passkey challenge URL") if (!challengeUrl) throw new Error("Missing passkey challenge URL")
const token = document.querySelector('meta[name="csrf-token"]')?.content const token = document.querySelector('meta[name="csrf-token"]')?.content
const body = new URLSearchParams()
if (purpose) body.append("purpose", purpose)
const response = await fetch(challengeUrl, { const response = await fetch(challengeUrl, {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
headers: { headers: {
"X-CSRF-Token": token, "X-CSRF-Token": token,
"Accept": "application/json" "Accept": "application/json"
} },
body
}) })
if (!response.ok) throw new Error("Failed to refresh challenge") if (!response.ok) throw new Error("Failed to refresh challenge")
@@ -18,7 +18,9 @@
class ActionPack::Passkey::ChallengesController < ActionController::Base class ActionPack::Passkey::ChallengesController < ActionController::Base
include ActionPack::Passkey::Request 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 def create
render json: { challenge: create_passkey_challenge } render json: { challenge: create_passkey_challenge }
end end
@@ -26,7 +28,17 @@ class ActionPack::Passkey::ChallengesController < ActionController::Base
private private
def create_passkey_challenge def create_passkey_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new( ActionPack::WebAuthn::PublicKeyCredential::Options.new(
challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration challenge_expiration: challenge_expiration
).challenge ).challenge
end 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 end
@@ -21,4 +21,38 @@ class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
assert_not_equal first_challenge, second_challenge assert_not_equal first_challenge, second_challenge
end end
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 end