agentadmit 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8231757da1987653f73ed2c9b1cfa48c8745c2220856d99da8427fc017406616
4
- data.tar.gz: a0128eb51759e86da018e9852db33cafaf853b86fb9f7cd3f15f6aabd06009e6
3
+ metadata.gz: 67f416bd1136c1ff49766c515270c9c8ef4102a2474960923af9648d49406e64
4
+ data.tar.gz: 2b86e062d60f5c3c762f0845ba1a13b8c60e394ad9b7db1b0fd296d804ac7087
5
5
  SHA512:
6
- metadata.gz: 5eb0d7b249d27e9f5455eabc5b3214493dcd82b6be6563530ec05c66669aef52ae164aba352a21c744024be2b2b7a080fa5581bafff3ff9a1dc4aecab76f78a1
7
- data.tar.gz: 8f955a6ac7d15948da7bf679e7fab80d5d0576110c672ba1e179f9e5458d6e1a6ef12dc60325d121dc41ccecdb69d0d709c3bbc8c125c3bff5bc6780c6601c84
6
+ metadata.gz: 4e48106a82e8b4bfe8d89828972347b5a33afbfb60908cae5b1b3e9d92887c52f8d118d4817dc8d880dcae51a41d9cf88f3d92f53557fb4d4ae619c1186eeeb7
7
+ data.tar.gz: 7f370525b8e98ff57a5849d083a2e5a6ddc8af4edcdfe229dbe7499db9188d922fc9f60bfe0d441d7b6dbe8b1320048fbf743c91a9b5de64401733b9ae4c9c3f
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  User-mediated AI agent authorization. Plug-and-play for any Rails app.
4
4
 
