0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/spec/lib/discourse_webauthn/discourse_webauthn_spec.rb
Rafael dos Santos Silva 0c90e25e47
DEV: Split passkey and security key WebAuthn ceremonies for 2FA (#40817)
Passkeys used as 2FA (behind `allow_passkeys_for_2fa`) previously shared
a single WebAuthn ceremony with second-factor security keys on
`/session/2fa`: one merged credential allow-list, posted as the
`security_key` method, with `userVerification: "preferred"`. A single
ceremony cannot both require user verification for passkeys and accept
legacy non-UV security keys, so this splits them:

* New `passkey` value (4) in `UserSecondFactor.methods` carries the
ceremony intent on the wire. No rows ever store it; passkeys live in
`user_security_keys`.
* `DiscourseWebauthn.allowed_credentials` now returns
`allowed_credential_ids` (second-factor keys only, as before the
combined ceremony) plus a separate `passkey_allowed_credential_ids`.
* `authenticate_security_key` only accepts second-factor credentials
again; the new `authenticate_passkey` only accepts first-factor
credentials. A passkey assertion posted to the security key ceremony (or
vice versa) fails with an ownership error.
* The `/session/2fa` page shows distinct "Use passkey" (UV required) and
"Use security key" (UV discouraged) actions instead of one mixed button.
* `passkeys_for_2fa_enabled?` is renamed to
`passkeys_available_as_second_factor?` (old name kept as an alias) and
now ignores disabled passkey rows.

No behavior expansion: passkeys still only satisfy `/session/2fa`.

This is the first of three stacked PRs completing the
`allow_passkeys_for_2fa` rollout so passkeys count as valid 2FA
everywhere, including `enforce_second_factor`. The safe ordering is:
every login/recovery path must be able to *challenge* a passkey before
any path starts *trusting* passkey-only accounts as compliant.
2026-06-17 12:52:47 -03:00

96 lines
3.2 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe DiscourseWebauthn do
fab!(:user)
describe "#origin" do
it "returns the current hostname" do
expect(DiscourseWebauthn.origin).to eq("http://test.localhost")
end
context "with subfolder" do
it "does not append /forum to origin" do
set_subfolder "/forum"
expect(DiscourseWebauthn.origin).to eq("http://test.localhost")
end
end
end
describe ".stage_challenge" do
let(:server_session) { ServerSession.new("some-prefix") }
it "stores the challenge in the provided session object with the right expiry" do
described_class.stage_challenge(user, server_session)
key = described_class.session_challenge_key(user)
expect(server_session[key]).to be_present
expect(server_session.ttl(key)).to be_within_one_second_of(
DiscourseWebauthn::CHALLENGE_EXPIRY,
)
end
end
describe ".clear_challenge" do
let(:server_session) { ServerSession.new("some-prefix") }
it "clears the challenge from the provided session object" do
described_class.stage_challenge(user, server_session)
key = described_class.session_challenge_key(user)
expect(server_session[key]).to be_present
described_class.clear_challenge(user, server_session)
expect(server_session[key]).to be_nil
end
end
describe ".allowed_credentials" do
let(:server_session) { ServerSession.new("some-prefix") }
before do
SiteSetting.allow_passkeys_for_2fa = true
described_class.stage_challenge(user, server_session)
end
it "returns an empty hash when the user has no webauthn credentials" do
expect(described_class.allowed_credentials(user, server_session)).to eq({})
end
it "returns only security key ids for a user with a security key" do
key = Fabricate(:user_security_key_with_random_credential, user: user)
response = described_class.allowed_credentials(user, server_session)
expect(response[:allowed_credential_ids]).to contain_exactly(key.credential_id)
expect(response).not_to have_key(:passkey_allowed_credential_ids)
expect(response[:challenge]).to be_present
end
it "does not include passkeys unless include_passkeys is passed" do
Fabricate(:passkey_with_random_credential, user: user)
expect(described_class.allowed_credentials(user, server_session)).to eq({})
end
it "returns passkey ids separately from security key ids" do
key = Fabricate(:user_security_key_with_random_credential, user: user)
passkey = Fabricate(:passkey_with_random_credential, user: user)
response = described_class.allowed_credentials(user, server_session, include_passkeys: true)
expect(response[:allowed_credential_ids]).to contain_exactly(key.credential_id)
expect(response[:passkey_allowed_credential_ids]).to contain_exactly(passkey.credential_id)
expect(response[:challenge]).to be_present
end
it "does not include passkey ids when allow_passkeys_for_2fa is disabled" do
SiteSetting.allow_passkeys_for_2fa = false
Fabricate(:passkey_with_random_credential, user: user)
expect(
described_class.allowed_credentials(user, server_session, include_passkeys: true),
).to eq({})
end
end
end