Class: AgentAdmit::IntrospectionClient

Inherits:
Object
  • Object
show all
Defined in:
lib/agentadmit/introspection_client.rb

Overview

Mandatory introspection client -- validates tokens via AgentAdmit hosted service. No local JWT decode. Every verification call goes through AgentAdmit.

Defined Under Namespace

Classes: IntrospectionResult

Constant Summary collapse

MAX_RETRY_WAIT_MS =

Hard cap on any single retry wait -- including a server-supplied Retry-After.

30_000
MAX_RETRY_BUDGET_MS =

Hard cap on cumulative wait across all retries of a single verify call.

120_000
CALLER_CLASSES =
%w[human_session in_app_ai external_agent].freeze

Instance Method Summary collapse

Constructor Details

#initialize(config = nil) ⇒ IntrospectionClient

Returns a new instance of IntrospectionClient.



50
51
52
53
# File 'lib/agentadmit/introspection_client.rb', line 50

def initialize(config = nil)
  @config = config || AgentAdmit.configuration || Config.new
  @config.validate_api_key!
end

Instance Method Details

Ask the Consent Ledger whether a caller class may act on a user's data. Decision point for the token-less caller classes (human_session, in_app_ai); external agents get the same verdict on the verify result.

Parameters:

  • app_user_id (String)

    your app's identifier for the data owner

  • caller_class (String)

    "human_session" | "in_app_ai" | "external_agent"

  • scope_group (String, nil) (defaults to: nil)

    optional finer-than-class group

Returns:

  • (Hash)

    verdict: granted, caller_class, scope_group, source, evaluated_at

Raises:

  • (ArgumentError)

    unknown caller_class

  • (IntrospectionError)

    hosted service unreachable or rejected the call



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/agentadmit/introspection_client.rb', line 211

def check_consent(app_user_id:, caller_class:, scope_group: nil)
  unless CALLER_CLASSES.include?(caller_class)
    raise ArgumentError, "caller_class must be one of #{CALLER_CLASSES.join(', ')}"
  end

  uri  = URI.parse("#{@config.api_url.sub(%r{/\z}, '')}/api/v1/consent/check")
  http = build_http(uri)

  request = Net::HTTP::Post.new(uri.path)
  request["Authorization"] = "Bearer #{@config.api_key}"
  request["Content-Type"]  = "application/json"
  body = { app_user_id: app_user_id, caller_class: caller_class }
  body[:scope_group] = scope_group if scope_group
  request.body = JSON.generate(body)

  response = begin
    http.request(request)
  rescue StandardError => e
    raise IntrospectionError, "Consent check failed: #{e.message}"
  end

  unless (200..299).cover?(response.code.to_i)
    data = JSON.parse(response.body) rescue {}
    raise IntrospectionError,
          data["error_description"] || data["error"] || "Consent check returned #{response.code}"
  end

  # 2xx -- parse strictly; a garbage body must not read as an empty verdict.
  begin
    JSON.parse(response.body)
  rescue JSON::ParserError
    raise IntrospectionError, "Consent check response is not valid JSON"
  end
end

#verify(token) ⇒ IntrospectionResult

Validate an ag_at_ token via introspection.

Automatically retries on HTTP 429 with exponential backoff + jitter. Raises RateLimitError when retries are exhausted.

Parameters:

  • token (String)

    The full token including ag_at_ prefix

Returns:

Raises:



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/agentadmit/introspection_client.rb', line 67

