Skip to content

Commit 44d47d5

Browse files
test: add request specs for authentication flows (#91)
* test: add request specs for 2FA flow * test: add request specs for passkey authentication
1 parent 1e86fea commit 44d47d5

5 files changed

Lines changed: 351 additions & 56 deletions

File tree

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
inherit_from: .rubocop_todo.yml
2+
13
plugins:
24
- rubocop-rspec
35
- rubocop-rails

.rubocop_todo.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# This configuration was generated by
2+
# `rubocop --auto-gen-config`
3+
# on 2026-01-02 20:36:46 UTC using RuboCop version 1.79.1.
4+
# The point is for the user to remove these configuration records
5+
# one by one as the offenses are removed from the code base.
6+
# Note that changes in the inspected code, or installation of new
7+
# versions of RuboCop, may require this file to be generated again.
8+
9+
# Offense count: 1
10+
# Configuration parameters: Max, CountAsOne.
11+
RSpec/ExampleLength:
12+
Exclude:
13+
- 'spec/requests/devise/two_factor_authentication_spec.rb'
14+
15+
# Offense count: 9
16+
# Configuration parameters: Max.
17+
RSpec/MultipleExpectations:
18+
Exclude:
19+
- 'spec/requests/devise/passkey_authentication_spec.rb'
20+
- 'spec/requests/devise/two_factor_authentication_spec.rb'
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "webauthn/fake_client"
5+
6+
RSpec.describe "Passkey authentication flow", type: :request do
7+
let(:password) { "password123" }
8+
let(:user) { Account.create!(email: "test@example.com", password: password) }
9+
let(:origin) { WebAuthn.configuration.allowed_origins.first }
10+
let(:client) { WebAuthn::FakeClient.new(origin) }
11+
12+
def create_passkey_for(account, fake_client)
13+
challenge = WebAuthn.configuration.encoder.encode(SecureRandom.random_bytes(32))
14+
raw_credential = fake_client.create(challenge: challenge)
15+
webauthn_credential = WebAuthn::Credential.from_create(raw_credential)
16+
17+
account.passkeys.create!(
18+
external_id: webauthn_credential.id,
19+
name: "My Passkey",
20+
public_key: webauthn_credential.public_key,
21+
sign_count: webauthn_credential.sign_count
22+
)
23+
end
24+
25+
def generate_assertion(fake_client, challenge:, credential:)
26+
fake_client.get(
27+
challenge: challenge,
28+
allow_credentials: [credential.external_id],
29+
user_verified: true
30+
)
31+
end
32+
33+
describe "sign-in with passkeys" do
34+
let!(:passkey) { create_passkey_for(user, client) }
35+
36+
it "completes authentication with valid credential" do
37+
get new_account_session_path
38+
39+
assertion = generate_assertion(
40+
client,
41+
challenge: session[:authentication_challenge],
42+
credential: passkey
43+
)
44+
45+
expect do
46+
post account_session_path, params: {
47+
public_key_credential: assertion.to_json
48+
}
49+
50+
expect(response).to redirect_to(root_path)
51+
expect(flash[:notice]).to eq(I18n.t("devise.sessions.signed_in"))
52+
expect(controller.current_account).to eq(user)
53+
expect(session[:authentication_challenge]).to be_nil
54+
end.to change { passkey.reload.sign_count }.by(1)
55+
end
56+
57+
it "rejects sign-in with non-existent credential" do
58+
get new_account_session_path
59+
60+
assertion = generate_assertion(
61+
client,
62+
challenge: session[:authentication_challenge],
63+
credential: passkey
64+
)
65+
passkey.destroy!
66+
67+
post account_session_path, params: {
68+
public_key_credential: assertion.to_json
69+
}
70+
71+
expect(response).to have_http_status(:unprocessable_entity)
72+
expect(response.body).to include("Log in")
73+
expect(flash[:alert]).to eq(I18n.t("devise.failure.passkey_not_found"))
74+
expect(controller.current_account).to be_nil
75+
end
76+
77+
it "fails with invalid challenge" do
78+
get new_account_session_path
79+
80+
assertion = client.get(
81+
challenge: WebAuthn.configuration.encoder.encode("invalid_challenge"),
82+
allow_credentials: [passkey.external_id]
83+
)
84+
85+
post account_session_path, params: {
86+
public_key_credential: assertion.to_json
87+
}
88+
89+
expect(response).to have_http_status(:unprocessable_entity)
90+
expect(response.body).to include("Log in")
91+
expect(flash[:alert]).to eq(I18n.t("devise.failure.passkey_verification_failed"))
92+
expect(controller.current_account).to be_nil
93+
end
94+
95+
it "fails when credential param is missing" do
96+
post account_session_path, params: {}
97+
98+
expect(response).to have_http_status(:unprocessable_entity)
99+
expect(response.body).to include("Log in")
100+
expect(flash[:alert]).to eq("Invalid Email or password.") # TODO: CHANGE THIS
101+
expect(controller.current_account).to be_nil
102+
end
103+
end
104+
end
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "webauthn/fake_client"
5+
6+
RSpec.describe "Two-Factor authentication flow", type: :request do
7+
let(:password) { "password123" }
8+
let(:user) { Account.create!(email: "test@example.com", password: password) }
9+
let(:origin) { WebAuthn.configuration.allowed_origins.first }
10+
let(:client) { WebAuthn::FakeClient.new(origin) }
11+
12+
def create_security_key_for(account, fake_client)
13+
creation_options = WebAuthn::Credential.options_for_create(
14+
user: { id: account.webauthn_id, name: account.email }
15+
)
16+
17+
raw_credential = fake_client.create(challenge: creation_options.challenge)
18+
webauthn_credential = WebAuthn::Credential.from_create(raw_credential)
19+
20+
account.second_factor_webauthn_credentials.create!(
21+
external_id: webauthn_credential.id,
22+
name: "Test Security Key",
23+
public_key: webauthn_credential.public_key,
24+
sign_count: webauthn_credential.sign_count
25+
)
26+
end
27+
28+
def generate_assertion(fake_client, challenge:, credential:)
29+
fake_client.get(
30+
challenge: challenge,
31+
allow_credentials: [credential.external_id]
32+
)
33+
end
34+
35+
describe "sign-in with 2FA enabled" do
36+
let!(:security_key) { create_security_key_for(user, client) }
37+
38+
it "completes authentication with valid password and valid credential" do
39+
post account_session_path, params: { account: { email: user.email, password: password } }
40+
41+
expect(response).to redirect_to(new_account_two_factor_authentication_path)
42+
43+
follow_redirect!
44+
45+
expect(response).to have_http_status(:ok)
46+
expect(flash[:notice]).to eq(I18n.t("devise.failure.two_factor_required"))
47+
expect(session[:current_authentication_resource_id]).to eq(user.id)
48+
expect(session[:two_factor_authentication_challenge]).not_to be_nil
49+
50+
assertion = generate_assertion(
51+
client,
52+
challenge: session[:two_factor_authentication_challenge],
53+
credential: security_key
54+
)
55+
56+
expect do
57+
post account_two_factor_authentication_path, params: {
58+
public_key_credential: assertion.to_json
59+
}
60+
61+
expect(response).to redirect_to(root_path)
62+
expect(flash[:notice]).to eq(I18n.t("devise.sessions.signed_in"))
63+
expect(controller.current_account).to eq(user)
64+
expect(session[:two_factor_authentication_challenge]).to be_nil
65+
expect(session[:current_authentication_resource_id]).to be_nil
66+
end.to change { security_key.reload.sign_count }.by(1)
67+
end
68+
69+
it "rejects sign-in with invalid password" do
70+
post account_session_path, params: { account: { email: user.email, password: "wrong password" } }
71+
72+
expect(response).to have_http_status(:unprocessable_entity)
73+
expect(session[:current_authentication_resource_id]).to be_nil
74+
expect(session[:two_factor_authentication_challenge]).to be_nil
75+
end
76+
77+
it "rejects 2FA with non-existent credential" do
78+
post account_session_path, params: { account: { email: user.email, password: password } }
79+
80+
expect(response).to redirect_to(new_account_two_factor_authentication_path)
81+
82+
follow_redirect!
83+
84+
expect(response).to have_http_status(:ok)
85+
expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required"))
86+
expect(session[:current_authentication_resource_id]).to eq(user.id)
87+
expect(session[:two_factor_authentication_challenge]).not_to be_nil
88+
89+
assertion = generate_assertion(
90+
client,
91+
challenge: session[:two_factor_authentication_challenge],
92+
credential: security_key
93+
)
94+
security_key.destroy!
95+
96+
post account_two_factor_authentication_path, params: {
97+
public_key_credential: assertion.to_json
98+
}
99+
100+
expect(response).to have_http_status(:unprocessable_entity)
101+
expect(response.body).to include("Use security key")
102+
expect(flash[:alert]).to eq(I18n.t("devise.failure.webauthn_credential_not_found"))
103+
expect(controller.current_account).to be_nil
104+
end
105+
106+
it "rejects 2FA with invalid challenge" do
107+
post account_session_path, params: { account: { email: user.email, password: password } }
108+
109+
expect(response).to redirect_to(new_account_two_factor_authentication_path)
110+
111+
follow_redirect!
112+
113+
expect(response).to have_http_status(:ok)
114+
expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required"))
115+
expect(session[:current_authentication_resource_id]).to eq(user.id)
116+
expect(session[:two_factor_authentication_challenge]).not_to be_nil
117+
118+
assertion = client.get(
119+
challenge: WebAuthn.configuration.encoder.encode("invalid_challenge"),
120+
allow_credentials: [security_key.external_id]
121+
)
122+
123+
post account_two_factor_authentication_path, params: {
124+
public_key_credential: assertion.to_json
125+
}
126+
127+
expect(response).to have_http_status(:unprocessable_entity)
128+
expect(response.body).to include("Use security key")
129+
expect(flash[:alert]).to eq(I18n.t("devise.failure.webauthn_credential_verification_failed"))
130+
expect(controller.current_account).to be_nil
131+
end
132+
133+
it "rejects 2FA with credential from different user" do
134+
other_user = Account.create!(email: "other@example.com", password: password)
135+
other_credential = create_security_key_for(other_user, client)
136+
137+
post account_session_path, params: { account: { email: user.email, password: password } }
138+
139+
expect(response).to redirect_to(new_account_two_factor_authentication_path)
140+
141+
follow_redirect!
142+
143+
expect(response).to have_http_status(:ok)
144+
expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required"))
145+
expect(session[:current_authentication_resource_id]).to eq(user.id)
146+
expect(session[:two_factor_authentication_challenge]).not_to be_nil
147+
148+
assertion = client.get(
149+
challenge: session[:two_factor_authentication_challenge],
150+
allow_credentials: [other_credential.external_id]
151+
)
152+
153+
post account_two_factor_authentication_path, params: {
154+
public_key_credential: assertion.to_json
155+
}
156+
157+
expect(response).to have_http_status(:unprocessable_entity)
158+
expect(response.body).to include("Use security key")
159+
expect(flash[:alert]).to eq(I18n.t("devise.failure.webauthn_credential_not_found"))
160+
expect(controller.current_account).to be_nil
161+
end
162+
163+
it "re-renders 2FA page when credential param is missing" do
164+
post account_session_path, params: { account: { email: user.email, password: password } }
165+
166+
expect(response).to redirect_to(new_account_two_factor_authentication_path)
167+
168+
follow_redirect!
169+
170+
expect(response).to have_http_status(:ok)
171+
expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required"))
172+
expect(session[:current_authentication_resource_id]).to eq(user.id)
173+
expect(session[:two_factor_authentication_challenge]).not_to be_nil
174+
175+
post account_two_factor_authentication_path, params: {}
176+
177+
expect(response).to have_http_status(:unprocessable_entity)
178+
expect(response.body).to include("Use security key")
179+
expect(flash[:alert]).to eq("Invalid Email or password.") # TODO: CHANGE THIS
180+
expect(controller.current_account).to be_nil
181+
end
182+
end
183+
184+
describe "sign-in with 2FA disabled" do
185+
it "authenticates user directly with valid password" do
186+
post account_session_path, params: { account: { email: user.email, password: password } }
187+
188+
expect(response).to redirect_to(root_path)
189+
expect(controller.current_account).to eq(user)
190+
end
191+
192+
it "does not redirect to 2FA page" do
193+
post account_session_path, params: { account: { email: user.email, password: password } }
194+
195+
expect(response).not_to redirect_to(new_account_two_factor_authentication_path)
196+
end
197+
198+
it "does not set 2FA session state" do
199+
post account_session_path, params: { account: { email: user.email, password: password } }
200+
201+
expect(session[:current_authentication_resource_id]).to be_nil
202+
end
203+
end
204+
205+
describe "2FA page access control" do
206+
context "when already authenticated" do
207+
before { sign_in user }
208+
209+
it "redirects away from 2FA page" do
210+
get new_account_two_factor_authentication_path
211+
212+
expect(response).to redirect_to(root_path)
213+
end
214+
end
215+
216+
context "when sign-in was not initiated" do
217+
it "redirects to sign-in page with flash message" do
218+
get new_account_two_factor_authentication_path
219+
220+
expect(response).to redirect_to(new_account_session_path)
221+
expect(flash[:alert]).to eq(I18n.t("devise.failure.sign_in_not_initiated"))
222+
end
223+
end
224+
end
225+
end

0 commit comments

Comments
 (0)