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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### 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]
- Add the `:cache` challenge store, which keeps challenges in `Rails.cache` instead of the session. [#152](https://github.com/cedarcode/devise-webauthn/pull/152) [@RenzoMinelli]

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

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ The two factor authentication flow with WebAuthn works as follows:
5. User selects a credential and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
6. The server verifies the response and signs in the user.

## Challenge store

By default, WebAuthn challenges are kept in the session between the options request and the sign-in or registration request. To keep them in a cache instead:

```ruby
# config/initializers/devise_webauthn.rb
Devise::Webauthn.challenge_store = :cache
# Optional. Defaults to Rails.cache and 5 minutes.
Devise::Webauthn::ChallengeStores::Cache.cache = Rails.cache
Devise::Webauthn::ChallengeStores::Cache.expires_in = 5.minutes
Comment on lines +142 to +143

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.

I think I'd like to give more thought to this interface, but we can leave for a follow up PR 🙂

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.

Sounds good, let's do that in a follow-up.

```

Each challenge can be used once. The two requests can reach different servers or processes, so use a cache shared by all your servers (Redis, Memcached or Solid Cache). `ActiveSupport::Cache::MemoryStore` and `FileStore` are local to one process or host.

## Customization

### Customizing Views
Expand Down
1 change: 1 addition & 0 deletions lib/devise/webauthn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

require_relative "webauthn/version"
require_relative "webauthn/challenge_stores/session"
require_relative "webauthn/challenge_stores/cache"
require_relative "webauthn/challenge_store_access"
require_relative "webauthn/engine"
require_relative "webauthn/helpers/credentials_helper"
Expand Down
63 changes: 63 additions & 0 deletions lib/devise/webauthn/challenge_stores/cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

module Devise
module Webauthn
module ChallengeStores
class Cache
cattr_accessor :cache
cattr_accessor :expires_in, default: 5.minutes

def initialize(request)
@request = request
end

def write(purpose, challenge)
store.write(key(purpose, challenge), true, expires_in: expires_in)
end

def pending?(purpose)
challenge.present? && store.exist?(key(purpose, challenge))
end

# `exist?` honours expiry, which MemoryStore#delete does not. `delete` decides which concurrent request wins.
def consume(purpose)
return if challenge.blank?
return unless store.exist?(key(purpose, challenge))
return unless store.delete(key(purpose, challenge))

challenge
end

private

def store
cache || Rails.cache
end

def key(purpose, challenge)
"devise_webauthn:challenge:#{purpose}:#{Digest::SHA256.hexdigest(challenge)}"
end

def challenge
return @challenge if defined?(@challenge)

@challenge = begin
client_data_json = credential.dig("response", "clientDataJSON") if credential.is_a?(Hash)
client_data = WebAuthn::ClientData.new(encoder.decode(client_data_json)) if client_data_json.is_a?(String)
encoder.encode(client_data.challenge) if client_data
rescue JSON::ParserError, ArgumentError, TypeError, EncodingError, NoMethodError
nil
end
end

def credential
@credential ||= JSON.parse(@request.params[:public_key_credential])
end
Comment thread
santiagorodriguez96 marked this conversation as resolved.

def encoder
WebAuthn.configuration.encoder
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@
# config.algorithms << "ES384"
end

# Where challenges are kept between requests: `:session` (default) or a store class.
# Where challenges are kept between requests: `:session` (default), `:cache` or a store class.
# Devise::Webauthn.challenge_store = :session
76 changes: 76 additions & 0 deletions spec/devise/webauthn/challenge_stores/cache_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

require "spec_helper"
require "webauthn/fake_client"

RSpec.describe Devise::Webauthn::ChallengeStores::Cache do
include ActiveSupport::Testing::TimeHelpers

let(:store) { described_class.new(instance_double(ActionDispatch::Request, params: { public_key_credential: credential })) }
let(:client) { WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first) }
let(:challenge) { WebAuthn::Credential.options_for_get.challenge }
let(:credential) do
client.create(challenge: WebAuthn::Credential.options_for_get.challenge)
client.get(challenge: challenge).to_json
end

around do |example|
original_cache = described_class.cache
described_class.cache = ActiveSupport::Cache::MemoryStore.new
example.run
ensure
described_class.cache = original_cache
end

# rubocop:disable RSpec/MultipleExpectations
it "finds the challenge through the credential's client data and consumes it once" 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)
expect(store.consume(:passkey_authentication)).to be_nil
end
Comment thread
santiagorodriguez96 marked this conversation as resolved.
# rubocop:enable RSpec/MultipleExpectations

it "keys the cache entry by a digest of the challenge" do
allow(described_class.cache).to receive(:write).and_call_original

store.write(:passkey_authentication, challenge)

expect(described_class.cache).to have_received(:write)
.with("devise_webauthn:challenge:passkey_authentication:#{Digest::SHA256.hexdigest(challenge)}", true,
expires_in: described_class.expires_in)
end

it "keeps challenges for different purposes apart" do
store.write(:two_factor_authentication, challenge)

expect(store.pending?(:passkey_authentication)).to be(false)
end

it "expires challenges" do
store.write(:passkey_authentication, challenge)

travel_to(described_class.expires_in.from_now + 1.second) do
expect(store.pending?(:passkey_authentication)).to be(false)
expect(store.consume(:passkey_authentication)).to be_nil
Comment thread
santiagorodriguez96 marked this conversation as resolved.
end
end

context "when credential is malformed" do
let(:credential) { "{}" }

it "ignores it" do
expect(store.pending?(:passkey_authentication)).to be(false)
end
end

context "when the client data has no challenge" do
let(:credential) { { response: { clientDataJSON: WebAuthn.configuration.encoder.encode("{}") } }.to_json }

it "ignores it" do
expect(store.pending?(:passkey_authentication)).to be(false)
end
end
end
Loading