diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css index bb6db0b37..d57583017 100644 --- a/app/assets/stylesheets/credentials.css +++ b/app/assets/stylesheets/credentials.css @@ -31,4 +31,13 @@ margin-inline-start: auto; opacity: 0; } + + [data-passkey-errors] [data-passkey-error] { + display: none; + } + + [data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"], + [data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] { + display: block; + } } diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb index 689bc146a..15f574ab0 100644 --- a/app/controllers/concerns/current_request.rb +++ b/app/controllers/concerns/current_request.rb @@ -8,9 +8,6 @@ module CurrentRequest Current.user_agent = request.user_agent Current.ip_address = request.ip 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/my/passkey_challenges_controller.rb b/app/controllers/my/passkey_challenges_controller.rb new file mode 100644 index 000000000..12029ef9e --- /dev/null +++ b/app/controllers/my/passkey_challenges_controller.rb @@ -0,0 +1,7 @@ +class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController + include Authentication + include Authorization + + allow_unauthenticated_access + disallow_account_scope +end diff --git a/app/controllers/my/passkeys_controller.rb b/app/controllers/my/passkeys_controller.rb new file mode 100644 index 000000000..40ddaca24 --- /dev/null +++ b/app/controllers/my/passkeys_controller.rb @@ -0,0 +1,34 @@ +class My::PasskeysController < ApplicationController + include ActionPack::Passkey::Request + + before_action :set_passkey, only: %i[ edit update destroy ] + + def index + @passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc) + @creation_options = passkey_creation_options(holder: Current.identity) + end + + def create + passkey = Current.identity.passkeys.register(passkey_creation_params) + + redirect_to edit_my_passkey_path(passkey, created: true) + end + + def edit + end + + def update + @passkey.update!(params.expect(passkey: [ :name ])) + redirect_to my_passkeys_path + end + + def destroy + @passkey.destroy! + redirect_to my_passkeys_path + end + + private + def set_passkey + @passkey = Current.identity.passkeys.find(params[:id]) + end +end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb index 18917583d..4dc087986 100644 --- a/app/controllers/sessions/passkeys_controller.rb +++ b/app/controllers/sessions/passkeys_controller.rb @@ -1,44 +1,27 @@ class Sessions::PasskeysController < ApplicationController + include ActionPack::Passkey::Request + disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded def create - credential = Identity::Credential.authenticate( - passkey: passkey_params, - challenge: session.delete(:webauthn_challenge) - ) - - if credential - authentication_succeeded(credential.identity) - else - authentication_failed - end - end - - private - def passkey_params - params.expect(passkey: [ :id, :client_data_json, :authenticator_data, :signature ]) - end - - def authentication_succeeded(identity) - start_new_session_for identity + if credential = ActionPack::Passkey.authenticate(passkey_request_params) + start_new_session_for credential.holder respond_to do |format| format.html { redirect_to after_authentication_url } format.json { render json: { session_token: session_token } } end - end - - def authentication_failed - alert_message = "That passkey didn't work. Try again." - + else respond_to do |format| - format.html { redirect_to new_session_path, alert: alert_message } - format.json { render json: { message: alert_message }, status: :unauthorized } + format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." } + format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized } end end + end + private def rate_limit_exceeded rate_limit_exceeded_message = "Try again later." diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index eb7fa8309..cfbb778f2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + include ActionPack::Passkey::Request + disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded @@ -6,8 +8,7 @@ class SessionsController < ApplicationController layout "public" def new - @request_options = Identity::Credential.request_options - session[:webauthn_challenge] = @request_options.challenge + @request_options = passkey_request_options end def create @@ -69,5 +70,4 @@ class SessionsController < ApplicationController end end end - end diff --git a/app/controllers/users/credentials_controller.rb b/app/controllers/users/credentials_controller.rb deleted file mode 100644 index aa0f3d8e9..000000000 --- a/app/controllers/users/credentials_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Users::CredentialsController < ApplicationController - before_action :set_credential, only: %i[ edit update destroy ] - - def index - @credentials = Current.identity.credentials.order(name: :asc, created_at: :desc) - @creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name) - session[:webauthn_challenge] = @creation_options.challenge - end - - def create - 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 - - def destroy - @credential.destroy! - redirect_to user_credentials_path(Current.user) - end - - private - def set_credential - @credential = Current.identity.credentials.find(params[:id]) - end - - def credential_params - params.expect(credential: [ :name ]) - end - - def passkey_params - params.expect(passkey: [ :client_data_json, :attestation_object, transports: [] ]) - end -end diff --git a/app/javascript/application.js b/app/javascript/application.js index c43a45eaa..6642e1ab1 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -6,3 +6,4 @@ import "controllers" import "lexxy" import "@rails/actiontext" +import "lib/action_pack/passkey" diff --git a/app/javascript/controllers/credential_controller.js b/app/javascript/controllers/credential_controller.js deleted file mode 100644 index ddc76085d..000000000 --- a/app/javascript/controllers/credential_controller.js +++ /dev/null @@ -1,60 +0,0 @@ -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, registerUrl: String } - static targets = ["button", "error", "cancelled"] - - async create() { - this.buttonTarget.disabled = true - this.errorTarget.hidden = true - this.cancelledTarget.hidden = true - - try { - const publicKey = this.#prepareOptions(this.publicKeyValue) - const credential = await navigator.credentials.create({ publicKey }) - await this.#registerCredential(credential) - } catch (error) { - if (error.name === "AbortError" || error.name === "NotAllowedError") { - this.cancelledTarget.hidden = false - } else { - this.errorTarget.hidden = false - } - this.buttonTarget.disabled = false - } - } - - 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" - }) - - if (response.ok) { - const { location } = await response.json - Turbo.visit(location) - } else { - throw new Error("Registration failed") - } - } - - #prepareOptions(options) { - return { - ...options, - challenge: base64urlToBuffer(options.challenge), - user: { ...options.user, id: base64urlToBuffer(options.user.id) }, - excludeCredentials: (options.excludeCredentials || []).map(cred => ({ - ...cred, - id: base64urlToBuffer(cred.id) - })) - } - } -} diff --git a/app/javascript/controllers/passkey_controller.js b/app/javascript/controllers/passkey_controller.js deleted file mode 100644 index 566518bb9..000000000 --- a/app/javascript/controllers/passkey_controller.js +++ /dev/null @@ -1,80 +0,0 @@ -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 } - - #abortController - - connect() { - 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: this.#prepareOptions(this.publicKeyValue), - mediation: "conditional", - signal: this.#abortController.signal - }) - - this.#submitAssertion(credential) - } catch (error) { - if (error.name !== "AbortError") { - console.error("Passkey error:", error) - } - } - } - - #submitAssertion(credential) { - const form = document.createElement("form") - form.method = "POST" - form.action = this.urlValue - form.style.display = "none" - - const fields = { - authenticity_token: this.csrfTokenValue, - "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)) { - const input = document.createElement("input") - input.type = "hidden" - input.name = name - input.value = value - form.appendChild(input) - } - - document.body.appendChild(form) - form.submit() - } - - #prepareOptions(options) { - const prepared = { - ...options, - challenge: base64urlToBuffer(options.challenge) - } - - if (options.allowCredentials?.length) { - prepared.allowCredentials = options.allowCredentials.map(cred => ({ - ...cred, - id: base64urlToBuffer(cred.id) - })) - } else { - delete prepared.allowCredentials - } - - return prepared - } -} diff --git a/app/javascript/helpers/base64url_helpers.js b/app/javascript/helpers/base64url_helpers.js deleted file mode 100644 index 945826f58..000000000 --- a/app/javascript/helpers/base64url_helpers.js +++ /dev/null @@ -1,12 +0,0 @@ -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/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js new file mode 100644 index 000000000..115a630dc --- /dev/null +++ b/app/javascript/lib/action_pack/passkey.js @@ -0,0 +1,246 @@ +// JS companion for the ActionPack::Passkey Ruby helpers. +// +// Binds click handlers to passkey buttons and manages the WebAuthn ceremony +// lifecycle (challenge refresh, credential creation/authentication, form submission). +// +// Expected data attributes: +// [data-passkey="create"] — triggers the registration ceremony +// [data-passkey="sign_in"] — triggers the authentication ceremony +// [data-passkey-mediation="conditional"] — on a