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 %> +
    + <%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
    + +

    <%= @page_title %>

    +<% 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 @@

    <%= @page_title %>

    <% 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? %> - - - - - - - - - <%= render partial: "users/credentials/credential", collection: @credentials %> - -
    Passkey
    + <% 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

    + + + + + +
    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 %> -
    - <%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> -
    - -

    <%= @page_title %>

    -<% end %> - -
    - <%= form_with url: user_credentials_path(Current.user), - data: { controller: "credential", credential_public_key_value: @creation_options.as_json }, - html: { class: "flex flex-column gap" } do |form| %> - -
    - - -
    - - - - - - <% end %> - - <%= link_to "Cancel and go back", user_credentials_path(Current.user), hidden: true %> -
    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",