Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ Used for registering new credentials (passkeys or security keys).

```html
<form action="/passkeys" method="post">
<webauthn-create data-options-json="<%= create_passkey_options(@user).to_json %>">
<webauthn-create data-options-url="<%= passkey_registration_options_path(:user) %>">
<input type="hidden" name="public_key_credential" data-webauthn-target="response">
<input type="text" name="name" placeholder="Passkey name">
<button type="submit">Create Passkey</button>
Expand All @@ -296,17 +296,19 @@ 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

#### `<webauthn-get>`

Used for authenticating with existing credentials.

```html
<form action="/users/sign_in" method="post">
<webauthn-get data-options-json="<%= passkey_authentication_options.to_json %>">
<webauthn-get data-options-url="<%= passkey_authentication_options_path(:user) %>">
<input type="hidden" name="public_key_credential" data-webauthn-target="response">
<button type="submit">Sign in with Passkey</button>
</webauthn-get>
Expand All @@ -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

Expand Down
21 changes: 17 additions & 4 deletions app/assets/javascript/devise/webauthn.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

module Devise
class PasskeyAuthenticationOptionsController < DeviseController
def index
skip_forgery_protection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to keep it simple in the JS side, as a forged options request can't do any harm: the session write only rotates the pending challenge.


def create
passkey_options =
WebAuthn::Credential.options_for_get(
user_verification: "required"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 4 additions & 4 deletions lib/devise/webauthn/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/devise/passkey_authentication_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/devise/passkeys_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions spec/requests/devise/two_factor_authentication_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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],
Expand Down Expand Up @@ -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],
Expand All @@ -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

Expand Down