5
- > **Get started:** Sign up at [agentadmit.com](https://agentadmit.com) Get your test keys Install the SDK Build.
5
+ > **Get started:** Sign up at [agentadmit.com](https://agentadmit.com) -- Get your test keys -- Install the SDK -- Build.
6
6
  > Test keys are available immediately after signup. Live keys become available when you subscribe an app.
7
7
 
8
8
  ## Quick Start
@@ -14,7 +14,6 @@ gem 'agentadmit'
14
14
 
15
15
  ```bash
16
16
  bundle install
17
- rails generate agentadmit:install
18
17
  ```
19
18
 
20
19
  Add your credentials to `config/credentials.yml.enc` or `.env`:
@@ -24,6 +23,14 @@ AGENTADMIT_APP_ID=app_yourappid
24
23
  AGENTADMIT_API_KEY=aa_test_yourkey
25
24
  ```
26
25
 
26
+ Create an initializer at `config/initializers/agentadmit.rb`:
27
+
28
+ ```ruby
29
+ AgentAdmit.configure do |config|
30
+ # Defaults are read from ENV - nothing required here unless you need overrides.
31
+ end
32
+ ```
33
+
27
34
  Add scope enforcement to any controller:
28
35
 
29
36
  ```ruby
@@ -36,13 +43,16 @@ class OrdersController < ApplicationController
36
43
  end
37
44
  ```
38
45
 
46
+ The `require_scope_if_agent!` method is available in all controllers automatically
47
+ when you use Rails -- the Railtie includes `AgentAdmit::ScopeEnforcement` into
48
+ `ActionController::Base` on load.
49
+
39
50
  Your app now supports AI agent connections with:
40
51
  - Scoped access control (you define the scopes)
41
52
  - User-controlled connection duration
42
53
  - Token generation and exchange
43
54
  - Mandatory introspection (every agent request validated through AgentAdmit)
44
- - Revocation and audit logging
45
- - Discovery endpoint at `/.well-known/agentadmit`
55
+ - Revocation support via `tokens.revoke`
46
56
 
47
57
  ## How It Works
48
58
 
@@ -58,27 +68,25 @@ The token goes to the human, not the agent. No automated delivery = no prompt in
58
68
 
59
69
  **Mandatory introspection.** All token validation goes through api.agentadmit.com. There is no self-hosted mode. No local JWT validation. No bypass. This is required for security, audit logging, and scope enforcement.
60
70
 
61
- **Admin revocation.** As the app operator, you can revoke any user's agent connection via `DELETE /agentadmit/admin/connections/{connection_id}` (requires admin role or `manage:connections` scope).
62
-
63
71
  **Embeddable admin panel.** Drop the `<AgentAdmitAdminPanel>` React component into your admin section to view all agent connections, usage metrics, billing status, and revoke any connection without leaving your app. See the React SDK for details.
64
72
 
65
73
  **In-app AI scopes.** If your app has built-in AI features (analysis, plan generation, photo recognition), do not expose those as agent scopes. The user's AI agent can read the raw data and do the analysis itself. Exposing in-app AI endpoints to agents creates double cost.
66
74
 
67
75
  ## Rate Limiting
68
76
 
69
- The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter no changes needed in your middleware code.
77
+ The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter -- no changes needed in your middleware code.
70
78
 
71
79
  ### Retry behavior
72
80
 
73
81
  | Parameter | Default | Description |
74
82
  |-----------|---------|-------------|
75
83
  | Initial delay | 1 second | First retry wait |
76
- | Backoff multiplier | | Doubles each retry |
84
+ | Backoff multiplier | 2x | Doubles each retry |
77
85
  | Cap | 30 seconds | Maximum wait per retry |
78
- | Jitter | 0500 ms | Random addition to each delay |
86
+ | Jitter | 0-500 ms | Random addition to each delay |
79
87
  | Max retries | **3** | Configurable |
80
88
 
81
- The SDK also respects the `Retry-After` response header if present, it overrides the computed backoff delay.
89
+ The SDK also respects the `Retry-After` response header -- if present, it overrides the computed backoff delay.
82
90
 
83
91
  ### Configuring max retries
84
92
 
@@ -107,10 +115,10 @@ end
107
115
  ```
108
116
 
109
117
  `RateLimitError` attributes:
110
- - `retry_after` seconds from `Retry-After` header (`nil` if absent)
111
- - `limit` `X-RateLimit-Limit` header value (`nil` if absent)
112
- - `remaining` `X-RateLimit-Remaining` header value (`nil` if absent)
113
- - `reset` `X-RateLimit-Reset` Unix timestamp (`nil` if absent)
118
+ - `retry_after` -- seconds from `Retry-After` header (`nil` if absent)
119
+ - `limit` -- `X-RateLimit-Limit` header value (`nil` if absent)
120
+ - `remaining` -- `X-RateLimit-Remaining` header value (`nil` if absent)
121
+ - `reset` -- `X-RateLimit-Reset` Unix timestamp (`nil` if absent)
114
122
 
115
123
  ## Documentation
116
124
 
@@ -122,14 +130,14 @@ Full integration guide: https://agentadmit.com/docs/app-owner-guide
122
130
  The AgentAdmit Ruby SDK runs server-side and does not interact with app stores or end-user devices directly.
123
131
 
124
132
  ### What the SDK does
125
- - Validates AgentAdmit tokens by calling AgentAdmit's hosted introspection endpoint (`https://api.agentadmit.com/api/v1/verify`) on every agent request this is mandatory introspection; there is no local or offline validation mode
133
+ - Validates AgentAdmit tokens by calling AgentAdmit's hosted introspection endpoint (`https://api.agentadmit.com/api/v1/verify`) on every agent request -- this is mandatory introspection; there is no local or offline validation mode
126
134
  - Enforces scope-based access control on your API routes
127
- - Manages connection lifecycle (create, revoke, audit) using your configured storage backend
135
+ - Manages connection lifecycle (create, revoke) using the AgentAdmit hosted service
128
136
 
129
137
  ### What the SDK does NOT do
130
- - Does not transmit raw end-user PII (such as name, email, or device identifiers) each introspection request sends the opaque access token and your API key
131
- - Does not perform passive background telemetry or analytics network calls occur only during active token validation
132
- - Does not maintain its own persistent storage — local state (connections, audit log) lives in the storage backend you configure
138
+ - Does not transmit raw end-user PII (such as name, email, or device identifiers) -- each introspection request sends the opaque access token and your API key
139
+ - Does not perform passive background telemetry or analytics -- network calls occur only during active token validation
140
+ - Does not maintain its own persistent local storage
133
141
 
134
142
  ### What the AgentAdmit hosted service records
135
143
  On every token validation, AgentAdmit's `/api/v1/verify` endpoint receives the access token and API key, resolves the token to its `user_id`, `connection_id`, granted `scopes`, and `agent_label`, and records per-call metadata (including the endpoint and timestamp) for billing, audit logging, the security alerts engine, and usage metering. This is integral to how AgentAdmit works and applies to both test and live keys. See the "Mandatory introspection" notes above and the [compliance guide](https://agentadmit.com/docs/compliance) for the full data-handling description.
@@ -177,10 +185,10 @@ config = alerts.get_alert_config(app_id: 'app_abc123')
177
185
 
178
186
  ### Notifying Your Users
179
187
 
180
- AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. **How you notify your own users is up to you.** AgentAdmit provides the data you deliver it through your own system (in-app notifications, email, push, etc.).
188
+ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. **How you notify your own users is up to you.** AgentAdmit provides the data -- you deliver it through your own system (in-app notifications, email, push, etc.).
181
189
 
182
- - **Poll alerts** Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
183
- - **Webhook delivery** Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your `whsec_…` secret. Always verify the signature against the raw request body before trusting the payload:
190
+ - **Poll alerts** -- Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
191
+ - **Webhook delivery** -- Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your `whsec_...` secret. Always verify the signature against the raw request body before trusting the payload:
184
192
 
185
193
  ```ruby
186
194
  # Rails controller
@@ -188,7 +196,7 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
188
196
  AgentAdmit::Webhook.verify_signature(
189
197
  request.raw_post,
190
198
  request.headers["X-AgentAdmit-Signature"].to_s,
191
- AgentAdmit.configuration.webhook_secret # whsec_ from AGENTADMIT_WEBHOOK_SECRET
199
+ AgentAdmit.configuration.webhook_secret # whsec_... from AGENTADMIT_WEBHOOK_SECRET
192
200
  )
193
201
  event = JSON.parse(request.raw_post)
194
202
  # ...
@@ -198,8 +206,8 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
198
206
  end
199
207
  ```
200
208
 
201
- The header format is `t=<unix_ts>,v1=<hex>` an HMAC-SHA256 of `{t}.{raw_body}` keyed with your signing secret. Verification compares in constant time and rejects timestamps more than 5 minutes off (replay protection).
202
- - **React SDK** Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
209
+ The header format is `t=<unix_ts>,v1=<hex>` -- an HMAC-SHA256 of `{t}.{raw_body}` keyed with your signing secret. Verification compares in constant time and rejects timestamps more than 5 minutes off (replay protection).
210
+ - **React SDK** -- Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
203
211
 
204
212
  ### Issuing & Exchanging Tokens
205
213
 
@@ -207,18 +215,18 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
207
215
  tokens = AgentAdmit::TokensClient.new
208
216
 
209
217
  # Duration is tri-state:
210
- # omit the argument AgentAdmit default (30 days)
211
- # nil until the user revokes
212
- # Integer (6031536000) explicit seconds
218
+ # omit the argument => AgentAdmit default (30 days)
219
+ # nil => until the user revokes
220
+ # Integer (60-31536000) => explicit seconds
213
221
  issued = tokens.issue_token(
214
222
  user_id: "user_42",
215
223
  scopes: ["read:orders"],
216
224
  role: "user",
217
225
  duration_seconds: nil # until revoked
218
226
  )
219
- connection_token = issued["token"] # ag_ct_
227
+ connection_token = issued["token"] # ag_ct_...
220
228
 
221
- # Agent side no API key needed; the connection token is the credential.
229
+ # Agent side -- no API key needed; the connection token is the credential.
222
230
  granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
223
231
 
224
232
  # Revoke when the user disconnects the agent.
@@ -5,26 +5,39 @@
5
5
  # There is no self-hosted mode. No local JWT validation. No bypass.
6
6
  # This is required for security, audit logging, and scope enforcement.
7
7
 
8
+ require "uri"
9
+
8
10
  module AgentAdmit
9
11
  class Config
10
- attr_accessor :app_id, :api_key, :verify_url, :api_url,
11
- :token_prefix_access, :token_prefix_connection,
12
+ attr_accessor :app_id, :api_key, :token_prefix_access, :token_prefix_connection,
12
13
  :webhook_secret, :max_retries
13
14
 
15
+ attr_reader :verify_url, :api_url
16
+
14
17
  def initialize
15
18
  @app_id = ENV.fetch("AGENTADMIT_APP_ID", "")
16
19
  @api_key = ENV.fetch("AGENTADMIT_API_KEY", "")
17
- @verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
18
- @api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
20
+ self.verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
21
+ self.api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
19
22
  @token_prefix_access = "ag_at_"
20
23
  @token_prefix_connection = "ag_ct_"
21
- # Webhook signing secret (whsec_) shown once when you configure the
24
+ # Webhook signing secret (whsec_...) -- shown once when you configure the
22
25
  # alert webhook URL in the dashboard. Used by AgentAdmit::Webhook.
23
26
  @webhook_secret = ENV.fetch("AGENTADMIT_WEBHOOK_SECRET", "")
24
27
  # Max retries on HTTP 429 before raising RateLimitError. Default: 3.
25
28
  @max_retries = ENV.fetch("AGENTADMIT_MAX_RETRIES", "3").to_i
26
29
  end
27
30
 
31
+ def verify_url=(url)
32
+ validate_url!(url, :verify_url)
33
+ @verify_url = url
34
+ end
35
+
36
+ def api_url=(url)
37
+ validate_url!(url, :api_url)
38
+ @api_url = url
39
+ end
40
+
28
41
  ##
29
42
  # Validate the API key prefix (aa_test_/aa_live_) without ever echoing
30
43
  # the key itself.
@@ -37,5 +50,30 @@ module AgentAdmit
37
50
 
38
51
  raise ConfigurationError, "api_key must start with 'aa_test_' or 'aa_live_'"
39
52
  end
53
+
54
+ private
55
+
56
+ # Local loopback hostnames / addresses that are allowed over plain HTTP.
57
+ LOCALHOST_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
58
+
59
+ ##
60
+ # Raise ConfigurationError for non-https URLs unless the host is localhost.
61
+ #
62
+ def validate_url!(url, field)
63
+ return if url.nil? || url.empty?
64
+
65
+ uri = URI.parse(url)
66
+ return if uri.scheme == "https"
67
+
68
+ if uri.scheme == "http" && LOCALHOST_HOSTS.include?(uri.host)
69
+ return
70
+ end
71
+
72
+ raise ConfigurationError,
73
+ "#{field} must use https (got: #{url.inspect}). " \
74
+ "Plain http is only permitted for localhost / 127.0.0.1 / [::1]."
75
+ rescue URI::InvalidURIError
76
+ raise ConfigurationError, "#{field} is not a valid URL: #{url.inspect}"
77
+ end
40
78
  end
41
79
  end
@@ -6,10 +6,15 @@ require "uri"
6
6
 
7
7
  module AgentAdmit
8
8
  ##
9
- # Mandatory introspection client validates tokens via AgentAdmit hosted service.
9
+ # Mandatory introspection client -- validates tokens via AgentAdmit hosted service.
10
10
  # No local JWT decode. Every verification call goes through AgentAdmit.
11
11
  #
12
12
  class IntrospectionClient
13
+ # Hard cap on any single retry wait -- including a server-supplied Retry-After.
14
+ MAX_RETRY_WAIT_MS = 30_000
15
+ # Hard cap on cumulative wait across all retries of a single verify call.
16
+ MAX_RETRY_BUDGET_MS = 120_000
17
+
13
18
  IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
14
19
  :sub, :role, :app_id, :jti, :exp, keyword_init: true) do
15
20
  def has_scope?(scope)
@@ -41,6 +46,7 @@ module AgentAdmit
41
46
 
42
47
  max_retries = @config.respond_to?(:max_retries) ? @config.max_retries.to_i : 3
43
48
  delay_ms = 1000 # initial backoff in milliseconds
49
+ waited_ms = 0 # cumulative wait across retries
44
50
 
45
51
  uri = URI.parse(@config.verify_url)
46
52
  http = build_http(uri)
@@ -72,11 +78,25 @@ module AgentAdmit
72
78
  )
