Skip to content
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ 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.
#
# Each entry in this list must match the `topOrigin` reported by the browser
# during registration and authentication.
#
# config.allowed_top_origins = ["https://app.example.com"]

# 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?
client_data.cross_origin || client_data.top_origin
end
end
end
11 changes: 11 additions & 0 deletions lib/webauthn/client_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ def token_binding
data["tokenBinding"]
end

def cross_origin
case data["crossOrigin"]
when "true" then true
when "false" then false
end
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
2 changes: 2 additions & 0 deletions lib/webauthn/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Configuration
:origin=,
:allowed_origins,
:allowed_origins=,
:allowed_top_origins,
:allowed_top_origins=,
:verify_attestation_statement,
:verify_attestation_statement=,
:credential_options_timeout,
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
3 changes: 3 additions & 0 deletions lib/webauthn/relying_party.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def initialize(
algorithms: DEFAULT_ALGORITHMS.dup,
encoding: WebAuthn::Encoder::STANDARD_ENCODING,
allowed_origins: nil,
allowed_top_origins: nil,
origin: nil,
id: nil,
name: nil,
Expand All @@ -32,6 +33,7 @@ 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
Expand All @@ -46,6 +48,7 @@ def initialize(
attr_accessor :algorithms,
:encoding,
:allowed_origins,
:allowed_top_origins,
:id,
:name,
:verify_attestation_statement,
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
195 changes: 195 additions & 0 deletions spec/webauthn/authenticator_assertion_response_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,201 @@
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 = [top_origin]
end

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

describe "migrated U2F credential" do
let(:origin) { "https://example.org" }
let(:app_id) { "#{origin}/appid" }
Expand Down
Loading
Loading