Implement Passkey registration
- Create Identity credentials - Add management UI and registration flow
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
class Users::CredentialsController < ApplicationController
|
||||||
|
before_action :set_user
|
||||||
|
before_action :set_webauthn_host
|
||||||
|
|
||||||
|
def index
|
||||||
|
@credentials = identity.credentials.order(created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@creation_options = creation_options
|
||||||
|
session[:webauthn_challenge] = @creation_options.challenge
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
|
||||||
|
client_data_json: decode64(credential_response[:client_data_json]),
|
||||||
|
attestation_object: decode64(credential_response[:attestation_object]),
|
||||||
|
challenge: session.delete(:webauthn_challenge),
|
||||||
|
origin: request.base_url,
|
||||||
|
transports: Array(credential_response[:transports])
|
||||||
|
)
|
||||||
|
|
||||||
|
identity.credentials.create!(
|
||||||
|
name: params.dig(:credential, :name),
|
||||||
|
credential_id: public_key_credential.id,
|
||||||
|
public_key: public_key_credential.public_key.to_der,
|
||||||
|
sign_count: public_key_credential.sign_count,
|
||||||
|
transports: public_key_credential.transports
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_to user_credentials_path(@user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
identity.credentials.find(params[:id]).destroy!
|
||||||
|
redirect_to user_credentials_path(@user)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_user
|
||||||
|
@user = Current.identity.users.find(params[:user_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_webauthn_host
|
||||||
|
ActionPack::WebAuthn::Current.host = request.host
|
||||||
|
end
|
||||||
|
|
||||||
|
def identity
|
||||||
|
@user.identity
|
||||||
|
end
|
||||||
|
|
||||||
|
def creation_options
|
||||||
|
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.email_address,
|
||||||
|
display_name: @user.name,
|
||||||
|
resident_key: :required,
|
||||||
|
exclude_credentials: identity.credentials.map { |c| ExcludeCredential.new(c.credential_id, c.transports) }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def credential_response
|
||||||
|
params.expect(credential: { response: [ :client_data_json, :attestation_object, transports: [] ] })[:response]
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode64(value)
|
||||||
|
Base64.urlsafe_decode64(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
ExcludeCredential = Struct.new(:id, :transports)
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { publicKey: Object }
|
||||||
|
static targets = ["clientDataJSON", "attestationObject"]
|
||||||
|
|
||||||
|
async create(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publicKey = this.prepareOptions(this.publicKeyValue)
|
||||||
|
const credential = await navigator.credentials.create({ publicKey })
|
||||||
|
this.submitCredential(credential)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError" && error.name !== "NotAllowedError") {
|
||||||
|
console.error("Registration failed:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCredential(credential) {
|
||||||
|
this.clientDataJSONTarget.value = this.bufferToBase64url(credential.response.clientDataJSON)
|
||||||
|
this.attestationObjectTarget.value = this.bufferToBase64url(credential.response.attestationObject)
|
||||||
|
|
||||||
|
for (const transport of credential.response.getTransports?.() || []) {
|
||||||
|
const input = document.createElement("input")
|
||||||
|
input.type = "hidden"
|
||||||
|
input.name = "credential[response][transports][]"
|
||||||
|
input.value = transport
|
||||||
|
this.element.appendChild(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element.requestSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareOptions(options) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
challenge: this.base64urlToBuffer(options.challenge),
|
||||||
|
user: { ...options.user, id: this.base64urlToBuffer(options.user.id) },
|
||||||
|
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: this.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(/=+$/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
if (window.PublicKeyCredential?.isConditionalMediationAvailable) {
|
||||||
|
this.attemptConditionalMediation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.abortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async attemptConditionalMediation() {
|
||||||
|
if (!await PublicKeyCredential.isConditionalMediationAvailable()) return
|
||||||
|
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
|
rpId: window.location.hostname
|
||||||
|
},
|
||||||
|
mediation: "conditional",
|
||||||
|
signal: this.abortController.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Passkey selected:", credential)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
console.error("Passkey error:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ class Identity < ApplicationRecord
|
|||||||
include Joinable, Transferable
|
include Joinable, Transferable
|
||||||
|
|
||||||
has_many :access_tokens, dependent: :destroy
|
has_many :access_tokens, dependent: :destroy
|
||||||
|
has_many :credentials, dependent: :destroy
|
||||||
has_many :magic_links, dependent: :destroy
|
has_many :magic_links, dependent: :destroy
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :users, dependent: :nullify
|
has_many :users, dependent: :nullify
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
class Identity::Credential < ApplicationRecord
|
||||||
|
belongs_to :identity
|
||||||
|
|
||||||
|
serialize :transports, coder: JSON, type: Array, default: []
|
||||||
|
|
||||||
|
def to_public_key_credential
|
||||||
|
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||||
|
id: credential_id,
|
||||||
|
public_key: public_key,
|
||||||
|
transports: transports
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<% @page_title = "Enter your email" %>
|
<% @page_title = "Enter your email" %>
|
||||||
|
|
||||||
<div class="panel panel--centered flex flex-column gap-half">
|
<div class="panel panel--centered flex flex-column gap-half" data-controller="passkey">
|
||||||
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
||||||
|
|
||||||
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
||||||
<div class="flex align-center gap">
|
<div class="flex align-center gap">
|
||||||
<label class="flex align-center gap input input--actor">
|
<label class="flex align-center gap input input--actor">
|
||||||
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address…" %>
|
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username webauthn", placeholder: "Enter your email address…" %>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<tr style="view-transition-name: <%= dom_id(credential) %>">
|
||||||
|
<td><strong><%= credential.name.presence || "Passkey" %></strong></td>
|
||||||
|
<td><%= local_datetime_tag credential.created_at, style: :datetime %></td>
|
||||||
|
<td>
|
||||||
|
<%= button_to user_credential_path(@user, credential), method: :delete,
|
||||||
|
class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent",
|
||||||
|
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
||||||
|
<%= icon_tag "trash" %>
|
||||||
|
<span class="for-screen-reader">Remove this passkey</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<% @page_title = "Passkeys" %>
|
||||||
|
|
||||||
|
<% content_for :header do %>
|
||||||
|
<div class="header__actions header__actions--start">
|
||||||
|
<%= back_link_to "My profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<section class="panel panel--wide shadow center">
|
||||||
|
<% if @credentials.any? %>
|
||||||
|
<p class="margin-none-block-start">Passkeys let you sign in without email codes. They use your device's biometrics, PIN, or security key.</p>
|
||||||
|
<table class="access_tokens_table margin-block-end-double max-width txt-small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Passkey</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= render partial: "users/credentials/credential", collection: @credentials %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% else %>
|
||||||
|
<p class="margin-none-block-start">Passkeys let you sign in without email codes. They use your device's biometrics, PIN, or security key.</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to new_user_credential_path(@user), class: "btn btn--link" do %>
|
||||||
|
<%= icon_tag "add" %>
|
||||||
|
<span>Add a passkey</span>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<% @page_title = "Add a passkey" %>
|
||||||
|
|
||||||
|
<% content_for :header do %>
|
||||||
|
<div class="header__actions header__actions--start">
|
||||||
|
<%= back_link_to "Passkeys", user_credentials_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<article class="panel panel--wide shadow center txt-align-start">
|
||||||
|
<%= form_with url: user_credentials_path(@user),
|
||||||
|
data: { controller: "credential", credential_public_key_value: @creation_options.as_json },
|
||||||
|
html: { class: "flex flex-column gap" } do |form| %>
|
||||||
|
<p>A passkey lets you sign in using your device's biometrics, PIN, or security key — no email code needed.</p>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-half">
|
||||||
|
<strong><label for="credential_name">Passkey name</label></strong>
|
||||||
|
<input type="text" id="credential_name" name="credential[name]" required autofocus class="input" placeholder="e.g. MacBook Pro, iPhone">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="credential[response][client_data_json]" data-credential-target="clientDataJSON">
|
||||||
|
<input type="hidden" name="credential[response][attestation_object]" data-credential-target="attestationObject">
|
||||||
|
|
||||||
|
<button type="button" data-action="credential#create" class="btn btn--link center txt-medium">
|
||||||
|
<%= icon_tag "add" %>
|
||||||
|
<span>Register passkey</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to "Cancel and go back", user_credentials_path(@user), hidden: true %>
|
||||||
|
</article>
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
<div class="settings__panel panel shadow txt-align-center">
|
<div class="settings__panel panel shadow txt-align-center">
|
||||||
<div class="flex flex-column gap position-relative">
|
<div class="flex flex-column gap position-relative">
|
||||||
<% if Current.user == @user %>
|
<% 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 %>
|
||||||
|
🔑
|
||||||
|
<span class="for-screen-reader">Manage passkeys</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= link_to edit_user_path(@user), class: "user-edit-link btn", data: { controller: "tooltip" } do %>
|
<%= link_to edit_user_path(@user), class: "user-edit-link btn", data: { controller: "tooltip" } do %>
|
||||||
<%= icon_tag "pencil" %>
|
<%= icon_tag "pencil" %>
|
||||||
<span class="for-screen-reader">Edit profile</span>
|
<span class="for-screen-reader">Edit profile</span>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Rails.application.routes.draw do
|
|||||||
resource :avatar
|
resource :avatar
|
||||||
resource :role
|
resource :role
|
||||||
resource :events
|
resource :events
|
||||||
|
resources :credentials, only: [ :index, :new, :create, :destroy ]
|
||||||
|
|
||||||
resources :push_subscriptions
|
resources :push_subscriptions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class CreateIdentityCredentials < ActiveRecord::Migration[8.2]
|
||||||
|
def change
|
||||||
|
create_table :identity_credentials, id: :uuid do |t|
|
||||||
|
t.uuid :identity_id, null: false
|
||||||
|
t.string :credential_id, null: false
|
||||||
|
t.binary :public_key, null: false
|
||||||
|
t.integer :sign_count, null: false, default: 0
|
||||||
|
t.string :name
|
||||||
|
t.text :transports
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
t.index :identity_id
|
||||||
|
t.index :credential_id, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+13
@@ -344,6 +344,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
|
|||||||
t.index ["identity_id"], name: "index_access_token_on_identity_id"
|
t.index ["identity_id"], name: "index_access_token_on_identity_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "identity_credentials", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "credential_id", null: false
|
||||||
|
t.uuid "identity_id", null: false
|
||||||
|
t.string "name"
|
||||||
|
t.binary "public_key", null: false
|
||||||
|
t.integer "sign_count", default: 0, null: false
|
||||||
|
t.text "transports"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["credential_id"], name: "index_identity_credentials_on_credential_id", unique: true
|
||||||
|
t.index ["identity_id"], name: "index_identity_credentials_on_identity_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "code", null: false
|
t.string "code", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
class ActionPack::WebAuthn::PublicKeyCredential
|
class ActionPack::WebAuthn::PublicKeyCredential
|
||||||
attr_reader :id, :public_key, :sign_count, :owner
|
attr_reader :id, :public_key, :sign_count, :transports, :owner
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def create(client_data_json:, attestation_object:, challenge:, origin:, owner: nil)
|
def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil)
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: client_data_json,
|
client_data_json: client_data_json,
|
||||||
attestation_object: attestation_object
|
attestation_object: attestation_object
|
||||||
@@ -14,15 +14,17 @@ class ActionPack::WebAuthn::PublicKeyCredential
|
|||||||
id: response.attestation.credential_id,
|
id: response.attestation.credential_id,
|
||||||
public_key: response.attestation.public_key,
|
public_key: response.attestation.public_key,
|
||||||
sign_count: response.attestation.sign_count,
|
sign_count: response.attestation.sign_count,
|
||||||
|
transports: transports,
|
||||||
owner: owner
|
owner: owner
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(id:, public_key:, sign_count:, owner: nil)
|
def initialize(id:, public_key:, sign_count:, transports: [], owner: nil)
|
||||||
@id = id
|
@id = id
|
||||||
@public_key = public_key
|
@public_key = public_key
|
||||||
@sign_count = sign_count
|
@sign_count = sign_count
|
||||||
|
@transports = transports
|
||||||
@owner = owner
|
@owner = owner
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,9 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "as_json includes excludeCredentials" do
|
test "as_json includes excludeCredentials" do
|
||||||
credential = Struct.new(:id, :transports)
|
|
||||||
credentials = [
|
credentials = [
|
||||||
credential.new("cred-1", [ "usb", "nfc" ]),
|
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
|
||||||
credential.new("cred-2", [ "internal" ])
|
build_credential(id: "cred-2", transports: [ "internal" ])
|
||||||
]
|
]
|
||||||
|
|
||||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||||
@@ -84,13 +83,11 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "as_json excludeCredentials omits transports when empty" do
|
test "as_json excludeCredentials omits transports when empty" do
|
||||||
credential = Struct.new(:id, :transports).new("cred-1", [])
|
|
||||||
|
|
||||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
name: "user@example.com",
|
name: "user@example.com",
|
||||||
display_name: "Test User",
|
display_name: "Test User",
|
||||||
exclude_credentials: [ credential ],
|
exclude_credentials: [ build_credential(id: "cred-1") ],
|
||||||
relying_party: @relying_party
|
relying_party: @relying_party
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,4 +95,14 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
{ type: "public-key", id: "cred-1" }
|
{ type: "public-key", id: "cred-1" }
|
||||||
], options.as_json[:excludeCredentials]
|
], options.as_json[:excludeCredentials]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def build_credential(id:, transports: [])
|
||||||
|
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||||
|
id: id,
|
||||||
|
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
|
||||||
|
sign_count: 0,
|
||||||
|
transports: transports
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ require "test_helper"
|
|||||||
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
|
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||||
credential = Struct.new(:id, :transports)
|
|
||||||
@credentials = [
|
@credentials = [
|
||||||
credential.new("credential-1", []),
|
build_credential(id: "credential-1"),
|
||||||
credential.new("credential-2", [])
|
build_credential(id: "credential-2")
|
||||||
]
|
]
|
||||||
@options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
@options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||||
credentials: @credentials,
|
credentials: @credentials,
|
||||||
@@ -39,10 +38,9 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "as_json includes transports when present" do
|
test "as_json includes transports when present" do
|
||||||
credential = Struct.new(:id, :transports)
|
|
||||||
credentials = [
|
credentials = [
|
||||||
credential.new("cred-1", [ "usb", "nfc" ]),
|
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
|
||||||
credential.new("cred-2", [ "internal" ])
|
build_credential(id: "cred-2", transports: [ "internal" ])
|
||||||
]
|
]
|
||||||
|
|
||||||
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||||
@@ -57,8 +55,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "as_json omits transports when empty" do
|
test "as_json omits transports when empty" do
|
||||||
credential = Struct.new(:id, :transports)
|
credentials = [ build_credential(id: "cred-1") ]
|
||||||
credentials = [ credential.new("cred-1", []) ]
|
|
||||||
|
|
||||||
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||||
credentials: credentials,
|
credentials: credentials,
|
||||||
@@ -69,4 +66,14 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
|
|||||||
{ type: "public-key", id: "cred-1" }
|
{ type: "public-key", id: "cred-1" }
|
||||||
], options.as_json[:allowCredentials]
|
], options.as_json[:allowCredentials]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def build_credential(id:, transports: [])
|
||||||
|
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||||
|
id: id,
|
||||||
|
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
|
||||||
|
sign_count: 0,
|
||||||
|
transports: transports
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user