73
79
  end
74
80
 
75
- # Compute wait: honor Retry-After header or use exponential backoff, cap at 30s
76
- wait_ms = retry_after ? (retry_after * 1000).ceil : [delay_ms, 30_000].min
81
+ # Compute wait: Retry-After beats exponential backoff, but both are
82
+ # capped -- Retry-After is untrusted server input and must not pin
83
+ # the caller.
84
+ requested_ms = retry_after ? (retry_after * 1000).ceil : delay_ms
85
+ wait_ms = [[requested_ms, 0].max, MAX_RETRY_WAIT_MS].min
77
86
  jitter_ms = rand(0..500)
78
87
  total_ms = wait_ms + jitter_ms
79
88
 
89
+ if waited_ms + total_ms > MAX_RETRY_BUDGET_MS
90
+ raise RateLimitError.new(
91
+ "AgentAdmit rate limit retry budget (#{MAX_RETRY_BUDGET_MS / 1000}s) exhausted.",
92
+ retry_after: retry_after,
93
+ limit: rl_limit,
94
+ remaining: rl_remaining,
95
+ reset: rl_reset
96
+ )
97
+ end
98
+ waited_ms += total_ms
99
+
80
100
  warn "[AgentAdmit] Rate-limited (attempt #{attempt + 1}/#{max_retries}). " \
