diff --git a/app/assets/images/authentication.svg b/app/assets/images/authentication.svg
new file mode 100644
index 000000000..6f02cc18b
--- /dev/null
+++ b/app/assets/images/authentication.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/key.svg b/app/assets/images/key.svg
deleted file mode 100644
index 386e0b501..000000000
--- a/app/assets/images/key.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/app/assets/images/passkeys/1password_dark.svg b/app/assets/images/passkeys/1password_dark.svg
new file mode 100644
index 000000000..ff3998898
--- /dev/null
+++ b/app/assets/images/passkeys/1password_dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/passkeys/1password_light.svg b/app/assets/images/passkeys/1password_light.svg
new file mode 100644
index 000000000..dcd5b1ce0
--- /dev/null
+++ b/app/assets/images/passkeys/1password_light.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/passkeys/apple_passwords_dark.svg b/app/assets/images/passkeys/apple_passwords_dark.svg
new file mode 100644
index 000000000..bd1715076
--- /dev/null
+++ b/app/assets/images/passkeys/apple_passwords_dark.svg
@@ -0,0 +1,38 @@
+
diff --git a/app/assets/images/passkeys/apple_passwords_light.svg b/app/assets/images/passkeys/apple_passwords_light.svg
new file mode 100644
index 000000000..bd1715076
--- /dev/null
+++ b/app/assets/images/passkeys/apple_passwords_light.svg
@@ -0,0 +1,38 @@
+
diff --git a/app/assets/images/passkeys/bitwarden_dark.svg b/app/assets/images/passkeys/bitwarden_dark.svg
new file mode 100644
index 000000000..761c97ab7
--- /dev/null
+++ b/app/assets/images/passkeys/bitwarden_dark.svg
@@ -0,0 +1,11 @@
+
diff --git a/app/assets/images/passkeys/bitwarden_light.svg b/app/assets/images/passkeys/bitwarden_light.svg
new file mode 100644
index 000000000..761c97ab7
--- /dev/null
+++ b/app/assets/images/passkeys/bitwarden_light.svg
@@ -0,0 +1,11 @@
+
diff --git a/app/assets/images/passkeys/dashlane_dark.svg b/app/assets/images/passkeys/dashlane_dark.svg
new file mode 100644
index 000000000..f23ec9e3f
--- /dev/null
+++ b/app/assets/images/passkeys/dashlane_dark.svg
@@ -0,0 +1,8 @@
+
diff --git a/app/assets/images/passkeys/dashlane_light.svg b/app/assets/images/passkeys/dashlane_light.svg
new file mode 100644
index 000000000..49f658ffd
--- /dev/null
+++ b/app/assets/images/passkeys/dashlane_light.svg
@@ -0,0 +1,8 @@
+
diff --git a/app/assets/images/passkeys/generic_dark.svg b/app/assets/images/passkeys/generic_dark.svg
new file mode 100644
index 000000000..e80a38065
--- /dev/null
+++ b/app/assets/images/passkeys/generic_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/generic_light.svg b/app/assets/images/passkeys/generic_light.svg
new file mode 100644
index 000000000..3f941f839
--- /dev/null
+++ b/app/assets/images/passkeys/generic_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/google_password_manager_dark.svg b/app/assets/images/passkeys/google_password_manager_dark.svg
new file mode 100644
index 000000000..b13ae8a5e
--- /dev/null
+++ b/app/assets/images/passkeys/google_password_manager_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/google_password_manager_light.svg b/app/assets/images/passkeys/google_password_manager_light.svg
new file mode 100644
index 000000000..b13ae8a5e
--- /dev/null
+++ b/app/assets/images/passkeys/google_password_manager_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/lastpass_dark.svg b/app/assets/images/passkeys/lastpass_dark.svg
new file mode 100644
index 000000000..764d273f5
--- /dev/null
+++ b/app/assets/images/passkeys/lastpass_dark.svg
@@ -0,0 +1,13 @@
+
diff --git a/app/assets/images/passkeys/lastpass_light.svg b/app/assets/images/passkeys/lastpass_light.svg
new file mode 100644
index 000000000..aac8009ea
--- /dev/null
+++ b/app/assets/images/passkeys/lastpass_light.svg
@@ -0,0 +1,13 @@
+
diff --git a/app/assets/images/passkeys/samsung_pass_dark.svg b/app/assets/images/passkeys/samsung_pass_dark.svg
new file mode 100644
index 000000000..399033941
--- /dev/null
+++ b/app/assets/images/passkeys/samsung_pass_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/samsung_pass_light.svg b/app/assets/images/passkeys/samsung_pass_light.svg
new file mode 100644
index 000000000..399033941
--- /dev/null
+++ b/app/assets/images/passkeys/samsung_pass_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/windows_hello_dark.svg b/app/assets/images/passkeys/windows_hello_dark.svg
new file mode 100644
index 000000000..960a7af56
--- /dev/null
+++ b/app/assets/images/passkeys/windows_hello_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/windows_hello_light.svg b/app/assets/images/passkeys/windows_hello_light.svg
new file mode 100644
index 000000000..960a7af56
--- /dev/null
+++ b/app/assets/images/passkeys/windows_hello_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/yubikey_dark.svg b/app/assets/images/passkeys/yubikey_dark.svg
new file mode 100644
index 000000000..61bf17f54
--- /dev/null
+++ b/app/assets/images/passkeys/yubikey_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/passkeys/yubikey_light.svg b/app/assets/images/passkeys/yubikey_light.svg
new file mode 100644
index 000000000..61bf17f54
--- /dev/null
+++ b/app/assets/images/passkeys/yubikey_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css
new file mode 100644
index 000000000..64e39005b
--- /dev/null
+++ b/app/assets/stylesheets/credentials.css
@@ -0,0 +1,29 @@
+@layer components {
+ .passkey-icon--dark {
+ display: none;
+ }
+
+ .passkey-icon--light {
+ html[data-theme="dark"] & {
+ display: none;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ display: none;
+ }
+ }
+ }
+
+ .passkey-icon--dark {
+ html[data-theme="dark"] & {
+ display: inline;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ html:not([data-theme]) & {
+ display: inline;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css
index 35680e429..2f7fe8564 100644
--- a/app/assets/stylesheets/icons.css
+++ b/app/assets/stylesheets/icons.css
@@ -27,6 +27,7 @@
.icon--art { --svg: url("art.svg "); }
.icon--assigned { --svg: url("assigned.svg "); }
.icon--attachment { --svg: url("attachment.svg "); }
+ .icon--authentication { --svg: url("authentication.svg "); }
.icon--bell-alert { --svg: url("bell-alert.svg "); }
.icon--bell-off { --svg: url("bell-off.svg "); }
.icon--bell { --svg: url("bell.svg "); }
@@ -62,7 +63,6 @@
.icon--history { --svg: url("history.svg "); }
.icon--home { --svg: url("home.svg "); }
.icon--install-edge { --svg: url("install-edge.svg "); }
- .icon--key { --svg: url("key.svg "); }
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
.icon--lock { --svg: url("lock.svg "); }
.icon--logout { --svg: url("logout.svg "); }
diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb
index 193a2713b..689bc146a 100644
--- a/app/controllers/concerns/current_request.rb
+++ b/app/controllers/concerns/current_request.rb
@@ -10,6 +10,7 @@ module CurrentRequest
Current.referrer = request.referrer
ActionPack::WebAuthn::Current.host = request.host
+ ActionPack::WebAuthn::Current.origin = request.base_url
end
end
end
diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb
index 5b997260b..18917583d 100644
--- a/app/controllers/sessions/passkeys_controller.rb
+++ b/app/controllers/sessions/passkeys_controller.rb
@@ -5,12 +5,8 @@ class Sessions::PasskeysController < ApplicationController
def create
credential = Identity::Credential.authenticate(
- id: params.expect(:credential_id),
- client_data_json: response_params[:client_data_json],
- authenticator_data: response_params[:authenticator_data],
- signature: response_params[:signature],
- challenge: session.delete(:webauthn_challenge),
- origin: request.base_url
+ passkey: passkey_params,
+ challenge: session.delete(:webauthn_challenge)
)
if credential
@@ -21,8 +17,8 @@ class Sessions::PasskeysController < ApplicationController
end
private
- def response_params
- params.expect(response: [ :client_data_json, :authenticator_data, :signature ])
+ def passkey_params
+ params.expect(passkey: [ :id, :client_data_json, :authenticator_data, :signature ])
end
def authentication_succeeded(identity)
diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb
index 65ebe42ff..aa0f3d8e9 100644
--- a/app/controllers/users/credentials_controller.rb
+++ b/app/controllers/users/credentials_controller.rb
@@ -1,26 +1,28 @@
class Users::CredentialsController < ApplicationController
- before_action :set_credential, only: :destroy
+ before_action :set_credential, only: %i[ edit update destroy ]
def index
@credentials = Current.identity.credentials.order(name: :asc, created_at: :desc)
- end
-
- def new
@creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name)
session[:webauthn_challenge] = @creation_options.challenge
end
def create
- Identity::Credential.register(
- identity: Current.identity,
- name: credential_params[:name],
- client_data_json: credential_params[:client_data_json],
- attestation_object: credential_params[:attestation_object],
- challenge: session.delete(:webauthn_challenge),
- origin: request.base_url,
- transports: Array(credential_params[:transports])
+ credential = Current.identity.credentials.register(
+ passkey: passkey_params,
+ challenge: session.delete(:webauthn_challenge)
)
+ render json: { location: edit_user_credential_path(Current.user, credential, created: true) }
+ rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError => error
+ render json: { error: error.message }, status: :unprocessable_entity
+ end
+
+ def edit
+ end
+
+ def update
+ @credential.update!(credential_params)
redirect_to user_credentials_path(Current.user)
end
@@ -35,6 +37,10 @@ class Users::CredentialsController < ApplicationController
end
def credential_params
- params.expect(credential: [ :name, :client_data_json, :attestation_object, transports: [] ])
+ params.expect(credential: [ :name ])
+ end
+
+ def passkey_params
+ params.expect(passkey: [ :client_data_json, :attestation_object, transports: [] ])
end
end
diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js
index bd9b0baee..ddc76085d 100644
--- a/app/javascript/controllers/credential_controller.js
+++ b/app/javascript/controllers/credential_controller.js
@@ -1,60 +1,60 @@
import { Controller } from "@hotwired/stimulus"
+import { post } from "@rails/request.js"
+import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers"
export default class extends Controller {
- static values = { publicKey: Object }
- static targets = ["clientDataJSON", "attestationObject"]
+ static values = { publicKey: Object, registerUrl: String }
+ static targets = ["button", "error", "cancelled"]
- async create(event) {
- event.preventDefault()
+ async create() {
+ this.buttonTarget.disabled = true
+ this.errorTarget.hidden = true
+ this.cancelledTarget.hidden = true
try {
- const publicKey = this.prepareOptions(this.publicKeyValue)
+ const publicKey = this.#prepareOptions(this.publicKeyValue)
const credential = await navigator.credentials.create({ publicKey })
- this.submitCredential(credential)
+ await this.#registerCredential(credential)
} catch (error) {
- if (error.name !== "AbortError" && error.name !== "NotAllowedError") {
- console.error("Registration failed:", error)
+ if (error.name === "AbortError" || error.name === "NotAllowedError") {
+ this.cancelledTarget.hidden = false
+ } else {
+ this.errorTarget.hidden = false
}
+ this.buttonTarget.disabled = false
}
}
- submitCredential(credential) {
- this.clientDataJSONTarget.value = this.bufferToBase64url(credential.response.clientDataJSON)
- this.attestationObjectTarget.value = this.bufferToBase64url(credential.response.attestationObject)
+ async #registerCredential(credential) {
+ const response = await post(this.registerUrlValue, {
+ body: JSON.stringify({
+ passkey: {
+ client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
+ attestation_object: bufferToBase64url(credential.response.attestationObject),
+ transports: credential.response.getTransports?.() || []
+ }
+ }),
+ contentType: "application/json",
+ responseKind: "json"
+ })
- for (const transport of credential.response.getTransports?.() || []) {
- const input = document.createElement("input")
- input.type = "hidden"
- input.name = "credential[transports][]"
- input.value = transport
- this.element.appendChild(input)
+ if (response.ok) {
+ const { location } = await response.json
+ Turbo.visit(location)
+ } else {
+ throw new Error("Registration failed")
}
-
- this.element.requestSubmit()
}
- prepareOptions(options) {
+ #prepareOptions(options) {
return {
...options,
- challenge: this.base64urlToBuffer(options.challenge),
- user: { ...options.user, id: this.base64urlToBuffer(options.user.id) },
+ challenge: base64urlToBuffer(options.challenge),
+ user: { ...options.user, id: base64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred,
- id: this.base64urlToBuffer(cred.id)
+ id: base64urlToBuffer(cred.id)
}))
}
}
-
- base64urlToBuffer(base64url) {
- const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
- const padding = "=".repeat((4 - base64.length % 4) % 4)
- const binary = atob(base64 + padding)
- return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
- }
-
- bufferToBase64url(buffer) {
- const bytes = new Uint8Array(buffer)
- const binary = String.fromCharCode(...bytes)
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
- }
}
diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js
index 87145db77..566518bb9 100644
--- a/app/javascript/controllers/passkey_controller.js
+++ b/app/javascript/controllers/passkey_controller.js
@@ -1,4 +1,5 @@
import { Controller } from "@hotwired/stimulus"
+import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers"
export default class extends Controller {
static values = { publicKey: Object, url: String, csrfToken: String }
@@ -41,10 +42,10 @@ export default class extends Controller {
const fields = {
authenticity_token: this.csrfTokenValue,
- credential_id: credential.id,
- "response[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON),
- "response[authenticator_data]": this.#bufferToBase64url(credential.response.authenticatorData),
- "response[signature]": this.#bufferToBase64url(credential.response.signature)
+ "passkey[id]": credential.id,
+ "passkey[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON),
+ "passkey[authenticator_data]": bufferToBase64url(credential.response.authenticatorData),
+ "passkey[signature]": bufferToBase64url(credential.response.signature)
}
for (const [name, value] of Object.entries(fields)) {
@@ -62,13 +63,13 @@ export default class extends Controller {
#prepareOptions(options) {
const prepared = {
...options,
- challenge: this.#base64urlToBuffer(options.challenge)
+ challenge: base64urlToBuffer(options.challenge)
}
if (options.allowCredentials?.length) {
prepared.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
- id: this.#base64urlToBuffer(cred.id)
+ id: base64urlToBuffer(cred.id)
}))
} else {
delete prepared.allowCredentials
@@ -76,17 +77,4 @@ export default class extends Controller {
return prepared
}
-
- #base64urlToBuffer(base64url) {
- const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
- const padding = "=".repeat((4 - base64.length % 4) % 4)
- const binary = atob(base64 + padding)
- return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
- }
-
- #bufferToBase64url(buffer) {
- const bytes = new Uint8Array(buffer)
- const binary = String.fromCharCode(...bytes)
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
- }
}
diff --git a/app/javascript/helpers/base64url_helpers.js b/app/javascript/helpers/base64url_helpers.js
new file mode 100644
index 000000000..945826f58
--- /dev/null
+++ b/app/javascript/helpers/base64url_helpers.js
@@ -0,0 +1,12 @@
+export function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
+ const padding = "=".repeat((4 - base64.length % 4) % 4)
+ const binary = atob(base64 + padding)
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
+}
+
+export function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer)
+ const binary = String.fromCharCode(...bytes)
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
+}
diff --git a/app/models/identity/credential.rb b/app/models/identity/credential.rb
index eb03add28..d233fbcb6 100644
--- a/app/models/identity/credential.rb
+++ b/app/models/identity/credential.rb
@@ -18,44 +18,51 @@ class Identity::Credential < ApplicationRecord
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential))
end
- def authenticate(id:, **params)
- find_by(credential_id: id)&.authenticate(**params)
- end
-
- def register(identity:, name:, client_data_json:, attestation_object:, challenge:, origin:, transports: [])
+ def register(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin, **attributes)
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
- client_data_json: Base64.urlsafe_decode64(client_data_json),
- attestation_object: Base64.urlsafe_decode64(attestation_object),
+ client_data_json: passkey[:client_data_json],
+ attestation_object: Base64.urlsafe_decode64(passkey[:attestation_object]),
challenge: challenge,
origin: origin,
- transports: transports
+ transports: Array(passkey[:transports])
)
- identity.credentials.create!(
- name: name,
+ create!(
+ **attributes,
+ name: attributes.fetch(:name, Authenticator.find_by_aaguid(public_key_credential.aaguid)&.name),
credential_id: public_key_credential.id,
public_key: public_key_credential.public_key.to_der,
sign_count: public_key_credential.sign_count,
+ aaguid: public_key_credential.aaguid,
+ backed_up: public_key_credential.backed_up,
transports: public_key_credential.transports
)
end
+
+ def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
+ find_by(credential_id: passkey[:id])&.authenticate(passkey: passkey, challenge: challenge, origin: origin)
+ end
end
- def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:)
+ def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
pkc = to_public_key_credential
pkc.authenticate(
- client_data_json: client_data_json,
- authenticator_data: Base64.urlsafe_decode64(authenticator_data),
- signature: Base64.urlsafe_decode64(signature),
+ client_data_json: passkey[:client_data_json],
+ authenticator_data: Base64.urlsafe_decode64(passkey[:authenticator_data]),
+ signature: Base64.urlsafe_decode64(passkey[:signature]),
challenge: challenge,
origin: origin
)
- update!(sign_count: pkc.sign_count)
+ update!(sign_count: pkc.sign_count, backed_up: pkc.backed_up)
self
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError
nil
end
+ def authenticator
+ Authenticator.find_by_aaguid(aaguid)
+ end
+
def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id,
diff --git a/app/models/identity/credential/authenticator.rb b/app/models/identity/credential/authenticator.rb
new file mode 100644
index 000000000..72ebece0f
--- /dev/null
+++ b/app/models/identity/credential/authenticator.rb
@@ -0,0 +1,12 @@
+class Identity::Credential::Authenticator < Data.define(:name, :icon)
+ REGISTRY = Rails.application.config_for(:passkey_aaguids).each_with_object({}) do |(_key, attrs), hash|
+ authenticator = new(name: attrs[:name], icon: attrs[:icon])
+ attrs[:aaguids].each { |aaguid| hash[aaguid] = authenticator }
+ end.freeze
+
+ class << self
+ def find_by_aaguid(aaguid)
+ REGISTRY[aaguid]
+ end
+ end
+end
diff --git a/app/views/users/credentials/_credential.html.erb b/app/views/users/credentials/_credential.html.erb
index a240fc46f..38fe5cb0e 100644
--- a/app/views/users/credentials/_credential.html.erb
+++ b/app/views/users/credentials/_credential.html.erb
@@ -1,11 +1,10 @@
-
- | <%= credential.name.presence || "Passkey" %> |
-
- <%= button_to user_credential_path(Current.user, credential), method: :delete,
- class: "btn btn--circle txt-negative txt-xx-small borderless",
- data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
- <%= icon_tag "trash" %>
- Remove this passkey
- <% end %>
- |
-
+
+ <%= link_to edit_user_credential_path(Current.user, credential), class: "txt-ink flex align-center gap txt-medium full-width" do %>
+ <% authenticator = credential.authenticator %>
+ <%= image_tag authenticator&.icon&.[](:light) || "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--light", aria: { hidden: true } %>
+ <%= image_tag authenticator&.icon&.[](:dark) || "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--dark", aria: { hidden: true } %>
+ <%= credential.name.presence || "Passkey" %>
+
+ <%= icon_tag "arrow-right", class: "txt-subtle txt-xx-small flex-item-no-shrink" %>
+ <% end %>
+
diff --git a/app/views/users/credentials/edit.html.erb b/app/views/users/credentials/edit.html.erb
new file mode 100644
index 000000000..c8e4ddfa4
--- /dev/null
+++ b/app/views/users/credentials/edit.html.erb
@@ -0,0 +1,33 @@
+<% @page_title = "Name your passkey" %>
+
+<% content_for :header do %>
+
+
+
+<% end %>
+
+
+ <% if params[:created] %>
+ Your passkey has been registered. Give it a name so you can identify it later.
+ <% end %>
+
+ <%= form_with model: @credential, scope: :credential, url: user_credential_path(Current.user, @credential), html: { class: "flex flex-column gap" } do |form| %>
+
+ <%= form.label :name %>
+ <%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
+
+
+ <%= form.submit "Save", class: "btn btn--primary" %>
+ <% end %>
+
+
+ <%= button_to user_credential_path(Current.user, @credential), method: :delete,
+ class: "btn txt-negative borderless txt-small",
+ data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
+ <%= icon_tag "trash" %>
+ Remove this passkey
+ <% end %>
+
+
diff --git a/app/views/users/credentials/index.html.erb b/app/views/users/credentials/index.html.erb
index 3b54e1e9f..3278f6d14 100644
--- a/app/views/users/credentials/index.html.erb
+++ b/app/views/users/credentials/index.html.erb
@@ -8,25 +8,33 @@
<% end %>
-
- Passkeys let you sign in using your device’s biometrics, PIN, or security key—no email code needed.
+
+
+ Passkeys let you sign in securely without a password or email code.
<% if @credentials.any? %>
-
-
-
- | Passkey |
- |
-
-
-
- <%= render partial: "users/credentials/credential", collection: @credentials %>
-
-
+
+ <%= render partial: "users/credentials/credential", collection: @credentials %>
+
<% end %>
- <%= link_to new_user_credential_path(Current.user), class: "btn btn--link" do %>
+ Your browser will prompt you to create a passkey
+ using your device's biometrics, PIN, or security key
+
+
+
+
+ Something went wrong while registering your passkey.
+
+
+
+ Passkey registration was cancelled.
+ Try again when you are ready.
+
diff --git a/app/views/users/credentials/new.html.erb b/app/views/users/credentials/new.html.erb
deleted file mode 100644
index 15ad75378..000000000
--- a/app/views/users/credentials/new.html.erb
+++ /dev/null
@@ -1,31 +0,0 @@
-<% @page_title = "Add a passkey" %>
-
-<% content_for :header do %>
-
-
-
-<% end %>
-
-
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index ad4ba3a59..f75febf2a 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -6,7 +6,7 @@
<% if Current.user == @user %>
<%= link_to user_credentials_path(@user), class: "user-edit-link btn", style: "inset: 0 auto auto 0", data: { controller: "tooltip" } do %>
- <%= icon_tag "key" %>
+ <%= icon_tag "authentication" %>
Manage passkeys
<% end %>
diff --git a/config/passkey_aaguids.yml b/config/passkey_aaguids.yml
new file mode 100644
index 000000000..537d99683
--- /dev/null
+++ b/config/passkey_aaguids.yml
@@ -0,0 +1,124 @@
+shared:
+ apple_passwords:
+ name: Apple Passwords
+ icon:
+ light: passkeys/apple_passwords_light.svg
+ dark: passkeys/apple_passwords_dark.svg
+ aaguids:
+ - dd4ec289-e01d-41c9-bb89-70fa845d4bf2
+ - fbfc3007-154e-4ecc-8c0b-6e020557d7bd
+
+ google_password_manager:
+ name: Google Password Manager
+ icon:
+ light: passkeys/google_password_manager_light.svg
+ dark: passkeys/google_password_manager_dark.svg
+ aaguids:
+ - ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4
+
+ windows_hello:
+ name: Windows Hello
+ icon:
+ light: passkeys/windows_hello_light.svg
+ dark: passkeys/windows_hello_dark.svg
+ aaguids:
+ - 08987058-cadc-4b81-b6e1-30de50dcbe96
+ - 6028b017-b1d4-4c02-b4b3-afcdafc96bb2
+ - 9ddd1817-af5a-4672-a2b9-3e3dd95000a9
+
+ 1password:
+ name: 1Password
+ icon:
+ light: passkeys/1password_light.svg
+ dark: passkeys/1password_dark.svg
+ aaguids:
+ - bada5566-a7aa-401f-bd96-45619a55120d
+
+ bitwarden:
+ name: Bitwarden
+ icon:
+ light: passkeys/bitwarden_light.svg
+ dark: passkeys/bitwarden_dark.svg
+ aaguids:
+ - d548826e-79b4-db40-a3d8-11116f7e8349
+
+ samsung_pass:
+ name: Samsung Pass
+ icon:
+ light: passkeys/samsung_pass_light.svg
+ dark: passkeys/samsung_pass_dark.svg
+ aaguids:
+ - 53414d53-554e-4700-0000-000000000000
+
+ lastpass:
+ name: LastPass
+ icon:
+ light: passkeys/lastpass_light.svg
+ dark: passkeys/lastpass_dark.svg
+ aaguids:
+ - b78a0a55-6ef8-d246-a042-ba0f6d55050c
+
+ dashlane:
+ name: Dashlane
+ icon:
+ light: passkeys/dashlane_light.svg
+ dark: passkeys/dashlane_dark.svg
+ aaguids:
+ - 531126d6-e717-415c-9320-3d9aa6981239
+
+ yubikey:
+ name: YubiKey
+ icon:
+ light: passkeys/yubikey_light.svg
+ dark: passkeys/yubikey_dark.svg
+ aaguids:
+ - 19083c3d-8383-4b18-bc03-8f1c9ab2fd1b
+ - 1ac71f64-468d-4fe0-bef1-0e5f2f551f18
+ - 20ac7a17-c814-4833-93fe-539f0d5e3389
+ - 24673149-6c86-42e7-98d9-433fb5b73296
+ - 2fc0579f-8113-47ea-b116-bb5a8db9202a
+ - 3124e301-f14e-4e38-876d-fbeeb090e7bf
+ - 34744913-4f57-4e6e-a527-e9ec3c4b94e6
+ - 34f5766d-1536-4a24-9033-0e294e510fb0
+ - 3a662962-c6d4-4023-bebb-98ae92e78e20
+ - 3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5
+ - 3b24bf49-1d45-4484-a917-13175df0867b
+ - 4599062e-6926-4fe7-9566-9e8fb1aedaa0
+ - 4fc84f16-2545-4e53-b8fc-7bf4d7282a10
+ - 57f7de54-c807-4eab-b1c6-1c9be7984e92
+ - 58276709-bb4b-4bb3-baf1-60eea99282a7
+ - 5b0e46ba-db02-44ac-b979-ca9b84f5e335
+ - 62e54e98-c209-4df3-b692-de71bb6a8528
+ - 662ef48a-95e2-4aaa-a6c1-5b9c40375824
+ - 6ab56fad-881f-4a43-acb2-0be065924522
+ - 6ec5cff2-a0f9-4169-945b-f33b563f7b99
+ - 73bb0cd4-e502-49b8-9c6f-b59445bf720b
+ - 7409272d-1ff9-4e10-9fc9-ac0019c124fd
+ - 79f3c8ba-9e35-484b-8f47-53a5a0f5c630
+ - 7b96457d-e3cd-432b-9ceb-c9fdd7ef7432
+ - 7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3
+ - 83c47309-aabb-4108-8470-8be838b573cb
+ - 85203421-48f9-4355-9bc8-8a53846e5083
+ - 8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee
+ - 905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7
+ - 90636e1f-ef82-43bf-bdcf-5255f139d12f
+ - 97e6a830-c952-4740-95fc-7c78dc97ce47
+ - 9e66c661-e428-452a-a8fb-51f7ed088acf
+ - 9eb7eabc-9db5-49a1-b6c3-555a802093f4
+ - a02167b9-ae71-4ac7-9a07-06432ebb6f1c
+ - a25342c0-3cdc-4414-8e46-f4807fca511c
+ - ad08c78a-4e41-49b9-86a2-ac15b06899e2
+ - b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc
+ - b90e7dc1-316e-4fee-a25a-56a666a670fe
+ - c1f9a0bc-1dd2-404a-b27f-8e29047a43fd
+ - c5ef55ff-ad9a-4b9f-b580-adebafe026d0
+ - cb69481e-8ff7-4039-93ec-0a2729a154a8
+ - ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1
+ - d2fbd093-ee62-488d-9dad-1e36389f8826
+ - d7781e5d-e353-46aa-afe2-3ca49f13332a
+ - d8522d9f-575b-4866-88a9-ba99fa02f35b
+ - dd86a2da-86a0-4cbe-b462-4bd31f57bc6f
+ - ee882879-721c-4913-9775-3dfcce97072a
+ - fa2b99dc-9e39-4257-8f92-4a30d23c4118
+ - fcc0118f-cd45-435b-8da1-9782b2da0715
+ - ff4dac45-ede8-4ec2-aced-cf66103f4335
diff --git a/config/routes.rb b/config/routes.rb
index d1ca55416..6353681d7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -15,7 +15,7 @@ Rails.application.routes.draw do
resource :avatar
resource :role
resource :events
- resources :credentials, only: [ :index, :new, :create, :destroy ]
+ resources :credentials, except: %i[ show new ]
resources :push_subscriptions
diff --git a/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb b/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb
new file mode 100644
index 000000000..172d88839
--- /dev/null
+++ b/db/migrate/20260219095815_add_aaguid_and_backed_up_to_identity_credentials.rb
@@ -0,0 +1,6 @@
+class AddAaguidAndBackedUpToIdentityCredentials < ActiveRecord::Migration[8.2]
+ def change
+ add_column :identity_credentials, :aaguid, :string
+ add_column :identity_credentials, :backed_up, :boolean
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 76ab5b9a0..07248185c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
+ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -345,6 +345,8 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
end
create_table "identity_credentials", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "aaguid"
+ t.boolean "backed_up"
t.datetime "created_at", null: false
t.string "credential_id", null: false
t.uuid "identity_id", null: false
diff --git a/lib/action_pack/web_authn.rb b/lib/action_pack/web_authn.rb
index 8ee72e6cb..f80c13321 100644
--- a/lib/action_pack/web_authn.rb
+++ b/lib/action_pack/web_authn.rb
@@ -3,5 +3,15 @@ module ActionPack::WebAuthn
def relying_party
RelyingParty.new
end
+
+ def attestation_verifiers
+ @attestation_verifiers ||= {
+ "none" => Authenticator::AttestationVerifiers::None.new
+ }
+ end
+
+ def register_attestation_verifier(format, verifier)
+ attestation_verifiers[format.to_s] = verifier
+ end
end
end
diff --git a/lib/action_pack/web_authn/authenticator/attestation.rb b/lib/action_pack/web_authn/authenticator/attestation.rb
index 66714fd6c..beacf0fac 100644
--- a/lib/action_pack/web_authn/authenticator/attestation.rb
+++ b/lib/action_pack/web_authn/authenticator/attestation.rb
@@ -38,7 +38,7 @@
class ActionPack::WebAuthn::Authenticator::Attestation
attr_reader :authenticator_data, :format, :attestation_statement
- delegate :credential_id, :public_key, :public_key_bytes, :sign_count, to: :authenticator_data
+ delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
def self.decode(bytes)
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
diff --git a/lib/action_pack/web_authn/authenticator/attestation_response.rb b/lib/action_pack/web_authn/authenticator/attestation_response.rb
index e20b4d7f7..d27cc499b 100644
--- a/lib/action_pack/web_authn/authenticator/attestation_response.rb
+++ b/lib/action_pack/web_authn/authenticator/attestation_response.rb
@@ -25,10 +25,10 @@
# In addition to the base Response validations, this class verifies:
#
# * The client data type is "webauthn.create"
-# * The attestation format is "none" (other formats require certificate verification)
+# * The attestation format has a registered verifier
+# * The attestation statement passes format-specific verification
#
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
- SUPPORTED_ATTESTATION_FORMATS = %w[ none ].freeze
attr_reader :attestation_object
def initialize(attestation_object:, **attributes)
@@ -43,9 +43,13 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::Web
raise InvalidResponseError, "Client data type is not webauthn.create"
end
- unless SUPPORTED_ATTESTATION_FORMATS.include?(attestation.format)
+ verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
+
+ unless verifier
raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}"
end
+
+ verifier.verify!(attestation, client_data_json: client_data_json)
end
def attestation
diff --git a/lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb b/lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb
new file mode 100644
index 000000000..ebb039054
--- /dev/null
+++ b/lib/action_pack/web_authn/authenticator/attestation_verifiers/none.rb
@@ -0,0 +1,24 @@
+# = Action Pack WebAuthn None Attestation Verifier
+#
+# Verifies attestation responses with the "none" format, which indicates the
+# authenticator did not provide any attestation statement. This is the default
+# format used by most consumer authenticators.
+#
+# == Implementing Custom Verifiers
+#
+# To support other attestation formats (e.g., "packed", "fido-u2f"), implement
+# a class with the same +verify!+ interface and register it:
+#
+# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
+#
+# The +verify!+ method receives the decoded +Attestation+ object and the raw
+# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails.
+#
+class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
+ def verify!(attestation, client_data_json:)
+ if attestation.attestation_statement.present?
+ raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError,
+ "Attestation statement must be empty for 'none' format"
+ end
+ end
+end
diff --git a/lib/action_pack/web_authn/authenticator/data.rb b/lib/action_pack/web_authn/authenticator/data.rb
index a088179ef..5d4e0f224 100644
--- a/lib/action_pack/web_authn/authenticator/data.rb
+++ b/lib/action_pack/web_authn/authenticator/data.rb
@@ -57,7 +57,7 @@ class ActionPack::WebAuthn::Authenticator::Data
BACKUP_STATE_FLAG = 0x10
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
- attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :credential_id, :public_key_bytes
+ attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
class << self
def wrap(data)
@@ -81,10 +81,13 @@ class ActionPack::WebAuthn::Authenticator::Data
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
position += SIGN_COUNT_LENGTH
+ aaguid = nil
credential_id = nil
public_key_bytes = nil
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
+ aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
+ aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
position += AAGUID_LENGTH
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
@@ -101,17 +104,19 @@ class ActionPack::WebAuthn::Authenticator::Data
relying_party_id_hash: relying_party_id_hash,
flags: flags,
sign_count: sign_count,
+ aaguid: aaguid,
credential_id: credential_id,
public_key_bytes: public_key_bytes
)
end
end
- def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, credential_id:, public_key_bytes:)
+ def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)
@bytes = bytes
@relying_party_id_hash = relying_party_id_hash
@flags = flags
@sign_count = sign_count
+ @aaguid = aaguid
@credential_id = credential_id
@public_key_bytes = public_key_bytes
end
diff --git a/lib/action_pack/web_authn/current.rb b/lib/action_pack/web_authn/current.rb
index ab9038a80..88183af54 100644
--- a/lib/action_pack/web_authn/current.rb
+++ b/lib/action_pack/web_authn/current.rb
@@ -1,3 +1,3 @@
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
- attribute :host
+ attribute :host, :origin
end
diff --git a/lib/action_pack/web_authn/public_key_credential.rb b/lib/action_pack/web_authn/public_key_credential.rb
index 6ca8a99df..062efab77 100644
--- a/lib/action_pack/web_authn/public_key_credential.rb
+++ b/lib/action_pack/web_authn/public_key_credential.rb
@@ -1,5 +1,5 @@
class ActionPack::WebAuthn::PublicKeyCredential
- attr_reader :id, :public_key, :sign_count, :transports, :owner
+ attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports, :owner
class << self
def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil)
@@ -14,16 +14,20 @@ class ActionPack::WebAuthn::PublicKeyCredential
id: response.attestation.credential_id,
public_key: response.attestation.public_key,
sign_count: response.attestation.sign_count,
+ aaguid: response.attestation.aaguid,
+ backed_up: response.attestation.backed_up?,
transports: transports,
owner: owner
)
end
end
- def initialize(id:, public_key:, sign_count:, transports: [], owner: nil)
+ def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [], owner: nil)
@id = id
@public_key = public_key
@sign_count = sign_count
+ @aaguid = aaguid
+ @backed_up = backed_up
@transports = transports
@owner = owner
end
@@ -39,5 +43,6 @@ class ActionPack::WebAuthn::PublicKeyCredential
response.validate!(challenge: challenge, origin: origin)
@sign_count = response.authenticator_data.sign_count
+ @backed_up = response.authenticator_data.backed_up?
end
end
diff --git a/lib/action_pack/web_authn/public_key_credential/creation_options.rb b/lib/action_pack/web_authn/public_key_credential/creation_options.rb
index 46510ad62..e68d8eba4 100644
--- a/lib/action_pack/web_authn/public_key_credential/creation_options.rb
+++ b/lib/action_pack/web_authn/public_key_credential/creation_options.rb
@@ -68,7 +68,11 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
# [+:exclude_credentials+]
# Optional. Existing credentials to exclude from registration. Each must
# respond to +id+ and +transports+.
- def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], **attrs)
+ #
+ # [+:attestation+]
+ # Optional. The attestation conveyance preference. One of +:none+,
+ # +:indirect+, +:direct+, or +:enterprise+. Defaults to +:none+.
+ def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], attestation: :none, **attrs)
super(**attrs)
@id = id
@@ -76,6 +80,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
@display_name = display_name
@resident_key = resident_key
@exclude_credentials = exclude_credentials
+ @attestation = validate_attestation(attestation)
end
# Returns a Hash suitable for JSON serialization and passing to the
@@ -104,10 +109,24 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
end
+ if @attestation != :none
+ json[:attestation] = @attestation.to_s
+ end
+
json
end
private
+ ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
+
+ def validate_attestation(value)
+ if ATTESTATION_PREFERENCES.include?(value)
+ value
+ else
+ raise ArgumentError, "Invalid attestation preference: #{value.inspect}. Must be one of: #{ATTESTATION_PREFERENCES.map(&:inspect).join(", ")}"
+ end
+ end
+
def exclude_credential_json(credential)
hash = { type: "public-key", id: credential.id }
hash[:transports] = credential.transports if credential.transports.any?
diff --git a/test/controllers/sessions/passkeys_controller_test.rb b/test/controllers/sessions/passkeys_controller_test.rb
index 88512f2df..e8e8d315a 100644
--- a/test/controllers/sessions/passkeys_controller_test.rb
+++ b/test/controllers/sessions/passkeys_controller_test.rb
@@ -44,7 +44,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
challenge = session[:webauthn_challenge]
params = assertion_params(challenge: challenge)
- params[:response][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
+ params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
post session_passkey_url, params: params
@@ -59,8 +59,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
get new_session_url
post session_passkey_url, params: {
- credential_id: "nonexistent",
- response: {
+ passkey: {
+ id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false)
@@ -89,8 +89,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
get new_session_url
post session_passkey_url(format: :json), params: {
- credential_id: "nonexistent",
- response: {
+ passkey: {
+ id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false)
@@ -116,8 +116,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
signature = sign(authenticator_data, client_data_json)
{
- credential_id: @credential.credential_id,
- response: {
+ passkey: {
+ id: @credential.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb
index 5d58a3ff4..bed10e4a4 100644
--- a/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb
+++ b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb
@@ -98,7 +98,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
assert_equal "Origin does not match", error.message
end
- test "validate! raises when attestation format is not supported" do
+ test "validate! raises when attestation format is not registered" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true, format: "packed")
@@ -111,6 +111,24 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
assert_equal "Unsupported attestation format: packed", error.message
end
+ test "validate! calls registered verifier for custom format" do
+ verified = false
+ custom_verifier = Object.new
+ custom_verifier.define_singleton_method(:verify!) { |_attestation, client_data_json:| verified = true }
+
+ ActionPack::WebAuthn.register_attestation_verifier("packed", custom_verifier)
+
+ response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
+ client_data_json: @client_data_json,
+ attestation_object: build_attestation_object(user_verified: true, format: "packed")
+ )
+
+ response.validate!(challenge: @challenge, origin: @origin)
+ assert verified
+ ensure
+ ActionPack::WebAuthn.attestation_verifiers.delete("packed")
+ end
+
private
def build_attestation_object(user_verified:, format: "none")
auth_data = build_authenticator_data(user_verified: user_verified)
diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb
new file mode 100644
index 000000000..5d3b71e23
--- /dev/null
+++ b/test/lib/action_pack/web_authn/authenticator/attestation_verifiers/none_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < ActiveSupport::TestCase
+ setup do
+ @verifier = ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None.new
+ end
+
+ test "verify! passes with nil attestation statement" do
+ attestation = stub(attestation_statement: nil)
+
+ assert_nothing_raised do
+ @verifier.verify!(attestation, client_data_json: "")
+ end
+ end
+
+ test "verify! passes with empty attestation statement" do
+ attestation = stub(attestation_statement: {})
+
+ assert_nothing_raised do
+ @verifier.verify!(attestation, client_data_json: "")
+ end
+ end
+
+ test "verify! raises with non-empty attestation statement" do
+ attestation = stub(attestation_statement: { "sig" => "abc" })
+
+ error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
+ @verifier.verify!(attestation, client_data_json: "")
+ end
+
+ assert_equal "Attestation statement must be empty for 'none' format", error.message
+ end
+end
diff --git a/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb
index 2fc1acfbc..232cb74ba 100644
--- a/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb
+++ b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb
@@ -84,6 +84,34 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
], options.as_json[:excludeCredentials]
end
+ test "as_json excludes attestation when none" do
+ assert_nil @options.as_json[:attestation]
+ end
+
+ test "as_json includes attestation when not none" do
+ options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
+ id: "user-123",
+ name: "user@example.com",
+ display_name: "Test User",
+ attestation: :direct,
+ relying_party: @relying_party
+ )
+
+ assert_equal "direct", options.as_json[:attestation]
+ end
+
+ test "raises with invalid attestation preference" do
+ assert_raises(ArgumentError) do
+ ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
+ id: "user-123",
+ name: "user@example.com",
+ display_name: "Test User",
+ attestation: :invalid,
+ relying_party: @relying_party
+ )
+ end
+ end
+
test "as_json excludeCredentials omits transports when empty" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",