Module: Reputable::Verify

Defined in:
lib/reputable/verify.rb

Overview

Bot Signals — verify the signed ticket the widget (or the check endpoint) hands to your form. A ticket is a short-lived signed JWT carrying the visitor's signal report (network evidence, score, and whether a CAPTCHA was solved). Verify it offline against the published JWKS (no network call), with the hosted introspection endpoint as a fallback for environments that cannot fetch/cache the JWKS.

What you do with the report is up to you: the "outcome" field is an advisory assessment derived from YOUR key's thresholds.

Examples:

Require a ticket on login

report = Reputable.verify_ticket(params["reputable-ticket"], customer_id: "cus_123")
if report && report["outcome"] == "allow"
  # proceed; report["challengePassed"] tells you if a CAPTCHA was solved
else
  # reject / re-challenge — your call
end

Class Method Summary collapse

Class Method Details

.blank?(value) ⇒ Boolean

Returns:

  • (Boolean)


116
117
118
# File 'lib/reputable/verify.rb', line 116

def blank?(value)
  value.nil? || (value.respond_to?(:empty?) && value.empty?)
end

.challenge_passed?(ticket, **kwargs) ⇒ Boolean

Convenience predicate: did this ticket clear a CAPTCHA challenge?

Returns:

  • (Boolean)


79
80
81
82
83
84
# File 'lib/reputable/verify.rb', line 79

def challenge_passed?(ticket, **kwargs)
  report = verify_ticket(ticket, **kwargs)
  return false unless report

  !!(report["challengePassed"] || report[:challengePassed])
end

.claim_matches?(claims, key, expected, alternate: nil) ⇒ Boolean

Returns:

  • (Boolean)


120
121
122
123
124
125
# File 'lib/reputable/verify.rb', line 120

def claim_matches?(claims, key, expected, alternate: nil)
  return true unless present?(expected)

  values = [claims[key], alternate && claims[alternate]].compact
  values.any? && values.all? { |value| value == expected }
end

.introspect(ticket, customer_id: nil, session_ref: nil) ⇒ Object

Introspection fallback: POST the ticket to the hosted service, which verifies it server-side and returns the report. Always treat a non-valid response as a rejection.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/reputable/verify.rb', line 89

def introspect(ticket, customer_id: nil, session_ref: nil)
  base = Measurement.measurement_base_url
  uri = URI("#{base}/v1/signals/introspect")
  payload = { ticket: ticket, customerId: customer_id, sessionRef: session_ref }.compact

  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
                                                 open_timeout: Reputable.configuration.connect_timeout,
                                                 read_timeout: Reputable.configuration.read_timeout) do |http|
    request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
    request.body = JSON.generate(payload)
    http.request(request)
  end
  return nil unless response.is_a?(Net::HTTPSuccess)

  data = JSON.parse(response.body)
  return nil unless data["valid"]

  { "report" => data["report"] || data["result"], "sessionRef" => data["sessionRef"], "customerId" => data["customerId"] }
rescue StandardError => e
  Reputable.logger&.warn "Reputable: ticket introspection failed: #{e.message}"
  nil
end

.normalize_origin(value) ⇒ Object



135
136
137
138
139
140
141
142
143
144
# File 'lib/reputable/verify.rb', line 135

def normalize_origin(value)
  uri = URI(value.to_s)
  return nil unless %w[http https].include?(uri.scheme) && present?(uri.host)

  default_port = (uri.scheme == "https" ? 443 : 80)
  port = uri.port == default_port ? "" : ":#{uri.port}"
  "#{uri.scheme}://#{uri.host.downcase}#{port}"
rescue URI::InvalidURIError
  nil
end

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


112
113
114
# File 'lib/reputable/verify.rb', line 112

def present?(value)
  !blank?(value)
end

.return_origin_matches?(actual, expected) ⇒ Boolean

Returns:

  • (Boolean)


127
128
129
130
131
132
133
# File 'lib/reputable/verify.rb', line 127

def return_origin_matches?(actual, expected)
  return true unless present?(expected)

  normalized_actual = normalize_origin(actual)
  normalized_expected = normalize_origin(expected)
  normalized_actual && normalized_expected && normalized_actual == normalized_expected
end

.verify_proof(proof, **kwargs) ⇒ Object

Legacy alias from the proof era.



74
75
76
# File 'lib/reputable/verify.rb', line 74

def verify_proof(proof, **kwargs)
  verify_ticket(proof, **kwargs)
end

.verify_ticket(ticket, **options) ⇒ Object

Verify a ticket and return its signal report hash (keys include "outcome", "challengePassed", "proxyScore", "rating", "signals", ...), or nil if invalid.

Verification is offline-first (JWKS); if that yields nothing it falls back to the hosted introspection endpoint. When session_ref is given it must match the ticket's subject.



35
36
37
38
39
40
41
# File 'lib/reputable/verify.rb', line 35

def verify_ticket(ticket, **options)
  claims = verify_ticket_claims(ticket, **options)
  claims && (claims["report"] || claims["result"] || claims)
rescue StandardError => e
  Reputable.logger&.warn "Reputable: verify_ticket failed: #{e.message}"
  nil
end

.verify_ticket_claims(ticket, customer_id: nil, session_ref: nil, jwks: nil, network_fallback: true, expected_key_id: nil, expected_mode: nil, expected_return_origin: nil) ⇒ Object

Verify a ticket and return the complete signed claims. Set network_fallback: false in latency-sensitive request paths to prohibit both JWKS fetching and hosted introspection.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/reputable/verify.rb', line 46

def verify_ticket_claims(ticket, customer_id: nil, session_ref: nil, jwks: nil,
                         network_fallback: true, expected_key_id: nil,
                         expected_mode: nil, expected_return_origin: nil)
  return nil if blank?(ticket)

  offline_jwks = jwks || Reputable.configuration.measurement_result_jwks
  return nil if !network_fallback && offline_jwks.nil?

  claims = Measurement.verify_result_token(
    ticket,
    jwks: offline_jwks,
    expected_customer_id: customer_id
  )
  claims ||= introspect(ticket, customer_id: customer_id, session_ref: session_ref) if network_fallback
  return nil unless claims

  return nil unless claim_matches?(claims, "sessionRef", session_ref, alternate: "sub")
  return nil unless claim_matches?(claims, "keyId", expected_key_id)
  return nil unless claim_matches?(claims, "mode", expected_mode)
  return nil unless return_origin_matches?(claims["returnOrigin"], expected_return_origin)

  claims
rescue StandardError => e
  Reputable.logger&.warn "Reputable: verify_ticket_claims failed: #{e.message}"
  nil
end