81
101
  "Retrying in #{total_ms}ms."
82
102
 
@@ -85,46 +105,48 @@ module AgentAdmit
85
105
  next
86
106
  end
87
107
 
88
- # Non-429 response process normally
89
- case status
90
- when 200
91
- data = JSON.parse(response.body)
92
-
93
- # Check active flag (RFC 7662 introspection pattern).
94
- # The verify endpoint returns {active: false} with HTTP 200 for
95
- # invalid/expired/revoked tokens; the error code is one of
96
- # VERIFY_ERROR_CODES (e.g. token_expired, connection_expired,
97
- # environment_mismatch). Without this check, we'd read empty scopes.
98
- unless data["active"]
99
- reason = data["error"] || "invalid_token"
100
- raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
108
+ # Non-429: only treat 2xx as a candidate for a valid token.
109
+ unless (200..299).cover?(status)
110
+ if status == 401
111
+ data = JSON.parse(response.body) rescue {}
112
+ raise InvalidTokenError, data["error_description"] || "Token validation failed"
101
113
  end
114
+ raise IntrospectionError, "Verification service returned #{response.code}"
115
+ end
102
116
 
103
- # insufficient_scope arrives with active: true (token valid,
104
- # requested scope not granted).
105
- if data["error"] == "insufficient_scope"
106
- raise InsufficientScopeError, data["error_description"] || "Scope not granted"
107
- end
117
+ # 2xx -- parse and strictly validate the response body.
118
+ data = begin
119
+ JSON.parse(response.body)
120
+ rescue JSON::ParserError
121
+ raise IntrospectionError, "Introspection response is not valid JSON"
122
+ end
108
123
 
