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 +4 -4
- data/README.md +38 -30
- data/lib/agentadmit/config.rb +43 -5
- data/lib/agentadmit/introspection_client.rb +91 -39
- data/lib/agentadmit/middleware.rb +12 -7
- data/lib/agentadmit/railtie.rb +8 -0
- data/lib/agentadmit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67f416bd1136c1ff49766c515270c9c8ef4102a2474960923af9648d49406e64
|
|
4
|
+
data.tar.gz: 2b86e062d60f5c3c762f0845ba1a13b8c60e394ad9b7db1b0fd296d804ac7087
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
|
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
|
|
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 |
|
|
84
|
+
| Backoff multiplier | 2x | Doubles each retry |
|
|
77
85
|
| Cap | 30 seconds | Maximum wait per retry |
|
|
78
|
-
| Jitter | 0
|
|
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
|
|
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`
|
|
111
|
-
- `limit`
|
|
112
|
-
- `remaining`
|
|
113
|
-
- `reset`
|
|
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
|
|
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
|
|
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)
|
|
131
|
-
- Does not perform passive background telemetry or analytics
|
|
132
|
-
- Does not maintain its own persistent
|
|
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
|
|
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**
|
|
183
|
-
- **Webhook delivery**
|
|
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_
|
|
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>`
|
|
202
|
-
- **React SDK**
|
|
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
|
|
211
|
-
# nil
|
|
212
|
-
# Integer (60
|
|
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
|
|
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.
|
data/lib/agentadmit/config.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
|
|
18
|
-
|
|
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_
|
|
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
|
|
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:
|
|
76
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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']
|
|
10
|
-
# env['agentadmit.user_id']
|
|
11
|
-
# env['agentadmit.scopes']
|
|
12
|
-
# env['agentadmit.connection_id']
|
|
13
|
-
# env['agentadmit.agent_label']
|
|
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
|
|
26
|
-
|
|
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)
|
data/lib/agentadmit/railtie.rb
CHANGED
|
@@ -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
|
data/lib/agentadmit/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-07-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|