Change challange expiration based on purpose
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user