diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f47b873..31d2d954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add support for crossOrigin/topOrigin verification during credential registration and authentication. [#486](https://github.com/cedarcode/webauthn-ruby/pull/486) [@nicolastemciuc] + ## [v3.4.3] - 2025-10-23 ### Fixed @@ -494,3 +498,4 @@ Note: Both additions should help making it compatible with Chrome for Android 70 [@jdongelmans]: https://github.com/jdongelmans [@petergoldstein]: https://github.com/petergoldstein [@ClearlyClaire]: https://github.com/ClearlyClaire +[@nicolastemciuc]: https://github.com/nicolastemciuc diff --git a/README.md b/README.md index e36783ae..7b5f2132 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,31 @@ WebAuthn.configure do |config| # Multiple origins can be used when needed. Using more than one will imply you MUST configure rp_id explicitely. If you need your credentials to be bound to a single origin but you have more than one tenant, please see [our Advanced Configuration section](https://github.com/cedarcode/webauthn-ruby/blob/master/docs/advanced_configuration.md) instead of adding multiple origins. config.allowed_origins = ["https://auth.example.com"] + # When operating within iframes or embedded contexts, you may need to restrict + # which top-level origins are permitted to host WebAuthn ceremonies. + # + # crossOrigin / topOrigin verification is DISABLED by default: + # config.verify_cross_origin = false + # + # When `verify_cross_origin` is false, any `crossOrigin` / `topOrigin` values reported by the browser + # are ignored. As a result, credentials created or used within a cross-origin iframe will be treated + # as valid. + # + # When `verify_cross_origin` is true, you can either: + # + # (A) Allow only specific top-level origins to embed your ceremony + # (each entry must match the browser-reported `topOrigin` during registration/authentication): + # + # config.allowed_top_origins = ["https://app.example.com"] + # + # (B) Forbid ANY cross-origin iframe usage altogether + # (this rejects creation/authentication whenever `crossOrigin` is true): + # + # config.allowed_top_origins = [] + # + # Note: if `verify_cross_origin` is not enabled, any values set in `allowed_top_origins` + # will be ignored. + # Relying Party name for display purposes config.rp_name = "Example Inc." diff --git a/lib/webauthn/authenticator_response.rb b/lib/webauthn/authenticator_response.rb index be640b16..c31f45d4 100644 --- a/lib/webauthn/authenticator_response.rb +++ b/lib/webauthn/authenticator_response.rb @@ -14,6 +14,7 @@ class ChallengeVerificationError < VerificationError; end class OriginVerificationError < VerificationError; end class RpIdVerificationError < VerificationError; end class TokenBindingVerificationError < VerificationError; end + class TopOriginVerificationError < VerificationError; end class TypeVerificationError < VerificationError; end class UserPresenceVerificationError < VerificationError; end class UserVerifiedVerificationError < VerificationError; end @@ -33,6 +34,7 @@ def verify(expected_challenge, expected_origin = nil, user_presence: nil, user_v verify_item(:token_binding) verify_item(:challenge, expected_challenge) verify_item(:origin, expected_origin) + verify_item(:top_origin) if needs_top_origin_verification? verify_item(:authenticator_data) verify_item( @@ -84,6 +86,12 @@ def valid_token_binding? client_data.valid_token_binding_format? end + def valid_top_origin? + return false unless client_data.cross_origin + + relying_party.allowed_top_origins&.include?(client_data.top_origin) + end + def valid_challenge?(expected_challenge) OpenSSL.secure_compare(client_data.challenge, expected_challenge) end @@ -121,5 +129,9 @@ def rp_id_from_origin(expected_origin) def type raise NotImplementedError, "Please define #type method in subclass" end + + def needs_top_origin_verification? + relying_party.verify_cross_origin && (client_data.cross_origin || client_data.top_origin) + end end end diff --git a/lib/webauthn/client_data.rb b/lib/webauthn/client_data.rb index ee5aefed..ea31ca0a 100644 --- a/lib/webauthn/client_data.rb +++ b/lib/webauthn/client_data.rb @@ -31,6 +31,14 @@ def token_binding data["tokenBinding"] end + def cross_origin + data["crossOrigin"] + end + + def top_origin + data["topOrigin"] + end + def valid_token_binding_format? if token_binding token_binding.is_a?(Hash) && VALID_TOKEN_BINDING_STATUSES.include?(token_binding["status"]) diff --git a/lib/webauthn/configuration.rb b/lib/webauthn/configuration.rb index 522bef8a..589a05bd 100644 --- a/lib/webauthn/configuration.rb +++ b/lib/webauthn/configuration.rb @@ -24,8 +24,12 @@ class Configuration :origin=, :allowed_origins, :allowed_origins=, + :allowed_top_origins, + :allowed_top_origins=, :verify_attestation_statement, :verify_attestation_statement=, + :verify_cross_origin, + :verify_cross_origin=, :credential_options_timeout, :credential_options_timeout=, :silent_authentication, diff --git a/lib/webauthn/fake_client.rb b/lib/webauthn/fake_client.rb index 98ae0d45..027ef704 100644 --- a/lib/webauthn/fake_client.rb +++ b/lib/webauthn/fake_client.rb @@ -10,15 +10,19 @@ module WebAuthn class FakeClient TYPES = { create: "webauthn.create", get: "webauthn.get" }.freeze - attr_reader :origin, :token_binding, :encoding + attr_reader :origin, :cross_origin, :top_origin, :token_binding, :encoding def initialize( origin = fake_origin, + cross_origin: nil, + top_origin: nil, token_binding: nil, authenticator: WebAuthn::FakeAuthenticator.new, encoding: WebAuthn.configuration.encoding ) @origin = origin + @cross_origin = cross_origin + @top_origin = top_origin @token_binding = token_binding @authenticator = authenticator @encoding = encoding @@ -137,6 +141,14 @@ def data_json_for(method, challenge) data[:tokenBinding] = token_binding end + if cross_origin + data[:crossOrigin] = cross_origin + end + + if top_origin + data[:topOrigin] = top_origin + end + data.to_json end diff --git a/lib/webauthn/relying_party.rb b/lib/webauthn/relying_party.rb index 506e0b42..2b6db690 100644 --- a/lib/webauthn/relying_party.rb +++ b/lib/webauthn/relying_party.rb @@ -19,10 +19,12 @@ def initialize( algorithms: DEFAULT_ALGORITHMS.dup, encoding: WebAuthn::Encoder::STANDARD_ENCODING, allowed_origins: nil, + allowed_top_origins: nil, origin: nil, id: nil, name: nil, verify_attestation_statement: true, + verify_cross_origin: false, credential_options_timeout: 120000, silent_authentication: false, acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'], @@ -32,9 +34,11 @@ def initialize( @algorithms = algorithms @encoding = encoding @allowed_origins = allowed_origins + @allowed_top_origins = allowed_top_origins @id = id @name = name @verify_attestation_statement = verify_attestation_statement + @verify_cross_origin = verify_cross_origin @credential_options_timeout = credential_options_timeout @silent_authentication = silent_authentication @acceptable_attestation_types = acceptable_attestation_types @@ -46,9 +50,11 @@ def initialize( attr_accessor :algorithms, :encoding, :allowed_origins, + :allowed_top_origins, :id, :name, :verify_attestation_statement, + :verify_cross_origin, :credential_options_timeout, :silent_authentication, :acceptable_attestation_types, diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 673037f9..9d1c4171 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -63,6 +63,10 @@ def fake_origin "http://localhost" end +def fake_top_origin + "http://localhost.org" +end + def fake_challenge SecureRandom.random_bytes(32) end diff --git a/spec/webauthn/authenticator_assertion_response_spec.rb b/spec/webauthn/authenticator_assertion_response_spec.rb index 165734b7..686668cd 100644 --- a/spec/webauthn/authenticator_assertion_response_spec.rb +++ b/spec/webauthn/authenticator_assertion_response_spec.rb @@ -501,6 +501,620 @@ end end + describe "top_origin validation" do + let(:client) { WebAuthn::FakeClient.new(origin, encoding: false, cross_origin: cross_origin, top_origin: client_top_origin) } + let(:top_origin) { fake_top_origin } + + before do + WebAuthn.configuration.allowed_top_origins = allowed_top_origins + WebAuthn.configuration.verify_cross_origin = verify_cross_origin + end + + context "when verify_cross_origin is false" do + let(:verify_cross_origin) { false } + + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + end + end + + context "when verify_cross_origin is true" do + let(:verify_cross_origin) { true } + + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + assertion_response.valid?( + original_challenge, + public_key: credential_public_key, + sign_count: 0 + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + assertion_response.verify(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + + it "is valid" do + expect( + assertion_response.valid?(original_challenge, public_key: credential_public_key, sign_count: 0) + ).to be_truthy + end + end + end + end + end + end + end + describe "migrated U2F credential" do let(:origin) { "https://example.org" } let(:app_id) { "#{origin}/appid" } diff --git a/spec/webauthn/authenticator_attestation_response_spec.rb b/spec/webauthn/authenticator_attestation_response_spec.rb index 473d18d6..0d382ab4 100644 --- a/spec/webauthn/authenticator_attestation_response_spec.rb +++ b/spec/webauthn/authenticator_attestation_response_spec.rb @@ -643,6 +643,611 @@ end end + describe "top_origin validation" do + let(:client) { WebAuthn::FakeClient.new(origin, encoding: false, cross_origin: cross_origin, top_origin: client_top_origin) } + let(:top_origin) { fake_top_origin } + + before do + WebAuthn.configuration.verify_cross_origin = verify_cross_origin + WebAuthn.configuration.allowed_top_origins = allowed_top_origins + WebAuthn.configuration.allowed_origins = [origin] + end + + context "when verify_cross_origin is false" do + let(:verify_cross_origin) { false } + + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + end + end + + context "when verify_cross_origin is true" do + let(:verify_cross_origin) { true } + + context "when allowed_top_origins is not set" do + let(:allowed_top_origins) { nil } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when allowed_top_origins is set" do + let(:allowed_top_origins) { [top_origin] } + + context "when cross_origin is true" do + let(:cross_origin) { true } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + end + + context "when cross_origin is false" do + let(:cross_origin) { false } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + + context "when cross_origin is not set" do + let(:cross_origin) { nil } + + context "when top_origin is set" do + context "when top_origin matches client top_origin" do + let(:client_top_origin) { top_origin } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin does not match client top_origin" do + let(:client_top_origin) { "https://malicious.example.com" } + + it "is invalid" do + expect( + attestation_response.valid?( + original_challenge, + WebAuthn.configuration.allowed_origins + ) + ).to be_falsy + end + + it "doesn't verify" do + expect { + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + }.to raise_exception(WebAuthn::TopOriginVerificationError) + end + end + + context "when top_origin is not set" do + let(:client_top_origin) { nil } + + it "verifies" do + expect( + attestation_response.verify(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + + it "is valid" do + expect( + attestation_response.valid?(original_challenge, WebAuthn.configuration.allowed_origins) + ).to be_truthy + end + end + end + end + end + end + end + describe "user presence" do context "when UP is not set" do let(:public_key_credential) { client.create(challenge: original_challenge, user_present: false) }