def verify(token)
  unless token.start_with?(@config.token_prefix_access)
    raise InvalidTokenError, "Not an AgentAdmit access token"
  end

  max_retries = @config.respond_to?(:max_retries) ? @config.max_retries.to_i : 3
  delay_ms    = 1000 # initial backoff in milliseconds
  waited_ms   = 0    # cumulative wait across retries

  uri  = URI.parse(@config.verify_url)
  http = build_http(uri)

  (0..max_retries).each do |attempt|
    request = build_request(uri, token)

    begin
      response = http.request(request)
    rescue StandardError => e
      raise IntrospectionError, "Introspection failed: #{e.message}"
    end

    status = response.code.to_i

    if status == 429
      retry_after  = parse_float_header(response, "Retry-After")
      rl_limit     = parse_int_header(response, "X-RateLimit-Limit")
      rl_remaining = parse_int_header(response, "X-RateLimit-Remaining")
      rl_reset     = parse_int_header(response, "X-RateLimit-Reset")

      if attempt >= max_retries
        raise RateLimitError.new(
          "AgentAdmit rate limit exceeded. Max retries (#{max_retries}) exhausted.",
          retry_after: retry_after,
          limit: rl_limit,
          remaining: rl_remaining,
          reset: rl_reset
        )
      end

      # Compute wait: Retry-After beats exponential backoff, but both are
      # capped -- Retry-After is untrusted server input and must not pin
      # the caller.
      requested_ms = retry_after ? (retry_after * 1000).ceil : delay_ms
      wait_ms   = [[requested_ms, 0].max, MAX_RETRY_WAIT_MS].min
      jitter_ms = rand(0..500)
      total_ms  = wait_ms + jitter_ms

      if waited_ms + total_ms > MAX_RETRY_BUDGET_MS
        raise RateLimitError.new(
          "AgentAdmit rate limit retry budget (#{MAX_RETRY_BUDGET_MS / 1000}s) exhausted.",
          retry_after: retry_after,
          limit: rl_limit,
          remaining: rl_remaining,
          reset: rl_reset
        )
      end
      waited_ms += total_ms

      warn "[AgentAdmit] Rate-limited (attempt #{attempt + 1}/#{max_retries}). " \
           "Retrying in #{total_ms}ms."

      sleep(total_ms / 1000.0)
      delay_ms = [delay_ms * 2, 30_000].min
      next
    end

    # Non-429: only treat 2xx as a candidate for a valid token.
    unless (200..299).cover?(status)
      if status == 401
        data = JSON.parse(response.body) rescue {}
        raise InvalidTokenError, data["error_description"] || "Token validation failed"
      end
      raise IntrospectionError, "Verification service returned #{response.code}"
    end

    # 2xx -- parse and strictly validate the response body.
    data = begin
      JSON.parse(response.body)
    rescue JSON::ParserError
      raise IntrospectionError, "Introspection response is not valid JSON"
    end

    # active must be strictly true (boolean).
    unless data["active"] == true
      reason = data["error"] || "invalid_token"
      raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
    end

    # insufficient_scope arrives with active: true (token valid,
    # requested scope not granted).
    if data["error"] == "insufficient_scope"
      raise InsufficientScopeError, data["error_description"] || "Scope not granted"
    end

    # Validate that consumed fields have the expected types when present.
    validate_introspection_types!(data)

    # Keep any PRESENT consent hash, even with a malformed granted value:
    # coercing it to nil would read as "legacy server, allowed" in
    # consent_granted?, silently failing open. A malformed verdict must
    # stay visible so consent_granted? can deny it.
    consent = data["consent"]
    consent = nil unless consent.is_a?(Hash)

    # Presence rides along when the platform returns it. Same strictness
    # as active: verified must be the boolean true or false, never coerced.
    # Unlike consent, a malformed block is dropped -- presence_verified?
    # fails closed on nil, so dropping cannot fail open.
    presence = data["presence"]
    presence = nil unless presence.is_a?(Hash) && [true, false].include?(presence["verified"])

    return IntrospectionResult.new(
      user_id:      data["user_id"],
      connection_id: data["connection_id"],
      scopes:       data["scopes"] || [],
      agent_label:  data["agent_label"] || "Unknown Agent",
      sub:          data["sub"],
      role:         data["role"],
      app_id:       data["app_id"],
      jti:          data["jti"],
      exp:          data["exp"],
      consent:      consent,
      presence:     presence
    )
  end

  # Should never be reached
  raise IntrospectionError, "Unexpected exit from retry loop"
end