Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
12 changes: 12 additions & 0 deletions lib/webauthn/authenticator_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions lib/webauthn/client_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
4 changes: 4 additions & 0 deletions lib/webauthn/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion lib/webauthn/fake_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions lib/webauthn/relying_party.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading