Skip to content

Commit 02de220

Browse files
RenzoMinelliclaude
andcommitted
feat: add a signed challenge store that keeps no server state
The server signs each challenge with secret_key_base and checks the signature when the credential returns it in clientDataJSON. API clients need no session and no shared cache. A signed challenge can be reused until it expires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ac0ddc2 commit 02de220

7 files changed

Lines changed: 149 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- 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]
88
- Add the `:cache` challenge store, which keeps challenges in `Rails.cache` so API clients without a session can use passkeys. [#152](https://github.com/cedarcode/devise-webauthn/pull/152) [@RenzoMinelli]
9+
- Add the `:signed` challenge store, which signs each challenge instead of storing it. API clients can use passkeys without a session or a shared cache. [@RenzoMinelli]
910
- Accept `public_key_credential` as a JSON object as well as a JSON string. [#152](https://github.com/cedarcode/devise-webauthn/pull/152) [@RenzoMinelli]
1011

1112
### Changed

‎README.md‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,24 @@ Mobile apps and other API clients can use passkeys without a cookie session, for
137137

138138
### Server setup
139139

140-
1. Keep challenges in a cache shared by all your servers (Redis, Memcached or Solid Cache), not in the session:
141-
```ruby
142-
# config/initializers/devise_webauthn.rb
143-
Devise::Webauthn.challenge_store = :cache
144-
# Optional. Defaults to Rails.cache and 5 minutes.
145-
Devise::Webauthn::ChallengeStores::Cache.cache = Rails.cache
146-
Devise::Webauthn::ChallengeStores::Cache.expires_in = 5.minutes
147-
```
148-
Each challenge can be used once. The options request and the sign-in request can reach different servers or processes, so they must share the cache. `ActiveSupport::Cache::MemoryStore` and `FileStore` are local to one process or host.
140+
1. Keep challenges outside the session. Choose one store:
141+
- `:cache` keeps challenges in a cache shared by all your servers (Redis, Memcached or Solid Cache):
142+
```ruby
143+
# config/initializers/devise_webauthn.rb
144+
Devise::Webauthn.challenge_store = :cache
145+
# Optional. Defaults to Rails.cache and 5 minutes.
146+
Devise::Webauthn::ChallengeStores::Cache.cache = Rails.cache
147+
Devise::Webauthn::ChallengeStores::Cache.expires_in = 5.minutes
148+
```
149+
Each challenge can be used once. The options request and the sign-in request can reach different servers or processes, so they must share the cache. `ActiveSupport::Cache::MemoryStore` and `FileStore` are local to one process or host.
150+
- `:signed` stores nothing. The server signs each challenge with `secret_key_base`, and checks the signature when the credential comes back:
151+
```ruby
152+
# config/initializers/devise_webauthn.rb
153+
Devise::Webauthn.challenge_store = :signed
154+
# Optional. Defaults to 5 minutes.
155+
Devise::Webauthn::ChallengeStores::Signed.expires_in = 5.minutes
156+
```
157+
A signed challenge can be used more than once until it expires, like a challenge in a cookie session. Keep `expires_in` short.
149158
2. Stop Devise from writing the user to the session. Passkey sign-in follows the `:params_auth` setting:
150159
```ruby
151160
# config/initializers/devise.rb

‎lib/devise/webauthn.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require_relative "webauthn/version"
77
require_relative "webauthn/challenge_stores/credential_challenge"
88
require_relative "webauthn/challenge_stores/session"
9+
require_relative "webauthn/challenge_stores/signed"
910
require_relative "webauthn/challenge_stores/cache"
1011
require_relative "webauthn/challenge_store_access"
1112
require_relative "webauthn/public_key_credential_param"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
module Webauthn
5+
module ChallengeStores
6+
class Signed
7+
include CredentialChallenge
8+
9+
cattr_accessor :expires_in, default: 5.minutes
10+
11+
def initialize(request)
12+
@request = request
13+
end
14+
15+
# The client signs the returned challenge, so it comes back in clientDataJSON with no server state.
16+
def write(purpose, challenge)
17+
encoder.encode(verifier.generate(challenge, purpose: purpose, expires_in: expires_in))
18+
end
19+
20+
def pending?(purpose)
21+
consume(purpose).present?
22+
end
23+
24+
def consume(purpose)
25+
challenge = challenge_from_credential
26+
challenge if challenge && verifier.verified(encoder.decode(challenge), purpose: purpose)
27+
rescue ArgumentError, TypeError, EncodingError
28+
nil
29+
end
30+
31+
private
32+
33+
def verifier
34+
Rails.application.message_verifier(:devise_webauthn_challenge)
35+
end
36+
end
37+
end
38+
end
39+
end

‎lib/generators/devise/webauthn/install/templates/webauthn.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,5 @@
3636
# config.algorithms << "ES384"
3737
end
3838

39-
# Where challenges are kept between requests: `:session` (default), `:cache` or a store class.
39+
# Where challenges are kept between requests: `:session` (default), `:cache`, `:signed` or a store class.
4040
# Devise::Webauthn.challenge_store = :session
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "webauthn/fake_client"
5+
6+
RSpec.describe Devise::Webauthn::ChallengeStores::Signed do
7+
include ActiveSupport::Testing::TimeHelpers
8+
9+
let(:client) { WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first) }
10+
let(:issued_challenge) { store_for(nil).write(:passkey_authentication, WebAuthn::Credential.options_for_get.challenge) }
11+
12+
def store_for(credential)
13+
described_class.new(instance_double(ActionDispatch::Request, params: { public_key_credential: credential }))
14+
end
15+
16+
def store_signing(challenge)
17+
client.create(challenge: WebAuthn::Credential.options_for_get.challenge)
18+
store_for(client.get(challenge: challenge))
19+
end
20+
21+
it "accepts the challenge it issued when the credential signs it" do
22+
store = store_signing(issued_challenge)
23+
24+
expect(store.pending?(:passkey_authentication)).to be(true)
25+
expect(store.consume(:passkey_authentication)).to eq(issued_challenge)
26+
end
27+
28+
it "rejects a challenge it did not issue" do
29+
store = store_signing(WebAuthn::Credential.options_for_get.challenge)
30+
31+
expect(store.consume(:passkey_authentication)).to be_nil
32+
end
33+
34+
it "rejects a challenge issued for another purpose" do
35+
store = store_signing(issued_challenge)
36+
37+
expect(store.consume(:two_factor_authentication)).to be_nil
38+
end
39+
40+
it "expires challenges" do
41+
store = store_signing(issued_challenge)
42+
43+
travel_to(described_class.expires_in.from_now + 1.second) do
44+
expect(store.consume(:passkey_authentication)).to be_nil
45+
end
46+
end
47+
48+
it "accepts a challenge more than once until it expires, because it keeps no state" do
49+
store = store_signing(issued_challenge)
50+
51+
expect(store.consume(:passkey_authentication)).to eq(issued_challenge)
52+
expect(store.consume(:passkey_authentication)).to eq(issued_challenge)
53+
end
54+
55+
it "ignores malformed credentials" do
56+
[nil, "{}", {}, { "response" => "x" }, { "response" => { "clientDataJSON" => "%%%" } }].each do |malformed|
57+
expect(store_for(malformed).pending?(:passkey_authentication)).to be(false)
58+
end
59+
end
60+
end

‎spec/requests/devise/api_client_spec.rb‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,33 @@ def request_challenge
8282
expect(response).to have_http_status(:unauthorized)
8383
end
8484
end
85+
86+
describe "passkey sign-in with the signed challenge store" do
87+
let!(:passkey) { create_credential_for(user, :first_factor) }
88+
89+
before { Devise::Webauthn.challenge_store = :signed }
90+
91+
def passkey_assertion(challenge)
92+
client.get(challenge: challenge, allow_credentials: [passkey.external_id], user_verified: true,
93+
user_handle: WebAuthn.configuration.encoder.decode(user.webauthn_id))
94+
end
95+
96+
it "signs in with a challenge the server signed, without keeping any state" do
97+
api_post account_passkey_authentication_options_path
98+
challenge = response.parsed_body["challenge"]
99+
100+
api_post account_session_path, public_key_credential: passkey_assertion(challenge)
101+
102+
expect(response).to have_http_status(:created)
103+
expect(response.parsed_body["email"]).to eq(user.email)
104+
end
105+
106+
it "rejects a challenge the server never signed" do
107+
challenge = WebAuthn.configuration.encoder.encode(SecureRandom.random_bytes(32))
108+
109+
api_post account_session_path, public_key_credential: passkey_assertion(challenge)
110+
111+
expect(response).to have_http_status(:unauthorized)
112+
end
113+
end
85114
end

0 commit comments

Comments
 (0)