diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5d46dd..48ced6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- BREAKING!: The WebAuthn options endpoints (`passkey_registration_options`, `passkey_authentication_options`, `security_key_registration_options` and `security_key_authentication_options`) now respond to `POST` instead of `GET`, since they mutate server state (they store the WebAuthn challenge in the session). If you overrode any of these controllers, rename the overridden `index` action to `create`; if you copied the bundled JavaScript into your app, update your copy. [@santiagorodriguez96] + ## [v0.4.0](https://github.com/cedarcode/devise-webauthn/compare/v0.3.1...v0.4.0/) - 2026-04-06 ### Added diff --git a/README.md b/README.md index e688e9ef..3b511698 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Used for registering new credentials (passkeys or security keys). ```html
- + @@ -296,9 +296,11 @@ Used for registering new credentials (passkeys or security keys). - The form's action should point to the appropriate endpoint – you can use the provided url helpers: - For creating passkeys: `passkeys_path(resource_name)` - For creating 2FA security keys: `second_factor_webauthn_credentials_path(resource_name)` -- Requires a `data-options-json` attribute containing JSON-serialized WebAuthn creation options +- Requires a `data-options-url` attribute pointing to the endpoint serving the WebAuthn creation options – you can use the provided url helpers: + - For passkey creation options: `passkey_registration_options_path(resource_name)` + - For 2FA security key creation options: `security_key_registration_options_path(resource_name)` - Must contain a hidden input with `data-webauthn-target="response"` to store the credential response -- Must contain the submit button — the element intercepts form submission, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form +- Must contain the submit button – the element intercepts form submission, fetches the options, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form #### `` @@ -306,7 +308,7 @@ Used for authenticating with existing credentials. ```html - + @@ -318,9 +320,11 @@ Used for authenticating with existing credentials. - The form's action should point to the appropriate endpoint – you can use the provided url helpers: - For passkey sign-in: `session_path(resource_name)` - For 2FA with WebAuthn: `two_factor_authentication_path(resource_name)` -- Requires a `data-options-json` attribute containing JSON-serialized WebAuthn request options +- Requires a `data-options-url` attribute pointing to the endpoint serving the WebAuthn request options – you can use the provided url helpers: + - For passkey authentication options: `passkey_authentication_options_path(resource_name)` + - For 2FA security key authentication options: `security_key_authentication_options_path(resource_name)` - Must contain a hidden input with `data-webauthn-target="response"` to store the credential response -- Must contain the submit button — the element intercepts form submission, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form +- Must contain the submit button – the element intercepts form submission, fetches the options, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form ## Development diff --git a/app/assets/javascript/devise/webauthn.js b/app/assets/javascript/devise/webauthn.js index 428fdf6e..752fce2e 100644 --- a/app/assets/javascript/devise/webauthn.js +++ b/app/assets/javascript/devise/webauthn.js @@ -24,8 +24,8 @@ export class WebauthnCreateElement extends HTMLElement { event.preventDefault(); try { - const response = await fetch(this.getAttribute('data-options-url')); - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(await response.json()); + const options = await fetchOptions(this.getAttribute('data-options-url')); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); const credential = await navigator.credentials.create({ publicKey }); this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential); @@ -109,8 +109,8 @@ export class WebauthnGetElement extends HTMLElement { event.preventDefault(); try { - const response = await fetch(this.getAttribute('data-options-url')); - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(await response.json()); + const options = await fetchOptions(this.getAttribute('data-options-url')); + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); const credential = await navigator.credentials.get({ publicKey }); this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential); @@ -176,6 +176,19 @@ export class WebauthnGetElement extends HTMLElement { } } +async function fetchOptions(url) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Accept': 'application/json' } + }); + + if (!response.ok) { + throw new Error(`Fetching WebAuthn options failed with status ${response.status}`); + } + + return response.json(); +} + function toBase64Url(buffer) { if (!buffer) return null; diff --git a/app/controllers/devise/passkey_authentication_options_controller.rb b/app/controllers/devise/passkey_authentication_options_controller.rb index 1747ff10..1db26e3b 100644 --- a/app/controllers/devise/passkey_authentication_options_controller.rb +++ b/app/controllers/devise/passkey_authentication_options_controller.rb @@ -2,7 +2,9 @@ module Devise class PasskeyAuthenticationOptionsController < DeviseController - def index + skip_forgery_protection + + def create passkey_options = WebAuthn::Credential.options_for_get( user_verification: "required" diff --git a/app/controllers/devise/passkey_registration_options_controller.rb b/app/controllers/devise/passkey_registration_options_controller.rb index f1a7aa7f..b120f734 100644 --- a/app/controllers/devise/passkey_registration_options_controller.rb +++ b/app/controllers/devise/passkey_registration_options_controller.rb @@ -2,9 +2,11 @@ module Devise class PasskeyRegistrationOptionsController < DeviseController + skip_forgery_protection + before_action :authenticate_scope! - def index + def create passkey_options = WebAuthn::Credential.options_for_create( user: { diff --git a/app/controllers/devise/security_key_authentication_options_controller.rb b/app/controllers/devise/security_key_authentication_options_controller.rb index fe24415e..3c1fddfb 100644 --- a/app/controllers/devise/security_key_authentication_options_controller.rb +++ b/app/controllers/devise/security_key_authentication_options_controller.rb @@ -2,9 +2,11 @@ module Devise class SecurityKeyAuthenticationOptionsController < DeviseController + skip_forgery_protection + before_action :set_resource - def index + def create security_key_authentication_options = WebAuthn::Credential.options_for_get( allow: @resource.webauthn_credentials.pluck(:external_id), diff --git a/app/controllers/devise/security_key_registration_options_controller.rb b/app/controllers/devise/security_key_registration_options_controller.rb index 3d13405a..2d5afc34 100644 --- a/app/controllers/devise/security_key_registration_options_controller.rb +++ b/app/controllers/devise/security_key_registration_options_controller.rb @@ -2,9 +2,11 @@ module Devise class SecurityKeyRegistrationOptionsController < DeviseController + skip_forgery_protection + before_action :authenticate_scope! - def index + def create create_security_key_options = WebAuthn::Credential.options_for_create( user: { diff --git a/lib/devise/webauthn/routes.rb b/lib/devise/webauthn/routes.rb index bbfad070..015bb331 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -8,9 +8,9 @@ class Mapper def devise_passkey_authentication(_mapping, controllers) resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] - resources :passkey_authentication_options, only: :index, + resources :passkey_authentication_options, only: :create, controller: controllers[:passkey_authentication_options] - resources :passkey_registration_options, only: :index, controller: controllers[:passkey_registration_options] + resources :passkey_registration_options, only: :create, controller: controllers[:passkey_registration_options] end def devise_two_factor_authentication(_mapping, controllers) @@ -22,9 +22,9 @@ def devise_two_factor_authentication(_mapping, controllers) only: %i[new create update destroy], controller: controllers[:second_factor_webauthn_credentials] - resources :security_key_authentication_options, only: %i[index], + resources :security_key_authentication_options, only: :create, controller: controllers[:security_key_authentication_options] - resources :security_key_registration_options, only: %i[index], + resources :security_key_registration_options, only: :create, controller: controllers[:security_key_registration_options] end end diff --git a/spec/requests/devise/passkey_authentication_options_controller_spec.rb b/spec/requests/devise/passkey_authentication_options_controller_spec.rb index 689d30b8..5ab9551d 100644 --- a/spec/requests/devise/passkey_authentication_options_controller_spec.rb +++ b/spec/requests/devise/passkey_authentication_options_controller_spec.rb @@ -3,9 +3,9 @@ require "spec_helper" RSpec.describe Devise::PasskeyAuthenticationOptionsController, type: :request do - describe "GET #index" do + describe "POST #create" do it "stores the challenge in session and returns it as json" do - get account_passkey_authentication_options_path + post account_passkey_authentication_options_path expect(response).to have_http_status(:ok) @@ -15,10 +15,10 @@ end it "generates a new challenge on each request" do - get account_passkey_authentication_options_path + post account_passkey_authentication_options_path first_challenge = session[:authentication_challenge] - get account_passkey_authentication_options_path + post account_passkey_authentication_options_path second_challenge = session[:authentication_challenge] expect(first_challenge).to be_present diff --git a/spec/requests/devise/passkey_authentication_spec.rb b/spec/requests/devise/passkey_authentication_spec.rb index 4840054e..1fa4bb68 100644 --- a/spec/requests/devise/passkey_authentication_spec.rb +++ b/spec/requests/devise/passkey_authentication_spec.rb @@ -35,7 +35,7 @@ def generate_assertion(fake_client, challenge:, credential:, user_handle: nil) let!(:passkey) { create_passkey_for(user, client) } before do - get account_passkey_authentication_options_path # To set the challenge in session + post account_passkey_authentication_options_path # To set the challenge in session end it "completes authentication with valid credential" do diff --git a/spec/requests/devise/passkey_registration_options_controller_spec.rb b/spec/requests/devise/passkey_registration_options_controller_spec.rb index dc0cf94e..44d9cca8 100644 --- a/spec/requests/devise/passkey_registration_options_controller_spec.rb +++ b/spec/requests/devise/passkey_registration_options_controller_spec.rb @@ -5,10 +5,10 @@ RSpec.describe Devise::PasskeyRegistrationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } - describe "GET #index" do + describe "POST #create" do context "when user is not authenticated" do it "redirects to the sign-in page" do - get account_passkey_registration_options_path + post account_passkey_registration_options_path expect(response).to redirect_to(new_account_session_path) end end @@ -19,7 +19,7 @@ end it "returns webauthn create options as json and stores the challenge in session" do - get account_passkey_registration_options_path + post account_passkey_registration_options_path expect(response).to have_http_status(:ok) @@ -30,10 +30,10 @@ end it "generates a new challenge on each request" do - get account_passkey_registration_options_path + post account_passkey_registration_options_path first_challenge = session[:webauthn_challenge] - get account_passkey_registration_options_path + post account_passkey_registration_options_path second_challenge = session[:webauthn_challenge] expect(first_challenge).to be_present diff --git a/spec/requests/devise/passkeys_controller_spec.rb b/spec/requests/devise/passkeys_controller_spec.rb index bbb6f50f..9711071d 100644 --- a/spec/requests/devise/passkeys_controller_spec.rb +++ b/spec/requests/devise/passkeys_controller_spec.rb @@ -31,7 +31,7 @@ context "when user is authenticated" do before do sign_in user, scope: :account - get account_passkey_registration_options_path # To set the challenge in session + post account_passkey_registration_options_path # To set the challenge in session end context "with valid parameters" do diff --git a/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb index 1ea20354..1bd4790a 100644 --- a/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb +++ b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb @@ -41,7 +41,7 @@ context "when user is authenticated" do before do sign_in user - get account_security_key_registration_options_path # To set the challenge in session + post account_security_key_registration_options_path # To set the challenge in session end context "with valid parameters" do diff --git a/spec/requests/devise/security_key_authentication_options_controller_spec.rb b/spec/requests/devise/security_key_authentication_options_controller_spec.rb index c0420d12..4abda231 100644 --- a/spec/requests/devise/security_key_authentication_options_controller_spec.rb +++ b/spec/requests/devise/security_key_authentication_options_controller_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Devise::SecurityKeyAuthenticationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } - describe "GET #index" do + describe "POST #create" do before do user.passkeys.create!( external_id: "external-id", @@ -23,7 +23,7 @@ end it "returns authentication options and stores the challenge in the session" do - get account_security_key_authentication_options_path + post account_security_key_authentication_options_path expect(response).to have_http_status(:ok) diff --git a/spec/requests/devise/security_key_registration_options_controller_spec.rb b/spec/requests/devise/security_key_registration_options_controller_spec.rb index f1ad88e9..656cc8cc 100644 --- a/spec/requests/devise/security_key_registration_options_controller_spec.rb +++ b/spec/requests/devise/security_key_registration_options_controller_spec.rb @@ -5,10 +5,10 @@ RSpec.describe Devise::SecurityKeyRegistrationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } - describe "GET #index" do + describe "POST #create" do context "when user is not authenticated" do it "redirects to the sign-in page" do - get account_security_key_registration_options_path + post account_security_key_registration_options_path expect(response).to redirect_to(new_account_session_path) end end @@ -19,7 +19,7 @@ end it "stores the challenge in session and returns it as json" do - get account_security_key_registration_options_path + post account_security_key_registration_options_path expect(response).to have_http_status(:ok) @@ -29,10 +29,10 @@ end it "generates a new challenge on each request" do - get account_security_key_registration_options_path + post account_security_key_registration_options_path first_challenge = session[:webauthn_challenge] - get account_security_key_registration_options_path + post account_security_key_registration_options_path second_challenge = session[:webauthn_challenge] expect(first_challenge).to be_present diff --git a/spec/requests/devise/two_factor_authentication_spec.rb b/spec/requests/devise/two_factor_authentication_spec.rb index 51b89892..250bed1a 100644 --- a/spec/requests/devise/two_factor_authentication_spec.rb +++ b/spec/requests/devise/two_factor_authentication_spec.rb @@ -44,7 +44,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to eq(I18n.t("devise.failure.two_factor_required")) - get account_security_key_authentication_options_path # To set the challenge in session + post account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -84,7 +84,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) - get account_security_key_authentication_options_path # To set the challenge in session + post account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -114,7 +114,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) - get account_security_key_authentication_options_path # To set the challenge in session + post account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -145,7 +145,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) - get account_security_key_authentication_options_path # To set the challenge in session + post account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -172,7 +172,7 @@ def generate_assertion(fake_client, challenge:, credential:) follow_redirect! expect(response).to have_http_status(:ok) - get account_security_key_authentication_options_path + post account_security_key_authentication_options_path assertion = client.get( challenge: session[:two_factor_authentication_challenge], @@ -200,7 +200,7 @@ def generate_assertion(fake_client, challenge:, credential:) follow_redirect! expect(response).to have_http_status(:ok) - get account_security_key_authentication_options_path + post account_security_key_authentication_options_path assertion = client.get( challenge: session[:two_factor_authentication_challenge], @@ -227,7 +227,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) - get account_security_key_authentication_options_path # To set the challenge in session + post account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil