Skip to content

Commit 543be6f

Browse files
RenzoMinelliclaude
andcommitted
feat: support two-factor sign-in for API clients with a signed token
When a password sign-in needs a second factor and the request is not navigational, respond `401` with a `two_factor_token`. The security key options and two-factor endpoints accept it in place of the session. Two-factor sign-in follows `skip_session_storage` (`:params_auth`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent db5749f commit 543be6f

10 files changed

Lines changed: 144 additions & 10 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
- 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]
99
- Accept `public_key_credential` as a JSON object as well as a JSON string. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
1010
- Respond with JSON from the passkey and security key controllers when the request is not navigational, through Devise's responder. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
11+
- Return a `two_factor_token` to API clients when a password sign-in needs a second factor, and accept it instead of the session to finish two-factor sign-in. [#154](https://github.com/cedarcode/devise-webauthn/pull/154) [@RenzoMinelli]
1112

1213
### Changed
1314

1415
- Passkey sign-in no longer stores the user in the session when `skip_session_storage` includes `:params_auth`, the same as password sign-in. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
16+
- Two-factor sign-in with a security key follows the same `skip_session_storage` setting. [#154](https://github.com/cedarcode/devise-webauthn/pull/154) [@RenzoMinelli]
1517
- The WebAuthn options controllers only call `skip_forgery_protection` when it is defined, so they load under an `ActionController::API` parent. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
1618

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

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,17 @@ Send `public_key_credential` as the JSON object your WebAuthn library returns. F
188188
| Add passkey | `POST /users/passkeys` with `name` and `public_key_credential` (authenticated) | `201` with the passkey, or `422` with `errors` |
189189
| Delete passkey | `DELETE /users/passkeys/:id` (authenticated) | `204` |
190190
191+
### Two-factor authentication
192+
193+
When a user with security keys signs in with a password, the response is `401` with a `two_factor_token`. Send that token to `POST /users/security_key_authentication_options` and to `POST /users/two_factor_authentication` together with `public_key_credential`. The token expires after 5 minutes (`Devise::Webauthn::TwoFactorToken.expires_in`).
194+
195+
devise-jwt only issues a token on `POST /users/sign_in` by default. Add the 2FA path:
196+
```ruby
197+
config.jwt do |jwt|
198+
jwt.dispatch_requests = [["POST", %r{^/users/two_factor_authentication$}]]
199+
end
200+
```
201+
191202
## Customization
192203
193204
### Customizing Views

‎app/controllers/devise/security_key_authentication_options_controller.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module Devise
44
class SecurityKeyAuthenticationOptionsController < DeviseController
55
include Devise::Webauthn::ChallengeStoreAccess
6+
include Devise::Webauthn::PendingTwoFactorSignIn
67

78
skip_forgery_protection if respond_to?(:skip_forgery_protection)
89

@@ -23,7 +24,7 @@ def create
2324
private
2425

2526
def set_resource
26-
@resource = resource_class.find(session[:current_authentication_resource_id])
27+
@resource = resource_class.find(pending_two_factor_sign_in(resource_name)&.fetch("id"))
2728
end
2829
end
2930
end

‎app/controllers/devise/two_factor_authentications_controller.rb‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module Devise
44
class TwoFactorAuthenticationsController < DeviseController
5+
include Devise::Webauthn::PendingTwoFactorSignIn
6+
57
prepend_before_action :set_resource, only: :new
68
prepend_before_action :ensure_sign_in_initiated
79
prepend_before_action :require_no_authentication
@@ -25,14 +27,18 @@ def auth_options
2527
private
2628

2729
def ensure_sign_in_initiated
28-
return if session[:current_authentication_resource_id].present?
30+
return if pending_two_factor_sign_in(resource_name).present?
2931

3032
set_flash_message! :alert, :sign_in_not_initiated, scope: :"devise.failure"
31-
redirect_to new_session_path(resource_name)
33+
if is_navigational_format?
34+
redirect_to new_session_path(resource_name)
35+
else
36+
render json: { error: find_message(:sign_in_not_initiated, scope: :"devise.failure") }, status: :unauthorized
37+
end
3238
end
3339

3440
def set_resource
35-
@resource = resource_class.find(session[:current_authentication_resource_id])
41+
@resource = resource_class.find(pending_two_factor_sign_in(resource_name)["id"])
3642
end
3743
end
3844
end

‎lib/devise/strategies/database_authenticatable.rb‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ def authenticate!
1111
hashed = false
1212

1313
if validate(resource){ hashed = true; resource.valid_password?(password) }
14-
if second_factor_enabled?(resource)
14+
if second_factor_enabled?(resource) && navigational_request?
1515
session[:current_authentication_resource_id] = resource.id
1616
session[:current_authentication_remember_me] = remember_me?
1717
request.flash[:notice] = two_factor_required_message
1818
request.commit_flash
1919
redirect!(two_factor_authentication_path, {}, message: two_factor_required_message)
20+
elsif second_factor_enabled?(resource)
21+
custom!(two_factor_required_response(resource))
2022
else
2123
remember_me(resource)
2224
resource.after_database_authentication
@@ -39,6 +41,17 @@ def two_factor_authentication_path
3941
Rails.application.routes.url_helpers.send(:"new_#{scope}_two_factor_authentication_path")
4042
end
4143

44+
def navigational_request?
45+
Devise.navigational_formats.include?(request.format.try(:ref))
46+
end
47+
48+
def two_factor_required_response(resource)
49+
token = Devise::Webauthn::TwoFactorToken.generate(resource, scope: scope, remember_me: remember_me?)
50+
body = { error: two_factor_required_message, two_factor_token: token }.to_json
51+
52+
[401, { "Content-Type" => "application/json" }, [body]]
53+
end
54+
4255
def two_factor_required_message
4356
I18n.t(:"#{scope}.two_factor_required", resource_name: scope, scope: "devise.failure", default: :two_factor_required)
4457
end

‎lib/devise/strategies/webauthn_two_factor_authenticatable.rb‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ module Strategies
55
class WebauthnTwoFactorAuthenticatable < Devise::Strategies::Base
66
include Devise::Webauthn::ChallengeStoreAccess
77
include Devise::Webauthn::PublicKeyCredentialParam
8+
include Devise::Webauthn::PendingTwoFactorSignIn
89

910
def valid?
1011
public_key_credential_param.present? &&
11-
session[:current_authentication_resource_id].present? &&
12+
pending_two_factor_sign_in(scope).present? &&
1213
challenge_store.pending?(:two_factor_authentication)
1314
end
1415

16+
def store?
17+
super && mapping.to.skip_session_storage.exclude?(:params_auth)
18+
end
19+
1520
# rubocop:disable Metrics/AbcSize
1621
def authenticate!
1722
expected_challenge = challenge_store.consume(:two_factor_authentication)
1823
credential_from_params = WebAuthn::Credential.from_get(public_key_credential_param)
19-
resource = resource_class.find_by(id: session[:current_authentication_resource_id])
24+
pending_sign_in = pending_two_factor_sign_in(scope)
25+
resource = resource_class.find_by(id: pending_sign_in["id"])
2026
stored_credential = resource&.webauthn_credentials&.find_by(external_id: credential_from_params.id)
2127

2228
return fail!(:webauthn_credential_not_found) if stored_credential.blank?
@@ -26,11 +32,10 @@ def authenticate!
2632

2733
verify_credential(credential_from_params, stored_credential, expected_challenge)
2834

29-
resource.remember_me = session[:current_authentication_remember_me] if resource.respond_to?(:remember_me=)
35+
resource.remember_me = pending_sign_in["remember_me"] if resource.respond_to?(:remember_me=)
3036
success!(resource)
3137

32-
session.delete(:current_authentication_resource_id)
33-
session.delete(:current_authentication_remember_me)
38+
clear_pending_two_factor_sign_in
3439
rescue WebAuthn::Error
3540
fail!(:webauthn_credential_verification_failed)
3641
end

‎lib/devise/webauthn.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
require_relative "webauthn/challenge_stores/cache"
99
require_relative "webauthn/challenge_store_access"
1010
require_relative "webauthn/public_key_credential_param"
11+
require_relative "webauthn/two_factor_token"
12+
require_relative "webauthn/pending_two_factor_sign_in"
1113
require_relative "webauthn/engine"
1214
require_relative "webauthn/helpers/credentials_helper"
1315
require_relative "webauthn/routes"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
module Webauthn
5+
module PendingTwoFactorSignIn
6+
private
7+
8+
# Browsers keep the pending sign-in in the session; API clients send back the `two_factor_token`.
9+
def pending_two_factor_sign_in(scope)
10+
resource_id = request.session[:current_authentication_resource_id]
11+
if resource_id.present?
12+
{ "id" => resource_id, "remember_me" => request.session[:current_authentication_remember_me] }
13+
else
14+
TwoFactorToken.read(params[:two_factor_token], scope: scope)
15+
end
16+
end
17+
18+
def clear_pending_two_factor_sign_in
19+
request.session.delete(:current_authentication_resource_id)
20+
request.session.delete(:current_authentication_remember_me)
21+
end
22+
end
23+
end
24+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
module Webauthn
5+
class TwoFactorToken
6+
PURPOSE = :devise_webauthn_two_factor
7+
8+
cattr_accessor :expires_in, default: 5.minutes
9+
10+
def self.generate(resource, scope:, remember_me:)
11+
payload = { "id" => resource.id, "scope" => scope.to_s, "remember_me" => remember_me }
12+
verifier.generate(payload, purpose: PURPOSE, expires_in: expires_in)
13+
end
14+
15+
def self.read(token, scope:)
16+
return unless token.is_a?(String)
17+
18+
payload = verifier.verified(token, purpose: PURPOSE)
19+
payload if payload.is_a?(Hash) && payload["scope"] == scope.to_s
20+
end
21+
22+
def self.verifier
23+
Rails.application.message_verifier(PURPOSE)
24+
end
25+
end
26+
end
27+
end

‎spec/requests/devise/api_client_spec.rb‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,47 @@ def request_registration_challenge
128128
expect(user.passkeys).to be_empty
129129
end
130130
end
131+
132+
describe "two-factor sign-in" do
133+
let!(:security_key) { create_credential_for(user, :second_factor) }
134+
135+
def request_two_factor_token
136+
api_post account_session_path, account: { email: user.email, password: password }
137+
response.parsed_body["two_factor_token"]
138+
end
139+
140+
def security_key_assertion(token)
141+
api_post account_security_key_authentication_options_path, two_factor_token: token
142+
client.get(challenge: response.parsed_body["challenge"], allow_credentials: [security_key.external_id])
143+
end
144+
145+
it "responds to the password step with 401 and a two-factor token" do
146+
api_post account_session_path, account: { email: user.email, password: password }
147+
148+
expect(response).to have_http_status(:unauthorized)
149+
expect(response.parsed_body).to include("error" => I18n.t("devise.failure.two_factor_required"),
150+
"two_factor_token" => be_present)
151+
end
152+
153+
it "signs in with the two-factor token and a security key and does not store the user in the session" do
154+
token = request_two_factor_token
155+
assertion = security_key_assertion(token)
156+
157+
api_post account_two_factor_authentication_path, two_factor_token: token, public_key_credential: assertion
158+
159+
expect(response).to have_http_status(:created)
160+
expect(response.parsed_body["email"]).to eq(user.email)
161+
expect(session["warden.user.account.key"]).to be_nil
162+
end
163+
164+
it "rejects a tampered two-factor token" do
165+
token = request_two_factor_token
166+
assertion = security_key_assertion(token)
167+
168+
api_post account_two_factor_authentication_path, two_factor_token: "#{token}x", public_key_credential: assertion
169+
170+
expect(response).to have_http_status(:unauthorized)
171+
expect(response.parsed_body["error"]).to eq(I18n.t("devise.failure.sign_in_not_initiated"))
172+
end
173+
end
131174
end

0 commit comments

Comments
 (0)