-
-
Notifications
You must be signed in to change notification settings - Fork 0
Add request specs for authentication flows #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
santiagorodriguez96
merged 9 commits into
master
from
sr--add-request-specs-for-auth-flows
Jan 19, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
4adb535
fix: scope webauthn credentials to user in 2FA strategy
santiagorodriguez96 5564989
chore: disable rubocop's `Metrics/AbcSize` offense
santiagorodriguez96 7274b2f
test: add request specs for 2FA flow
santiagorodriguez96 3cb424c
test: add request specs for passkey authentication
santiagorodriguez96 2e9f7a9
chore: ignore rubocop `RSpec/ExampleLength` and `RSpec/MultipleExpect…
santiagorodriguez96 c9dda01
chore: remove unnecessary call to `verify`
santiagorodriguez96 d1a25e9
chore: improve readability
santiagorodriguez96 7066bbc
Merge branch 'master' into sr--add-request-specs-for-auth-flows
santiagorodriguez96 715faee
Merge branch 'master' into sr--add-request-specs-for-auth-flows
santiagorodriguez96 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| inherit_from: .rubocop_todo.yml | ||
|
|
||
| plugins: | ||
| - rubocop-rspec | ||
| - rubocop-rails | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # This configuration was generated by | ||
| # `rubocop --auto-gen-config` | ||
| # on 2026-01-02 20:36:46 UTC using RuboCop version 1.79.1. | ||
| # The point is for the user to remove these configuration records | ||
| # one by one as the offenses are removed from the code base. | ||
| # Note that changes in the inspected code, or installation of new | ||
| # versions of RuboCop, may require this file to be generated again. | ||
|
|
||
| # Offense count: 1 | ||
| # Configuration parameters: Max, CountAsOne. | ||
| RSpec/ExampleLength: | ||
| Exclude: | ||
| - 'spec/requests/devise/two_factor_authentication_spec.rb' | ||
|
|
||
| # Offense count: 9 | ||
| # Configuration parameters: Max. | ||
| RSpec/MultipleExpectations: | ||
| Exclude: | ||
| - 'spec/requests/devise/passkey_authentication_spec.rb' | ||
| - 'spec/requests/devise/two_factor_authentication_spec.rb' | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "spec_helper" | ||
| require "webauthn/fake_client" | ||
|
|
||
| RSpec.describe "Passkey authentication flow", type: :request do | ||
| let(:password) { "password123" } | ||
| let(:user) { Account.create!(email: "[email protected]", password: password) } | ||
| let(:origin) { WebAuthn.configuration.allowed_origins.first } | ||
| let(:client) { WebAuthn::FakeClient.new(origin) } | ||
|
|
||
| def create_passkey_for(account, fake_client) | ||
| challenge = WebAuthn.configuration.encoder.encode(SecureRandom.random_bytes(32)) | ||
| raw_credential = fake_client.create(challenge: challenge) | ||
| webauthn_credential = WebAuthn::Credential.from_create(raw_credential) | ||
|
|
||
| account.passkeys.create!( | ||
| external_id: webauthn_credential.id, | ||
| name: "My Passkey", | ||
| public_key: webauthn_credential.public_key, | ||
| sign_count: webauthn_credential.sign_count | ||
| ) | ||
| end | ||
|
|
||
| def generate_assertion(fake_client, challenge:, credential:) | ||
| fake_client.get( | ||
| challenge: challenge, | ||
| allow_credentials: [credential.external_id], | ||
| user_verified: true | ||
| ) | ||
| end | ||
|
|
||
| describe "sign-in with passkeys" do | ||
| let!(:passkey) { create_passkey_for(user, client) } | ||
|
|
||
| it "completes authentication with valid credential" do | ||
| get new_account_session_path | ||
|
|
||
| assertion = generate_assertion( | ||
| client, | ||
| challenge: session[:authentication_challenge], | ||
| credential: passkey | ||
| ) | ||
|
|
||
| expect do | ||
| post account_session_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to redirect_to(root_path) | ||
| expect(flash[:notice]).to eq(I18n.t("devise.sessions.signed_in")) | ||
| expect(controller.current_account).to eq(user) | ||
| expect(session[:authentication_challenge]).to be_nil | ||
| end.to change { passkey.reload.sign_count }.by(1) | ||
| end | ||
|
|
||
| it "rejects sign-in with non-existent credential" do | ||
| get new_account_session_path | ||
|
|
||
| assertion = generate_assertion( | ||
| client, | ||
| challenge: session[:authentication_challenge], | ||
| credential: passkey | ||
| ) | ||
| passkey.destroy! | ||
|
|
||
| post account_session_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Log in") | ||
| expect(flash[:alert]).to eq(I18n.t("devise.failure.passkey_not_found")) | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
|
|
||
| it "fails with invalid challenge" do | ||
| get new_account_session_path | ||
|
|
||
| assertion = client.get( | ||
| challenge: WebAuthn.configuration.encoder.encode("invalid_challenge"), | ||
| allow_credentials: [passkey.external_id] | ||
| ) | ||
|
|
||
| post account_session_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Log in") | ||
| expect(flash[:alert]).to eq(I18n.t("devise.failure.passkey_verification_failed")) | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
|
|
||
| it "fails when credential param is missing" do | ||
| post account_session_path, params: {} | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Log in") | ||
| expect(flash[:alert]).to eq("Invalid Email or password.") # TODO: CHANGE THIS | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "spec_helper" | ||
| require "webauthn/fake_client" | ||
|
|
||
| RSpec.describe "Two-Factor authentication flow", type: :request do | ||
| let(:password) { "password123" } | ||
| let(:user) { Account.create!(email: "[email protected]", password: password) } | ||
| let(:origin) { WebAuthn.configuration.allowed_origins.first } | ||
| let(:client) { WebAuthn::FakeClient.new(origin) } | ||
|
|
||
| def create_security_key_for(account, fake_client) | ||
| creation_options = WebAuthn::Credential.options_for_create( | ||
| user: { id: account.webauthn_id, name: account.email } | ||
| ) | ||
|
|
||
| raw_credential = fake_client.create(challenge: creation_options.challenge) | ||
| webauthn_credential = WebAuthn::Credential.from_create(raw_credential) | ||
|
|
||
| account.second_factor_webauthn_credentials.create!( | ||
| external_id: webauthn_credential.id, | ||
| name: "Test Security Key", | ||
| public_key: webauthn_credential.public_key, | ||
| sign_count: webauthn_credential.sign_count | ||
| ) | ||
| end | ||
|
|
||
| def generate_assertion(fake_client, challenge:, credential:) | ||
| fake_client.get( | ||
| challenge: challenge, | ||
| allow_credentials: [credential.external_id] | ||
| ) | ||
| end | ||
|
|
||
| describe "sign-in with 2FA enabled" do | ||
| let!(:security_key) { create_security_key_for(user, client) } | ||
|
|
||
| it "completes authentication with valid password and valid credential" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).to redirect_to(new_account_two_factor_authentication_path) | ||
|
|
||
| follow_redirect! | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(flash[:notice]).to eq(I18n.t("devise.failure.two_factor_required")) | ||
| expect(session[:current_authentication_resource_id]).to eq(user.id) | ||
| expect(session[:two_factor_authentication_challenge]).not_to be_nil | ||
|
|
||
| assertion = generate_assertion( | ||
| client, | ||
| challenge: session[:two_factor_authentication_challenge], | ||
| credential: security_key | ||
| ) | ||
|
|
||
| expect do | ||
| post account_two_factor_authentication_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to redirect_to(root_path) | ||
| expect(flash[:notice]).to eq(I18n.t("devise.sessions.signed_in")) | ||
| expect(controller.current_account).to eq(user) | ||
| expect(session[:two_factor_authentication_challenge]).to be_nil | ||
| expect(session[:current_authentication_resource_id]).to be_nil | ||
| end.to change { security_key.reload.sign_count }.by(1) | ||
| end | ||
|
|
||
| it "rejects sign-in with invalid password" do | ||
| post account_session_path, params: { account: { email: user.email, password: "wrong password" } } | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(session[:current_authentication_resource_id]).to be_nil | ||
| expect(session[:two_factor_authentication_challenge]).to be_nil | ||
| end | ||
|
|
||
| it "rejects 2FA with non-existent credential" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).to redirect_to(new_account_two_factor_authentication_path) | ||
|
|
||
| follow_redirect! | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) | ||
| expect(session[:current_authentication_resource_id]).to eq(user.id) | ||
| expect(session[:two_factor_authentication_challenge]).not_to be_nil | ||
|
|
||
| assertion = generate_assertion( | ||
| client, | ||
| challenge: session[:two_factor_authentication_challenge], | ||
| credential: security_key | ||
| ) | ||
| security_key.destroy! | ||
|
|
||
| post account_two_factor_authentication_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Use security key") | ||
| expect(flash[:alert]).to eq(I18n.t("devise.failure.webauthn_credential_not_found")) | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
|
|
||
| it "rejects 2FA with invalid challenge" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).to redirect_to(new_account_two_factor_authentication_path) | ||
|
|
||
| follow_redirect! | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) | ||
| expect(session[:current_authentication_resource_id]).to eq(user.id) | ||
| expect(session[:two_factor_authentication_challenge]).not_to be_nil | ||
|
|
||
| assertion = client.get( | ||
| challenge: WebAuthn.configuration.encoder.encode("invalid_challenge"), | ||
| allow_credentials: [security_key.external_id] | ||
| ) | ||
|
|
||
| post account_two_factor_authentication_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Use security key") | ||
| expect(flash[:alert]).to eq(I18n.t("devise.failure.webauthn_credential_verification_failed")) | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
|
|
||
| it "rejects 2FA with credential from different user" do | ||
| other_user = Account.create!(email: "[email protected]", password: password) | ||
| other_credential = create_security_key_for(other_user, client) | ||
|
|
||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).to redirect_to(new_account_two_factor_authentication_path) | ||
|
|
||
| follow_redirect! | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) | ||
| expect(session[:current_authentication_resource_id]).to eq(user.id) | ||
| expect(session[:two_factor_authentication_challenge]).not_to be_nil | ||
|
|
||
| assertion = client.get( | ||
| challenge: session[:two_factor_authentication_challenge], | ||
| allow_credentials: [other_credential.external_id] | ||
| ) | ||
|
|
||
| post account_two_factor_authentication_path, params: { | ||
| public_key_credential: assertion.to_json | ||
| } | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Use security key") | ||
| expect(flash[:alert]).to eq(I18n.t("devise.failure.webauthn_credential_not_found")) | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
|
|
||
| it "re-renders 2FA page when credential param is missing" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).to redirect_to(new_account_two_factor_authentication_path) | ||
|
|
||
| follow_redirect! | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) | ||
| expect(session[:current_authentication_resource_id]).to eq(user.id) | ||
| expect(session[:two_factor_authentication_challenge]).not_to be_nil | ||
|
|
||
| post account_two_factor_authentication_path, params: {} | ||
|
|
||
| expect(response).to have_http_status(:unprocessable_entity) | ||
| expect(response.body).to include("Use security key") | ||
| expect(flash[:alert]).to eq("Invalid Email or password.") # TODO: CHANGE THIS | ||
| expect(controller.current_account).to be_nil | ||
| end | ||
| end | ||
|
|
||
| describe "sign-in with 2FA disabled" do | ||
| it "authenticates user directly with valid password" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).to redirect_to(root_path) | ||
| expect(controller.current_account).to eq(user) | ||
| end | ||
|
|
||
| it "does not redirect to 2FA page" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(response).not_to redirect_to(new_account_two_factor_authentication_path) | ||
| end | ||
|
|
||
| it "does not set 2FA session state" do | ||
| post account_session_path, params: { account: { email: user.email, password: password } } | ||
|
|
||
| expect(session[:current_authentication_resource_id]).to be_nil | ||
| end | ||
| end | ||
|
|
||
| describe "2FA page access control" do | ||
| context "when already authenticated" do | ||
| before { sign_in user } | ||
|
|
||
| it "redirects away from 2FA page" do | ||
| get new_account_two_factor_authentication_path | ||
|
|
||
| expect(response).to redirect_to(root_path) | ||
| end | ||
| end | ||
|
|
||
| context "when sign-in was not initiated" do | ||
| it "redirects to sign-in page with flash message" do | ||
| get new_account_two_factor_authentication_path | ||
|
|
||
| expect(response).to redirect_to(new_account_session_path) | ||
| expect(flash[:alert]).to eq(I18n.t("devise.failure.sign_in_not_initiated")) | ||
| end | ||
| end | ||
| end | ||
| end |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we disable these cops instead of creating a
rubocop_todo?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was planning on disabling them after this PR so that's why I added them to this file