Merge pull request #2758 from basecamp/fix-yubikeys-getting-stuck-on-sign-in
Fix yubikeys getting stuck on sign in
This commit is contained in:
@@ -30,6 +30,7 @@ class PasskeyButton extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
this.abortConditionalMediation?.()
|
||||||
this.button.removeEventListener("click", this.#perform)
|
this.button.removeEventListener("click", this.#perform)
|
||||||
this.button.disabled = false
|
this.button.disabled = false
|
||||||
this.#hideErrors()
|
this.#hideErrors()
|
||||||
@@ -53,6 +54,7 @@ class PasskeyButton extends HTMLElement {
|
|||||||
|
|
||||||
// Arrow function to preserve `this` binding for addEventListener/removeEventListener.
|
// Arrow function to preserve `this` binding for addEventListener/removeEventListener.
|
||||||
#perform = async () => {
|
#perform = async () => {
|
||||||
|
await this.abortConditionalMediation?.()
|
||||||
this.button.disabled = true
|
this.button.disabled = true
|
||||||
this.#hideErrors()
|
this.#hideErrors()
|
||||||
this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||||
@@ -105,6 +107,9 @@ class PasskeyRegistrationButton extends PasskeyButton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PasskeySignInButton extends PasskeyButton {
|
class PasskeySignInButton extends PasskeyButton {
|
||||||
|
#conditionalMediationController = null
|
||||||
|
#conditionalMediationPromise = null
|
||||||
|
|
||||||
get purpose() { return "authentication" }
|
get purpose() { return "authentication" }
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -116,32 +121,49 @@ class PasskeySignInButton extends PasskeyButton {
|
|||||||
return this.getAttribute("mediation")
|
return this.getAttribute("mediation")
|
||||||
}
|
}
|
||||||
|
|
||||||
async perform(options, { mediation } = {}) {
|
async perform(options, { signal, mediation } = {}) {
|
||||||
return await authenticate(options, { mediation })
|
return await authenticate(options, { signal, mediation })
|
||||||
}
|
}
|
||||||
|
|
||||||
fillForm(passkey) {
|
fillForm(passkey) {
|
||||||
fillSignInForm(this.form, passkey)
|
fillSignInForm(this.form, passkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async abortConditionalMediation() {
|
||||||
|
if (this.#conditionalMediationController) {
|
||||||
|
this.#conditionalMediationController.abort()
|
||||||
|
await this.#conditionalMediationPromise
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #attemptConditionalMediation() {
|
async #attemptConditionalMediation() {
|
||||||
if (await this.#conditionalMediationAvailable()) {
|
if (await this.#conditionalMediationAvailable()) {
|
||||||
const options = this.options
|
const options = this.options
|
||||||
|
|
||||||
this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
|
||||||
|
|
||||||
try {
|
this.#conditionalMediationController = new AbortController()
|
||||||
await refreshChallenge(options, this.challengeUrl, this.purpose)
|
this.#conditionalMediationPromise = this.#runConditionalMediation(options)
|
||||||
const passkey = await this.perform(options, { mediation: this.mediation })
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
async #runConditionalMediation(options) {
|
||||||
this.fillForm(passkey)
|
try {
|
||||||
this.form.submit()
|
await refreshChallenge(options, this.challengeUrl, this.purpose)
|
||||||
} catch (error) {
|
const passkey = await this.perform(options, { signal: this.#conditionalMediationController.signal, mediation: this.mediation })
|
||||||
console.error("Passkey conditional mediation failed", error)
|
|
||||||
const type = errorType(error)
|
this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
|
||||||
this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } }))
|
this.fillForm(passkey)
|
||||||
}
|
this.form.submit()
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") return
|
||||||
|
|
||||||
|
console.error("Passkey conditional mediation failed", error)
|
||||||
|
const type = errorType(error)
|
||||||
|
this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } }))
|
||||||
|
} finally {
|
||||||
|
this.#conditionalMediationController = null
|
||||||
|
this.#conditionalMediationPromise = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, options: @authentication_options, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
|
<div class="margin-block-start">
|
||||||
|
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path,
|
||||||
|
options: @authentication_options,
|
||||||
|
mediation: "conditional",
|
||||||
|
error: { class: "txt-negative margin-block-half" },
|
||||||
|
cancellation: { class: "txt-subtle margin-block-half" },
|
||||||
|
class: "btn btn--plain txt-link center txt-small" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
# View helpers for rendering passkey web components.
|
# View helpers for rendering passkey registration and sign-in buttons.
|
||||||
#
|
|
||||||
# Include this module in your helper or ApplicationHelper to get access to:
|
|
||||||
#
|
|
||||||
# - +passkey_registration_button+ — render a <rails-passkey-registration-button> web component with
|
|
||||||
# a form, hidden fields, and error messages for the registration ceremony.
|
|
||||||
# - +passkey_sign_in_button+ — render a <rails-passkey-sign-in-button> web component with
|
|
||||||
# a form, hidden fields, and error messages for the authentication ceremony.
|
|
||||||
module ActionPack::Passkey::FormHelper
|
module ActionPack::Passkey::FormHelper
|
||||||
REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey."
|
REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey."
|
||||||
REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready."
|
REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready."
|
||||||
REGISTRATION_DUPLICATE_MESSAGE = "You already have a passkey registered on this device. Remove the existing one first to re-register."
|
REGISTRATION_DUPLICATE_MESSAGE = "You already have a passkey registered on this device. Remove the existing one first and try again."
|
||||||
SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey."
|
SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey."
|
||||||
SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready."
|
SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready."
|
||||||
|
|
||||||
# Renders a +<rails-passkey-registration-button>+ web component containing a form with hidden
|
# Renders a button for registering a new passkey. Accepts a +label+ string or a block
|
||||||
# fields for the passkey registration ceremony and error messages. The form POSTs to +url+ and
|
# for button content.
|
||||||
# includes hidden fields for +client_data_json+, +attestation_object+, and +transports+ —
|
|
||||||
# populated by the web component after the browser credential API resolves.
|
|
||||||
# Accepts a +label+ string or a block for button content.
|
|
||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# - +options+: WebAuthn creation options (JSON-serializable hash)
|
# - +options+: WebAuthn creation options (JSON-serializable hash)
|
||||||
# - +challenge_url+: endpoint to refresh the challenge nonce
|
# - +challenge_url+: endpoint to refresh the challenge nonce
|
||||||
|
# - +wrapper+: HTML attributes for the outer web component element
|
||||||
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
|
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
|
||||||
# to set the form parameter namespace (default: +:passkey+)
|
# to set the form parameter namespace (default: +:passkey+)
|
||||||
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
|
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
|
||||||
@@ -48,15 +39,14 @@ module ActionPack::Passkey::FormHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Renders a +<rails-passkey-sign-in-button>+ web component containing a form with hidden
|
# Renders a button for signing in with a passkey. Accepts a +label+ string or a block
|
||||||
# fields for the passkey authentication ceremony and error messages. The form POSTs to +url+
|
# for button content.
|
||||||
# and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and +signature+.
|
|
||||||
# Accepts a +label+ string or a block for button content.
|
|
||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# - +options+: WebAuthn request options (JSON-serializable hash)
|
# - +options+: WebAuthn request options (JSON-serializable hash)
|
||||||
# - +challenge_url+: endpoint to refresh the challenge nonce
|
# - +challenge_url+: endpoint to refresh the challenge nonce
|
||||||
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
|
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
|
||||||
|
# - +wrapper+: HTML attributes for the outer web component element
|
||||||
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
|
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
|
||||||
# to set the form parameter namespace (default: +:passkey+)
|
# to set the form parameter namespace (default: +:passkey+)
|
||||||
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
|
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
|
||||||
@@ -86,18 +76,21 @@ module ActionPack::Passkey::FormHelper
|
|||||||
private
|
private
|
||||||
def partition_passkey_options(url, options)
|
def partition_passkey_options(url, options)
|
||||||
passkey_options = options.fetch(:options, {})
|
passkey_options = options.fetch(:options, {})
|
||||||
|
wrapper_options = options.fetch(:wrapper, {})
|
||||||
|
|
||||||
component_options = options
|
component_options = options
|
||||||
.slice(:challenge_url, :mediation)
|
.slice(:challenge_url, :mediation)
|
||||||
.reverse_merge(challenge_url: default_passkey_challenge_url, options: passkey_options.to_json(except: :challenge))
|
.reverse_merge(challenge_url: default_passkey_challenge_url, options: passkey_options.to_json(except: :challenge))
|
||||||
|
|
||||||
form_options = options
|
form_options = options
|
||||||
.fetch(:form, {})
|
.fetch(:form, {})
|
||||||
.reverse_merge(method: :post, action: url, class: "button_to", param: :passkey)
|
.reverse_merge(method: :post, action: url, class: "button_to", param: :passkey)
|
||||||
|
|
||||||
error_options = options.slice(:error, :cancellation, :duplicate).reverse_merge(error: {}, cancellation: {}, duplicate: {})
|
error_options = options.slice(:error, :cancellation, :duplicate).reverse_merge(error: {}, cancellation: {}, duplicate: {})
|
||||||
|
|
||||||
button_options = options.except(:options, :form, *component_options.keys, *error_options.keys)
|
button_options = options.except(:options, :form, :wrapper, *component_options.keys, *error_options.keys)
|
||||||
|
|
||||||
[ component_options, form_options, button_options, error_options ]
|
[ wrapper_options.merge(component_options), form_options, button_options, error_options ]
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_passkey_challenge_url
|
def default_passkey_challenge_url
|
||||||
@@ -109,25 +102,27 @@ module ActionPack::Passkey::FormHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def passkey_error_messages(error: {}, cancellation: {}, duplicate: {})
|
def passkey_error_messages(error: {}, cancellation: {}, duplicate: {})
|
||||||
error_message = error[:message]
|
error_message, error_attributes = build_passkey_error_options("error", error)
|
||||||
error_attributes = error.except(:message)
|
cancellation_message, cancellation_attributes = build_passkey_error_options("cancelled", cancellation)
|
||||||
error_attributes[:data] ||= {}
|
|
||||||
error_attributes[:data][:passkey_error] = "error"
|
|
||||||
|
|
||||||
cancellation_message = cancellation[:message]
|
|
||||||
cancellation_attributes = cancellation.except(:message)
|
|
||||||
cancellation_attributes[:data] ||= {}
|
|
||||||
cancellation_attributes[:data][:passkey_error] = "cancelled"
|
|
||||||
|
|
||||||
messages = tag.div(error_message, hidden: true, **error_attributes) +
|
messages = tag.div(error_message, hidden: true, **error_attributes) +
|
||||||
tag.div(cancellation_message, hidden: true, **cancellation_attributes)
|
tag.div(cancellation_message, hidden: true, **cancellation_attributes)
|
||||||
|
|
||||||
if duplicate[:message]
|
if duplicate[:message]
|
||||||
duplicate_attributes = duplicate.except(:message)
|
duplicate_message, duplicate_attributes = build_passkey_error_options("duplicate", duplicate)
|
||||||
duplicate_attributes[:data] = { passkey_error: "duplicate" }
|
messages += tag.div(duplicate_message, hidden: true, **duplicate_attributes)
|
||||||
messages += tag.div(duplicate[:message], hidden: true, **duplicate_attributes)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
messages
|
messages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_passkey_error_options(type, options)
|
||||||
|
message = options[:message]
|
||||||
|
|
||||||
|
attributes = options.except(:message)
|
||||||
|
attributes[:data] ||= {}
|
||||||
|
attributes[:data][:passkey_error] = type
|
||||||
|
|
||||||
|
[ message, attributes ]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user