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.



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

def initialize(config = nil)
  @config = config || AgentAdmit.configuration || Config.new
  @config.validate_api_key!
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:



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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/agentadmit/introspection_client.rb', line 37

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; the error code is one of
      # VERIFY_ERROR_CODES (e.g. token_expired, connection_expired,
      # environment_mismatch). Without this check, we'd read empty scopes.
      unless data["active"]
        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

      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",
        sub:          data["sub"],
        role:         data["role"],
        app_id:       data["app_id"],
        jti:          data["jti"],
        exp:          data["exp"]
      )
    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