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

Instance Method Summary collapse

Constructor Details

#initialize(config = nil) ⇒ IntrospectionClient

Returns a new instance of IntrospectionClient.



19
20
21
# File 'lib/agentadmit/introspection_client.rb', line 19

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

Instance Method Details

#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:



35
36
37
38
39
40
41
42
43
44
45
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
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
# File 'lib/agentadmit/introspection_client.rb', line 35

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

  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: honor Retry-After header or use exponential backoff, cap at 30s
      wait_ms   = retry_after ? (retry_after * 1000).ceil : [delay_ms, 30_000].min
      jitter_ms = rand(0..500)
      total_ms  = wait_ms + jitter_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 response — process normally
    case status
    when 200
      data = JSON.parse(response.body)

      # Check active flag (RFC 7662 introspection pattern).
      # The verify endpoint returns {active: false} with HTTP 200 for invalid/
      # expired/revoked tokens. Without this check, we'd read empty scopes.
      unless data["active"]
        reason = data["error"] || "invalid_token"
        raise InvalidTokenError, "Token is not active: #{reason}"
      end

      raise InvalidTokenError, "Introspection returned no user" if data["user_id"].nil?

      return IntrospectionResult.new(
        user_id:      data["user_id"],
        connection_id: data["connection_id"],
        scopes:       data["scopes"] || [],
        agent_label:  data["agent_label"] || "Unknown Agent"
      )
    when 401
      data = JSON.parse(response.body) rescue {}
      raise InvalidTokenError, data["error_description"] || "Token validation failed"
    else
      raise IntrospectionError, "Verification service returned #{response.code}"
    end
  end

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