109
- raise InvalidTokenError, "Introspection returned no user" if data["user_id"].nil?
110
-
111
- return IntrospectionResult.new(
112
- user_id: data["user_id"],
113
- connection_id: data["connection_id"],
114
- scopes: data["scopes"] || [],
115
- agent_label: data["agent_label"] || "Unknown Agent",
116
- sub: data["sub"],
117
- role: data["role"],
118
- app_id: data["app_id"],
119
- jti: data["jti"],
120
- exp: data["exp"]
121
- )
122
- when 401
123
- data = JSON.parse(response.body) rescue {}
124
- raise InvalidTokenError, data["error_description"] || "Token validation failed"
125
- else
126
- raise IntrospectionError, "Verification service returned #{response.code}"
124
+ # active must be strictly true (boolean).
125
+ unless data["active"] == true
126
+ reason = data["error"] || "invalid_token"
127
+ raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
127
128
  end
129
+
130
+ # insufficient_scope arrives with active: true (token valid,
131
+ # requested scope not granted).
132
+ if data["error"] == "insufficient_scope"
133
+ raise InsufficientScopeError, data["error_description"] || "Scope not granted"
134
+ end
135
+
136
+ # Validate that consumed fields have the expected types when present.
137
+ validate_introspection_types!(data)
138
+
139
+ return IntrospectionResult.new(
140
+ user_id: data["user_id"],
141
+ connection_id: data["connection_id"],
142
+ scopes: data["scopes"] || [],
143
+ agent_label: data["agent_label"] || "Unknown Agent",
144
+ sub: data["sub"],
145
+ role: data["role"],
146
+ app_id: data["app_id"],
147
+ jti: data["jti"],
148
+ exp: data["exp"]
149
+ )
128
150
  end
