A passkey lets you sign in using your device's biometrics, PIN, or security key — no email code needed.
+ +diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb new file mode 100644 index 000000000..63c623253 --- /dev/null +++ b/app/controllers/users/credentials_controller.rb @@ -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 diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js new file mode 100644 index 000000000..1b882d05e --- /dev/null +++ b/app/javascript/controllers/credential_controller.js @@ -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(/=+$/, "") + } +} diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js new file mode 100644 index 000000000..a652d0c8f --- /dev/null +++ b/app/javascript/controllers/passkey_controller.js @@ -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) + } + } + } +} diff --git a/app/models/identity.rb b/app/models/identity.rb index 18f4e3163..8e56ce196 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -2,6 +2,7 @@ class Identity < ApplicationRecord include Joinable, Transferable has_many :access_tokens, dependent: :destroy + has_many :credentials, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify diff --git a/app/models/identity/credential.rb b/app/models/identity/credential.rb new file mode 100644 index 000000000..14093c943 --- /dev/null +++ b/app/models/identity/credential.rb @@ -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 diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 4e82c0b70..10293b5a0 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,12 +1,12 @@ <% @page_title = "Enter your email" %> -
Passkeys let you sign in without email codes. They use your device's biometrics, PIN, or security key.
+| Passkey | +Created | ++ |
|---|
Passkeys let you sign in without email codes. They use your device's biometrics, PIN, or security key.
+ <% end %> + + <%= link_to new_user_credential_path(@user), class: "btn btn--link" do %> + <%= icon_tag "add" %> + Add a passkey + <% end %> +A passkey lets you sign in using your device's biometrics, PIN, or security key — no email code needed.
+ +