Skip to content
Merged
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

### Added

- Add `Devise::Webauthn.challenge_store` to configure where WebAuthn challenges are kept between the options request and the verification request. It takes a symbol such as `:session`, or a store class. The default, `:session`, keeps them in the session as before. [#150](https://github.com/cedarcode/devise-webauthn/pull/150) [@RenzoMinelli]

## [v0.5.0](https://github.com/cedarcode/devise-webauthn/compare/v0.4.0...v0.5.0/) - 2026-07-13

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class PasskeyAuthenticationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection

def create
Expand All @@ -10,8 +12,7 @@ def create
user_verification: "required"
)

# Store challenge in session for later verification
session[:authentication_challenge] = passkey_options.challenge
challenge_store.write(:passkey_authentication, passkey_options.challenge)

render json: passkey_options
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class PasskeyRegistrationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection

before_action :authenticate_scope!
Expand All @@ -21,8 +23,7 @@ def create
}
)

# Store challenge in session for later verification
session[:webauthn_challenge] = passkey_options.challenge
challenge_store.write(:registration, passkey_options.challenge)

render json: passkey_options
end
Expand Down
6 changes: 4 additions & 2 deletions app/controllers/devise/passkeys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class PasskeysController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

before_action :authenticate_scope!

def new; end
Expand All @@ -19,7 +21,7 @@ def create
set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
redirect_to after_update_path
ensure
session.delete(:webauthn_challenge)
challenge_store.consume(:registration)
end

def destroy
Expand All @@ -41,7 +43,7 @@ def authenticate_scope!

def verify_and_save_passkey(passkey_from_params)
passkey_from_params.verify(
session[:webauthn_challenge],
challenge_store.consume(:registration),
user_verification: true
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class SecondFactorWebauthnCredentialsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

before_action :authenticate_scope!

def new; end
Expand All @@ -19,7 +21,7 @@ def create
set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
redirect_to after_create_path
ensure
session.delete(:webauthn_challenge)
challenge_store.consume(:registration)
end

def update
Expand Down Expand Up @@ -51,7 +53,7 @@ def authenticate_scope!

def verify_and_save_security_key(security_key_from_params)
security_key_from_params.verify(
session[:webauthn_challenge]
challenge_store.consume(:registration)
)

resource.second_factor_webauthn_credentials.create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class SecurityKeyAuthenticationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection

before_action :set_resource
Expand All @@ -13,8 +15,7 @@ def create
user_verification: "discouraged"
)

# Store challenge in session for later verification
session[:two_factor_authentication_challenge] = security_key_authentication_options.challenge
challenge_store.write(:two_factor_authentication, security_key_authentication_options.challenge)

render json: security_key_authentication_options
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class SecurityKeyRegistrationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection

before_action :authenticate_scope!
Expand All @@ -21,8 +23,7 @@ def create
}
)

# Store challenge in session for later verification
session[:webauthn_challenge] = create_security_key_options.challenge
challenge_store.write(:registration, create_security_key_options.challenge)

render json: create_security_key_options
end
Expand Down
13 changes: 7 additions & 6 deletions lib/devise/strategies/passkey_authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
module Devise
module Strategies
class PasskeyAuthenticatable < Devise::Strategies::Base
include Devise::Webauthn::ChallengeStoreAccess

def valid?
passkey_param.present? && session[:authentication_challenge].present?
passkey_param.present? && challenge_store.pending?(:passkey_authentication)
end

def authenticate! # rubocop:disable Metrics/AbcSize
expected_challenge = challenge_store.consume(:passkey_authentication)
passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))

return fail!(:passkey_not_found) if passkey_from_params.user_handle.nil?
Expand All @@ -17,14 +20,12 @@ def authenticate! # rubocop:disable Metrics/AbcSize

return fail!(:passkey_not_found) if stored_passkey.blank?

verify_passkeys(passkey_from_params, stored_passkey)
verify_passkeys(passkey_from_params, stored_passkey, expected_challenge)

remember_me(resource)
success!(resource)
rescue WebAuthn::Error
fail!(:passkey_verification_failed)
ensure
session.delete(:authentication_challenge)
end

private
Expand All @@ -33,9 +34,9 @@ def passkey_param
params[:public_key_credential]
end