129
151
 
130
152
  # Should never be reached
@@ -133,6 +155,36 @@ module AgentAdmit
133
155
 
134
156
  private
135
157
 
158
+ ##
159
+ # Enforce that the fields the middleware relies on have the correct types.
160
+ # Any type mismatch means we cannot safely use the response -- treat as invalid.
161
+ #
162
+ # @raise [InvalidTokenError]
163
+ #
164
+ def validate_introspection_types!(data)
165
+ # user_id is required and must be a String.
166
+ unless data["user_id"].is_a?(String)
167
+ raise InvalidTokenError, "Introspection returned no user"
168
+ end
169
+
170
+ # agent_id, connection_id -- must be String when present.
171
+ %w[agent_id connection_id].each do |field|
172
+ val = data[field]
173
+ next if val.nil?
174
+ unless val.is_a?(String)
175
+ raise InvalidTokenError, "Introspection field '#{field}' must be a String"
176
+ end
177
+ end
178
+
179
+ # scopes -- must be Array of Strings when present.
180
+ if data.key?("scopes")
181
+ scopes = data["scopes"]
182
+ unless scopes.is_a?(Array) && scopes.all? { |s| s.is_a?(String) }
183
+ raise InvalidTokenError, "Introspection field 'scopes' must be an Array of Strings"
184
+ end
185
+ end
186
+ end
187
+
136
188
  def build_http(uri)
137
189
  http = Net::HTTP.new(uri.host, uri.port)
138
190
  http.use_ssl = uri.scheme == "https"
@@ -6,13 +6,17 @@ module AgentAdmit
6
6
  # and validates them via introspection.
7
7
  #
8
8
  # Sets env variables for downstream use:
9
- # env['agentadmit.auth_type'] "agent" or nil
10
- # env['agentadmit.user_id'] validated user ID
11
- # env['agentadmit.scopes'] granted scopes array
12
- # env['agentadmit.connection_id'] connection identifier
13
- # env['agentadmit.agent_label'] agent display name
9
+ # env['agentadmit.auth_type'] -- "agent" or nil
10
+ # env['agentadmit.user_id'] -- validated user ID
11
+ # env['agentadmit.scopes'] -- granted scopes array
12
+ # env['agentadmit.connection_id'] -- connection identifier
13
+ # env['agentadmit.agent_label'] -- agent display name
14
14
  #
15
15
  class Middleware
16
+ # RFC 7235: the auth-scheme token is case-insensitive.
17
+ # Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
18
+ BEARER_AGENT_RE = /\Abearer ag_at_/i
19
+
16
20
  def initialize(app)
17
21
  @app = app
18
22
  @client = IntrospectionClient.new
@@ -22,8 +26,9 @@ module AgentAdmit
22
26
  def call(env)
23
27
  auth = env["HTTP_AUTHORIZATION"] || ""
24
28
 
25
- if auth.start_with?("Bearer #{@config.token_prefix_access}")
26
- token = auth.sub("Bearer ", "")
29
+ if BEARER_AGENT_RE.match?(auth)
30
+ # Strip the scheme prefix (case-insensitively) to get the bare token.
31
+ token = auth.sub(/\Abearer /i, "")
27
32
 
28
33
  begin
29
34
  result = @client.verify(token)
@@ -4,6 +4,7 @@ module AgentAdmit
4
4
  class Railtie < Rails::Railtie
5
5
  initializer "agentadmit.middleware" do |app|
6
6
  app.middleware.use AgentAdmit::Middleware
7
+ Rails.logger.info "[AgentAdmit] Middleware and scope enforcement active."
7
8
  end
8
9
 
9
10
  initializer "agentadmit.configure" do
@@ -11,5 +12,12 @@ module AgentAdmit
11
12
  # Config loaded from environment variables by default
12
13
  end
13
14
  end
15
+
16
+ # Include ScopeEnforcement into every ActionController so that
17
+ # require_scope_if_agent! and require_scope! are available without
18
+ # an explicit `include` in each controller.
19
+ ActiveSupport.on_load(:action_controller) do
20
+ include AgentAdmit::ScopeEnforcement
21
+ end
14
22
  end
15
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.1.2"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agentadmit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Emerson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-17 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json