def verify_passkeys(passkey_from_params, stored_passkey)
def verify_passkeys(passkey_from_params, stored_passkey, expected_challenge)
passkey_from_params.verify(
session[:authentication_challenge],
expected_challenge,
public_key: stored_passkey.public_key,
sign_count: stored_passkey.sign_count,
user_verification: true
Expand Down
13 changes: 7 additions & 6 deletions lib/devise/strategies/webauthn_two_factor_authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
module Devise
module Strategies
class WebauthnTwoFactorAuthenticatable < Devise::Strategies::Base
include Devise::Webauthn::ChallengeStoreAccess

def valid?
credential_param.present? &&
session[:current_authentication_resource_id].present? &&
session[:two_factor_authentication_challenge].present?
challenge_store.pending?(:two_factor_authentication)
end

# rubocop:disable Metrics/AbcSize
def authenticate!
expected_challenge = challenge_store.consume(:two_factor_authentication)
credential_from_params = WebAuthn::Credential.from_get(JSON.parse(credential_param))
resource = resource_class.find_by(id: session[:current_authentication_resource_id])
stored_credential = resource&.webauthn_credentials&.find_by(external_id: credential_from_params.id)
Expand All @@ -20,7 +23,7 @@ def authenticate!
return fail!(:webauthn_credential_verification_failed)
end

verify_credential(credential_from_params, stored_credential)
verify_credential(credential_from_params, stored_credential, expected_challenge)

resource.remember_me = session[:current_authentication_remember_me] if resource.respond_to?(:remember_me=)
success!(resource)
Expand All @@ -29,8 +32,6 @@ def authenticate!
session.delete(:current_authentication_remember_me)
rescue WebAuthn::Error
fail!(:webauthn_credential_verification_failed)
ensure
session.delete(:two_factor_authentication_challenge)
end
# rubocop:enable Metrics/AbcSize

Expand All @@ -40,9 +41,9 @@ def credential_param
params[:public_key_credential]
end

def verify_credential(credential_from_params, stored_credential)
def verify_credential(credential_from_params, stored_credential, expected_challenge)
credential_from_params.verify(
session[:two_factor_authentication_challenge],
expected_challenge,
public_key: stored_credential.public_key,
sign_count: stored_credential.sign_count
)
Expand Down
4 changes: 4 additions & 0 deletions lib/devise/webauthn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
require "webauthn"

require_relative "webauthn/version"
require_relative "webauthn/challenge_stores/session"
require_relative "webauthn/challenge_store_access"
require_relative "webauthn/engine"
require_relative "webauthn/helpers/credentials_helper"
require_relative "webauthn/routes"
require_relative "webauthn/url_helpers"

module Devise
module Webauthn
mattr_accessor :challenge_store, default: :session

module Test
autoload :AuthenticatorHelpers, "devise/webauthn/test/authenticator_helpers"
end
Expand Down
15 changes: 15 additions & 0 deletions lib/devise/webauthn/challenge_store_access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Devise
module Webauthn
module ChallengeStoreAccess
private

def challenge_store
store = Devise::Webauthn.challenge_store
store = Devise::Webauthn::ChallengeStores.const_get(store.to_s.camelize) if store.is_a?(Symbol)
store.new(request)
end
end
end
end
31 changes: 31 additions & 0 deletions lib/devise/webauthn/challenge_stores/session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Devise
module Webauthn
module ChallengeStores
class Session
KEYS = {
passkey_authentication: :authentication_challenge,
two_factor_authentication: :two_factor_authentication_challenge,
registration: :webauthn_challenge
}.freeze

def initialize(request)
@session = request.session
end

def write(purpose, challenge)
@session[KEYS.fetch(purpose)] = challenge
end

def pending?(purpose)
@session[KEYS.fetch(purpose)].present?
end

def consume(purpose)
@session.delete(KEYS.fetch(purpose))
end
end
end
end
end
3 changes: 3 additions & 0 deletions lib/generators/devise/webauthn/install/templates/webauthn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@
#
# config.algorithms << "ES384"
end

# Where challenges are kept between requests: `:session` (default) or a store class.
# Devise::Webauthn.challenge_store = :session
49 changes: 49 additions & 0 deletions spec/devise/webauthn/challenge_store_access_spec.rb

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Isn't this a model spec? Should we move it to our models/ folder?

Now that I think about it tho, shouldn't it be placed in spec/models? Not to be done as part of this PR but just to spark the discussion :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These aren't models, they're plain Ruby classes. Specs here mirror lib/ (spec/devise/webauthn/ ↔ lib/devise/webauthn/, spec/devise/models/ ↔ lib/devise/models/), so I'd leave them. Happy to discuss a different layout in a separate PR.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Devise::Webauthn::ChallengeStoreAccess do
let(:request) { instance_double(ActionDispatch::Request, session: {}) }
let(:host) do
Class.new do
include Devise::Webauthn::ChallengeStoreAccess

attr_reader :request

def initialize(request)
@request = request
end

public :challenge_store
end.new(request)
end

around do |example|
original_store = Devise::Webauthn.challenge_store
example.run
ensure
Devise::Webauthn.challenge_store = original_store
end

it "uses the session store by default" do
expect(host.challenge_store).to be_a(Devise::Webauthn::ChallengeStores::Session)
end

it "resolves a symbol to the matching store" do
Devise::Webauthn.challenge_store = :session

expect(host.challenge_store).to be_a(Devise::Webauthn::ChallengeStores::Session)
end

it "uses a store class as given" do
Devise::Webauthn.challenge_store = MemoryChallengeStore

expect(host.challenge_store).to be_a(MemoryChallengeStore)
end

it "raises on an unknown symbol" do
Devise::Webauthn.challenge_store = :unknown

expect { host.challenge_store }.to raise_error(NameError)
end
end
36 changes: 36 additions & 0 deletions spec/devise/webauthn/challenge_stores/session_spec.rb

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These aren't models, they're plain Ruby classes. Specs here mirror lib/ (spec/devise/webauthn/ ↔ lib/devise/webauthn/, spec/devise/models/ ↔ lib/devise/models/), so I'd leave them. Happy to discuss a different layout in a separate PR.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Devise::Webauthn::ChallengeStores::Session do
let(:session) { {} }
let(:store) { described_class.new(instance_double(ActionDispatch::Request, session: session)) }

it "keeps each purpose under its own session key" do
store.write(:passkey_authentication, "passkey-challenge")
store.write(:two_factor_authentication, "2fa-challenge")
store.write(:registration, "registration-challenge")

expect(session).to eq(
authentication_challenge: "passkey-challenge",
two_factor_authentication_challenge: "2fa-challenge",
webauthn_challenge: "registration-challenge"
)
end

it "reports a challenge as pending until it is consumed" do
store.write(:passkey_authentication, "challenge")

expect(store.pending?(:passkey_authentication)).to be(true)
expect(store.consume(:passkey_authentication)).to eq("challenge")
expect(store.pending?(:passkey_authentication)).to be(false)
end

it "returns nil when consuming a challenge that was never written" do
expect(store.consume(:registration)).to be_nil
end

it "raises on an unknown purpose" do
expect { store.write(:unknown, "challenge") }.to raise_error(KeyError)
end
end
Loading
Loading