agentadmit 1.8.0 → 1.10.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 +61 -0
- data/lib/agentadmit/app_attested_presence.rb +77 -0
- data/lib/agentadmit/caller_consent.rb +69 -21
- data/lib/agentadmit/introspection_client.rb +96 -8
- data/lib/agentadmit/middleware.rb +42 -2
- data/lib/agentadmit/tokens_client.rb +18 -3
- data/lib/agentadmit/version.rb +1 -1
- data/lib/agentadmit.rb +79 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccda6bad890712e5c6060b2c33d02df1b1245ff48918a267cede579acf3f0d84
|
|
4
|
+
data.tar.gz: 5ef222b57006bcba2bd589c2b6276117abcfac031230263a8063ef56449cf355
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a703d18e7a9a55ed4a34b07d2ed295ddfbc49fabdba0caef6b49ef0a57cb670097b78ef5a53849035e22cc3a29709977836df8b8bdd4f660fc09bb20adaac84e
|
|
7
|
+
data.tar.gz: 06a8b43f3a18b696df4ddccb6abb0130c62addff9d4ab948b51ea6ea1d2b0e2bf2cdb11b5cd1da90734fc94d9b6f7e3512f9fae536563418d4d8a361fc058a8b
|
data/README.md
CHANGED
|
@@ -349,3 +349,64 @@ result.user_intent # => "Book my flights to the Austin offsite in October" or ni
|
|
|
349
349
|
```
|
|
350
350
|
|
|
351
351
|
`user_intent` is nullable -- connections issued without one (or by servers that predate the field) read as `nil`. Months later, a review screen can answer "is this still appropriate?" with the user's own stated boundary, not just the app's. Like purpose, it is a review-time record and never an enforcement input; authorization decisions ride scopes, connection status, and consent.
|
|
352
|
+
|
|
353
|
+
## App-Attested Presence
|
|
354
|
+
|
|
355
|
+
If your app gates token minting behind its own embedded passkey/WebAuthn ceremony, AgentAdmit never witnesses that ceremony (it is origin-bound), so by default the hosted service reports `presence.verified: false` for those connections. Attest the ceremony fact at issuance to close that gap -- AFTER verifying and consuming your own fresh, purpose-bound attestation:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
issued = tokens.issue_token(
|
|
359
|
+
user_id: "user_42",
|
|
360
|
+
scopes: ["read:orders"],
|
|
361
|
+
presence: AgentAdmit::AppAttestedPresence.new(
|
|
362
|
+
method: "my_webauthn", # lowercase alphanumeric/underscore
|
|
363
|
+
verified_at: attestation.created_at # Time or DateTime
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
The SDK sends it as `presence: {verified: true, uv: true, method, verified_at}` -- `verified`/`uv` are literal true by construction and the class cannot represent anything else; a raw Hash is rejected so the wire contract stays owned by the typed class. The hosted service validates freshness (10-minute window, 60 s future clock-skew slack) and stores the method provenance-marked `app:<method>` so app-attested facts stay distinct from ceremonies AgentAdmit witnessed itself. Introspection, the grant-event ledger, and the evidence API then carry `presence.verified: true` for the connection.
|
|
369
|
+
|
|
370
|
+
Honesty ceiling: this is your app's attestation, recorded and provenance-marked. It is not witnessed by AgentAdmit and not independently verifiable. Only attest a ceremony that verified the user with UV (biometric or PIN user verification); a ceremony without UV carries no presence fact, so pass `nil` (the default). An out-of-contract method (`^[a-z0-9_]+$`, 1-60) raises `ArgumentError` at construction, before any request; Ruby `Time`/`DateTime` always carry an offset, so `verified_at` serializes RFC 3339 with an explicit offset by construction.
|
|
371
|
+
|
|
372
|
+
## Per-Call Audit Telemetry
|
|
373
|
+
|
|
374
|
+
Every verified call reports the exercised scope, endpoint, and method to your app's tamper-evident audit log on the hosted service. The introspection request body carries three optional fields alongside the token:
|
|
375
|
+
|
|
376
|
+
- `scope_used` -- the single declared scope this call enforces (never a joined list)
|
|
377
|
+
- `endpoint` -- the request path only; the query string is stripped before sending (queries can carry PII) and the path is truncated to 500 characters
|
|
378
|
+
- `method` -- the HTTP method, uppercased, capped at 20 characters
|
|
379
|
+
|
|
380
|
+
Each field is sent whenever it is known and OMITTED when it is not -- never null, never an empty string. When a field is omitted, the audit row honestly records "not reported".
|
|
381
|
+
|
|
382
|
+
The Rack middleware always sends `endpoint` (from `PATH_INFO`) and `method`; declare the enforced scope at mount time to send `scope_used` too:
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
# Scope resolved per request (env -> scope String or nil) ...
|
|
386
|
+
use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
|
|
387
|
+
# ... or one static scope for everything behind this middleware.
|
|
388
|
+
use AgentAdmit::Middleware, scope_for: "read:orders"
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`AgentAdmit::CallerConsent` never sends `scope_used` (its consent gate precedes any scope disclosure; the scope check stays local, after consent) but still reports endpoint and method. Direct client calls pass the same optional keyword arguments:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
result = AgentAdmit::IntrospectionClient.new.verify(
|
|
395
|
+
token,
|
|
396
|
+
scope_used: "read:orders",
|
|
397
|
+
endpoint: request.path,
|
|
398
|
+
method: request.request_method
|
|
399
|
+
)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The local `ScopeEnforcement` checks (`require_scope!`, `require_scope_if_agent!`) are unchanged -- defense in depth on top of the hosted decision.
|
|
403
|
+
|
|
404
|
+
### Active-error responses are denials
|
|
405
|
+
|
|
406
|
+
An introspection response with `active: true` AND a string `error` field means the token itself is valid but the authorization service refused this call. The SDK treats every such response as a denial, never a pass-through -- the downstream app does not run:
|
|
407
|
+
|
|
408
|
+
- `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
|
|
409
|
+
- `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
|
|
410
|
+
- any other error string -> `AgentAdmit::ActiveDenialError`; middlewares return 403 with `{error: <code>, error_description: "Call refused by the authorization service."}` -- unknown codes fail closed
|
|
411
|
+
|
|
412
|
+
All three inherit from `AgentAdmit::ActiveDenialError` and expose `#code`, `#data` (the parsed hosted response), and `#denial_body` (the ready-made 403 JSON body) for apps that call `verify` directly.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module AgentAdmit
|
|
6
|
+
##
|
|
7
|
+
# App-attested presence: a ceremony fact your app attests at token issuance.
|
|
8
|
+
#
|
|
9
|
+
# Pass an instance to TokensClient#issue_token AFTER verifying and consuming
|
|
10
|
+
# your app's own fresh, purpose-bound WebAuthn/passkey attestation for the
|
|
11
|
+
# mint. The SDK forwards it to the hosted mint as
|
|
12
|
+
# presence {verified: true, uv: true, method, verified_at}; the hosted
|
|
13
|
+
# service stores it method-prefixed "app:<method>" — the provenance marker
|
|
14
|
+
# that keeps app-attested facts distinct from hosted-witnessed ceremonies.
|
|
15
|
+
#
|
|
16
|
+
# Honesty ceiling: this is YOUR attestation, recorded and provenance-marked.
|
|
17
|
+
# It is not witnessed by AgentAdmit and not independently verifiable. Only
|
|
18
|
+
# construct one for a ceremony that verified the user with UV (biometric or
|
|
19
|
+
# PIN user verification); verified/uv serialize as literal true and cannot
|
|
20
|
+
# represent anything else — a ceremony without UV carries no presence fact,
|
|
21
|
+
# so simply pass nil.
|
|
22
|
+
#
|
|
23
|
+
# verified_at must be recent: the hosted service enforces a 10-minute
|
|
24
|
+
# freshness window with 60 seconds of future clock-skew slack. Ruby Time and
|
|
25
|
+
# DateTime always carry an offset, so #iso8601 serializes RFC 3339 with an
|
|
26
|
+
# explicit offset by construction (the hosted contract; offset-less
|
|
27
|
+
# timestamps are rejected with 400).
|
|
28
|
+
#
|
|
29
|
+
class AppAttestedPresence
|
|
30
|
+
METHOD_PATTERN = /\A[a-z0-9_]+\z/
|
|
31
|
+
METHOD_MAX_LENGTH = 60
|
|
32
|
+
|
|
33
|
+
# NOTE: a +method+ reader shadows Object#method on instances — the same
|
|
34
|
+
# trade stdlib's Net::HTTPGenericRequest makes; the name matches the wire
|
|
35
|
+
# field.
|
|
36
|
+
attr_reader :method, :verified_at
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param method [String] your ceremony mechanism, 1-60 lowercase
|
|
40
|
+
# alphanumeric/underscore characters (e.g. "my_webauthn")
|
|
41
|
+
# @param verified_at [Time, DateTime] when the ceremony completed
|
|
42
|
+
# @raise [ArgumentError] when method is out of contract or verified_at is
|
|
43
|
+
# not a timestamp — validated at construction, before any request,
|
|
44
|
+
# where the fix is obvious
|
|
45
|
+
#
|
|
46
|
+
def initialize(method:, verified_at:)
|
|
47
|
+
unless method.is_a?(String) && !method.empty? &&
|
|
48
|
+
method.length <= METHOD_MAX_LENGTH && METHOD_PATTERN.match?(method)
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"method must be 1-#{METHOD_MAX_LENGTH} lowercase alphanumeric/underscore " \
|
|
51
|
+
"characters (e.g. 'my_webauthn')"
|
|
52
|
+
end
|
|
53
|
+
unless verified_at.respond_to?(:iso8601)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"verified_at must be a Time or DateTime (the ceremony that authorized " \
|
|
56
|
+
"this mint just happened)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@method = method
|
|
60
|
+
@verified_at = verified_at
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# The exact JSON object forwarded to the hosted mint.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash]
|
|
67
|
+
#
|
|
68
|
+
def to_wire
|
|
69
|
+
{
|
|
70
|
+
"verified" => true,
|
|
71
|
+
"uv" => true,
|
|
72
|
+
"method" => @method,
|
|
73
|
+
"verified_at" => @verified_at.iso8601
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -134,33 +134,37 @@ module AgentAdmit
|
|
|
134
134
|
token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
|
|
135
135
|
|
|
136
136
|
begin
|
|
137
|
-
|
|
137
|
+
# The verify body carries endpoint/method audit telemetry only.
|
|
138
|
+
# scope_used is deliberately NOT sent from this middleware: the
|
|
139
|
+
# hosted refusal body carries no consent verdict and no user_id, so
|
|
140
|
+
# a hosted scope refusal here could not be consent-resolved -- and
|
|
141
|
+
# consent must precede any scope disclosure (Patent FIG. 3). The
|
|
142
|
+
# scope check stays local, after the consent gate, exactly as the
|
|
143
|
+
# other AgentAdmit SDKs do.
|
|
144
|
+
result = @client.verify(token,
|
|
145
|
+
endpoint: env["PATH_INFO"],
|
|
146
|
+
method: env["REQUEST_METHOD"])
|
|
147
|
+
rescue InsufficientScopeError
|
|
148
|
+
# Unreachable when this middleware performs the verify (scope_used
|
|
149
|
+
# is never sent, so the hosted service cannot refuse on scope).
|
|
150
|
+
# Kept as a fail-closed guard that reveals no scope state to a
|
|
151
|
+
# caller class whose consent was never evaluated.
|
|
152
|
+
return [403, { "Content-Type" => "application/json" },
|
|
153
|
+
[{ error: "insufficient_scope",
|
|
154
|
+
message: "Call refused by the authorization service." }.to_json]]
|
|
155
|
+
rescue ActiveDenialError => e
|
|
156
|
+
# Token valid, call refused (bound_exceeded or an unknown error
|
|
157
|
+
# string on an active response). Fail closed: 403, app never runs.
|
|
158
|
+
return [403, { "Content-Type" => "application/json" },
|
|
159
|
+
[e.denial_body.to_json]]
|
|
138
160
|
rescue InvalidTokenError => e
|
|
139
161
|
return json_error(401, "invalid_token", e.message)
|
|
140
162
|
rescue IntrospectionError => e
|
|
141
163
|
return json_error(502, "introspection_failed", e.message)
|
|
142
164
|
end
|
|
143
165
|
|
|
144
|
-
consent = result.consent
|
|
145
|
-
|
|
146
|
-
owner = result.user_id
|
|
147
|
-
if !owner.is_a?(String) || owner.empty?
|
|
148
|
-
return json_error(503, "consent_unavailable",
|
|
149
|
-
"Introspection carried no consent verdict and no resolvable data owner")
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
begin
|
|
153
|
-
consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
|
|
154
|
-
scope_group: @scope_group)
|
|
155
|
-
rescue StandardError
|
|
156
|
-
return json_error(503, "consent_unavailable", "Consent check failed")
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
unless consent.is_a?(Hash) && consent["granted"] == true
|
|
161
|
-
return json_error(403, "consent_not_granted",
|
|
162
|
-
"The data owner has not enabled external agent access.")
|
|
163
|
-
end
|
|
166
|
+
status, consent = resolve_external_consent(result.consent, result.user_id)
|
|
167
|
+
return consent unless status == :granted
|
|
164
168
|
|
|
165
169
|
if @required_scope && !(result.scopes || []).include?(@required_scope)
|
|
166
170
|
return [403, { "Content-Type" => "application/json" },
|
|
@@ -181,6 +185,50 @@ module AgentAdmit
|
|
|
181
185
|
@app.call(env)
|
|
182
186
|
end
|
|
183
187
|
|
|
188
|
+
##
|
|
189
|
+
# Resolve the external-agent consent verdict, fail-closed. An inline
|
|
190
|
+
# boolean verdict is authoritative; an absent or malformed one is
|
|
191
|
+
# resolved through the Consent Ledger (absence is never a grant), using
|
|
192
|
+
# the owner from the introspection payload.
|
|
193
|
+
#
|
|
194
|
+
# @return [Array] [:granted, verdict Hash] when the class is allowed,
|
|
195
|
+
# otherwise [:denied | :unavailable, Rack response triple] ready to
|
|
196
|
+
# return.
|
|
197
|
+
#
|
|
198
|
+
def resolve_external_consent(consent, owner)
|
|
199
|
+
unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
|
|
200
|
+
if !owner.is_a?(String) || owner.empty?
|
|
201
|
+
return [:unavailable,
|
|
202
|
+
json_error(503, "consent_unavailable",
|
|
203
|
+
"Introspection carried no consent verdict and no resolvable data owner")]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
|
|
208
|
+
scope_group: @scope_group)
|
|
209
|
+
rescue StandardError
|
|
210
|
+
return [:unavailable, json_error(503, "consent_unavailable", "Consent check failed")]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
unless consent.is_a?(Hash) && consent["granted"] == true
|
|
215
|
+
return [:denied,
|
|
216
|
+
json_error(403, "consent_not_granted",
|
|
217
|
+
"The data owner has not enabled external agent access.")]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
[:granted, consent]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
##
|
|
224
|
+
# A hosted insufficient_scope refusal (active: true, valid token). The
|
|
225
|
+
# class consent decision still comes first: a caller whose class the
|
|
226
|
+
# owner denied must not learn scope state, so the verdict is resolved
|
|
227
|
+
# from the hosted payload (or the ledger, fail-closed) and a denied or
|
|
228
|
+
# unresolvable class gets its consent response, never the step-up shape.
|
|
229
|
+
# Only a consent-granted caller sees the 403 step-up body.
|
|
230
|
+
#
|
|
231
|
+
|
|
184
232
|
##
|
|
185
233
|
# Token-less caller class (in_app_ai, or human_session under gate_human),
|
|
186
234
|
# gated on the Consent Ledger. Fail closed: an unreachable or erroring
|
|
@@ -71,13 +71,27 @@ module AgentAdmit
|
|
|
71
71
|
# Automatically retries on HTTP 429 with exponential backoff + jitter.
|
|
72
72
|
# Raises {RateLimitError} when retries are exhausted.
|
|
73
73
|
#
|
|
74
|
+
# Per-call audit telemetry (all optional, all omitted from the request
|
|
75
|
+
# body when unknown -- never sent as null or empty string):
|
|
76
|
+
#
|
|
74
77
|
# @param token [String] The full token including ag_at_ prefix
|
|
78
|
+
# @param scope_used [String, nil] the single scope this call enforces
|
|
79
|
+
# (from the scope-enforcing integration point). Never a joined list;
|
|
80
|
+
# omit for bare auth resolution / presence-only gates.
|
|
81
|
+
# @param endpoint [String, nil] inbound request path. Sent path-only:
|
|
82
|
+
# the query string is stripped (queries can carry PII) and the path is
|
|
83
|
+
# truncated to 500 characters.
|
|
84
|
+
# @param method [String, nil] inbound HTTP method; sent uppercased,
|
|
85
|
+
# capped at 20 characters.
|
|
75
86
|
# @return [IntrospectionResult]
|
|
76
87
|
# @raise [InvalidTokenError] if validation fails
|
|
88
|
+
# @raise [ActiveDenialError] (incl. {InsufficientScopeError},
|
|
89
|
+
# {BoundExceededError}) if the response is active but carries an error
|
|
90
|
+
# string -- the service refused this call; always a denial
|
|
77
91
|
# @raise [IntrospectionError] if the service is unreachable
|
|
78
92
|
# @raise [RateLimitError] if rate-limited and retries exhausted
|
|
79
93
|
#
|
|
80
|
-
def verify(token)
|
|
94
|
+
def verify(token, scope_used: nil, endpoint: nil, method: nil)
|
|
81
95
|
unless token.start_with?(@config.token_prefix_access)
|
|
82
96
|
raise InvalidTokenError, "Not an AgentAdmit access token"
|
|
83
97
|
end
|
|
@@ -90,7 +104,8 @@ module AgentAdmit
|
|
|
90
104
|
http = build_http(uri)
|
|
91
105
|
|
|
92
106
|
(0..max_retries).each do |attempt|
|
|
93
|
-
request = build_request(uri, token
|
|
107
|
+
request = build_request(uri, token, scope_used: scope_used,
|
|
108
|
+
endpoint: endpoint, method: method)
|
|
94
109
|
|
|
95
110
|
begin
|
|
96
111
|
response = http.request(request)
|
|
@@ -165,10 +180,13 @@ module AgentAdmit
|
|
|
165
180
|
raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
|
|
166
181
|
end
|
|
167
182
|
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
# Active-error fail-closed: `active: true` with a string `error`
|
|
184
|
+
# means the token is valid but the authorization service refused
|
|
185
|
+
# THIS call (insufficient_scope, bound_exceeded, or anything the
|
|
186
|
+
# service may add later). Every such response is a DENIAL, never a
|
|
187
|
+
# pass-through -- unknown error strings included.
|
|
188
|
+
if data["error"].is_a?(String) && !data["error"].empty?
|
|
189
|
+
raise_active_denial!(data, scope_used)
|
|
172
190
|
end
|
|
173
191
|
|
|
174
192
|
# Validate that consumed fields have the expected types when present.
|
|
@@ -310,14 +328,84 @@ module AgentAdmit
|
|
|
310
328
|
http
|
|
311
329
|
end
|
|
312
330
|
|
|
313
|
-
|
|
331
|
+
##
|
|
332
|
+
# Map an active-response error string to its typed denial (always raises).
|
|
333
|
+
#
|
|
334
|
+
# - insufficient_scope: token valid, enforced scope not granted. Carries
|
|
335
|
+
# the step-up fields: required_scope from the hosted response when
|
|
336
|
+
# present, else the scope this call enforced; granted_scopes from the
|
|
337
|
+
# hosted response when present.
|
|
338
|
+
# - bound_exceeded: the hosted bounded-capabilities layer refused the
|
|
339
|
+
# call; hosted fields ride along verbatim on the error's data.
|
|
340
|
+
# - anything else: unknown refusal -> generic typed denial. Fail closed.
|
|
341
|
+
#
|
|
342
|
+
def raise_active_denial!(data, scope_used)
|
|
343
|
+
case data["error"]
|
|
344
|
+
when "insufficient_scope"
|
|
345
|
+
required = data["required_scope"].is_a?(String) ? data["required_scope"] : scope_used
|
|
346
|
+
granted = data["granted_scopes"]
|
|
347
|
+
granted = data["scopes"] unless granted.is_a?(Array)
|
|
348
|
+
granted = nil unless granted.is_a?(Array)
|
|
349
|
+
raise InsufficientScopeError.new(
|
|
350
|
+
data["error_description"] || "Scope not granted",
|
|
351
|
+
required_scope: required, granted_scopes: granted, data: data
|
|
352
|
+
)
|
|
353
|
+
when "bound_exceeded"
|
|
354
|
+
raise BoundExceededError.new(
|
|
355
|
+
data["error_description"] || "Call refused by the authorization service.",
|
|
356
|
+
data: data
|
|
357
|
+
)
|
|
358
|
+
else
|
|
359
|
+
raise ActiveDenialError.new(
|
|
360
|
+
"Call refused by the authorization service.",
|
|
361
|
+
code: data["error"], data: data
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
##
|
|
367
|
+
# Build the introspection POST. Beyond the token, the body carries the
|
|
368
|
+
# per-call audit telemetry when known: scope_used (the single scope this
|
|
369
|
+
# call enforces), endpoint (path only -- query stripped, queries can
|
|
370
|
+
# carry PII -- truncated to 500 chars), method (uppercase, capped at
|
|
371
|
+
# 20). Unknown fields are OMITTED, never sent as null or empty string --
|
|
372
|
+
# the hosted audit row then honestly records "not reported".
|
|
373
|
+
#
|
|
374
|
+
def build_request(uri, token, scope_used: nil, endpoint: nil, method: nil)
|
|
314
375
|
req = Net::HTTP::Post.new(uri.path)
|
|
315
376
|
req["Authorization"] = "Bearer #{@config.api_key}"
|
|
316
377
|
req["Content-Type"] = "application/json"
|
|
317
|
-
|
|
378
|
+
|
|
379
|
+
body = { token: token }
|
|
380
|
+
scope = presence_of(scope_used)
|
|
381
|
+
body[:scope_used] = scope if scope
|
|
382
|
+
path = normalize_endpoint(endpoint)
|
|
383
|
+
body[:endpoint] = path if path
|
|
384
|
+
verb = presence_of(method)
|
|
385
|
+
body[:method] = verb.upcase[0, 20] if verb
|
|
386
|
+
|
|
387
|
+
req.body = JSON.generate(body)
|
|
318
388
|
req
|
|
319
389
|
end
|
|
320
390
|
|
|
391
|
+
# The value when it is a non-empty String, else nil (field omitted).
|
|
392
|
+
def presence_of(value)
|
|
393
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Path only: strip everything from the first "?" (query strings can
|
|
397
|
+
# carry PII) and cap at 500 characters. nil when nothing usable remains
|
|
398
|
+
# so the field is omitted, never null.
|
|
399
|
+
def normalize_endpoint(endpoint)
|
|
400
|
+
path = presence_of(endpoint)
|
|
401
|
+
return nil unless path
|
|
402
|
+
|
|
403
|
+
path = path.split("?", 2).first
|
|
404
|
+
return nil if path.nil? || path.empty?
|
|
405
|
+
|
|
406
|
+
path[0, 500]
|
|
407
|
+
end
|
|
408
|
+
|
|
321
409
|
##
|
|
322
410
|
# Parse a response header as Float, returning nil if absent or non-numeric.
|
|
323
411
|
#
|
|
@@ -13,15 +13,34 @@ module AgentAdmit
|
|
|
13
13
|
# env['agentadmit.agent_label'] -- agent display name
|
|
14
14
|
# env['agentadmit.presence'] -- human-presence block (Hash) or nil
|
|
15
15
|
#
|
|
16
|
+
# Every verify call carries per-call audit telemetry: the request path
|
|
17
|
+
# (PATH_INFO -- no query string) and the uppercase HTTP method, plus the
|
|
18
|
+
# scope the call enforces when the middleware is mounted with one:
|
|
19
|
+
#
|
|
20
|
+
# # scope resolved per request (env -> scope String or nil) ...
|
|
21
|
+
# use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
|
|
22
|
+
# # ... or one static scope for everything behind this middleware
|
|
23
|
+
# use AgentAdmit::Middleware, scope_for: "read:orders"
|
|
24
|
+
#
|
|
25
|
+
# When no scope is known the field is omitted (never null) and the hosted
|
|
26
|
+
# audit row records "not reported". The local ScopeEnforcement checks
|
|
27
|
+
# remain unchanged -- defense in depth.
|
|
28
|
+
#
|
|
29
|
+
# An introspection response with active: true AND an error string is a
|
|
30
|
+
# DENIAL: the token is valid but the authorization service refused this
|
|
31
|
+
# call (insufficient_scope, bound_exceeded, or an error code this SDK has
|
|
32
|
+
# never heard of). The middleware returns 403 and never calls the app.
|
|
33
|
+
#
|
|
16
34
|
class Middleware
|
|
17
35
|
# RFC 7235: the auth-scheme token is case-insensitive.
|
|
18
36
|
# Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
|
|
19
37
|
BEARER_AGENT_RE = /\Abearer ag_at_/i
|
|
20
38
|
|
|
21
|
-
def initialize(app)
|
|
39
|
+
def initialize(app, scope_for: nil)
|
|
22
40
|
@app = app
|
|
23
41
|
@client = IntrospectionClient.new
|
|
24
42
|
@config = AgentAdmit.configuration || Config.new
|
|
43
|
+
@scope_for = scope_for
|
|
25
44
|
end
|
|
26
45
|
|
|
27
46
|
def call(env)
|
|
@@ -32,13 +51,21 @@ module AgentAdmit
|
|
|
32
51
|
token = auth.sub(/\Abearer /i, "")
|
|
33
52
|
|
|
34
53
|
begin
|
|
35
|
-
result = @client.verify(token
|
|
54
|
+
result = @client.verify(token,
|
|
55
|
+
scope_used: resolve_scope(env),
|
|
56
|
+
endpoint: env["PATH_INFO"],
|
|
57
|
+
method: env["REQUEST_METHOD"])
|
|
36
58
|
env["agentadmit.auth_type"] = "agent"
|
|
37
59
|
env["agentadmit.user_id"] = result.user_id
|
|
38
60
|
env["agentadmit.scopes"] = result.scopes
|
|
39
61
|
env["agentadmit.connection_id"] = result.connection_id
|
|
40
62
|
env["agentadmit.agent_label"] = result.agent_label
|
|
41
63
|
env["agentadmit.presence"] = result.presence
|
|
64
|
+
rescue ActiveDenialError => e
|
|
65
|
+
# Token valid, call refused (active: true + error). Fail closed:
|
|
66
|
+
# 403 with the denial's contract shape; the app never runs.
|
|
67
|
+
return [403, { "Content-Type" => "application/json" },
|
|
68
|
+
[e.denial_body.to_json]]
|
|
42
69
|
rescue InvalidTokenError => e
|
|
43
70
|
return [401, { "Content-Type" => "application/json" },
|
|
44
71
|
[{ error: "invalid_token", error_description: e.message }.to_json]]
|
|
@@ -50,5 +77,18 @@ module AgentAdmit
|
|
|
50
77
|
|
|
51
78
|
@app.call(env)
|
|
52
79
|
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# The scope this request enforces, when the app declared one at mount
|
|
85
|
+
# time. scope_for may be a Proc (env -> scope String or nil) or a
|
|
86
|
+
# static String; nil (the default) omits scope_used from the verify body.
|
|
87
|
+
#
|
|
88
|
+
def resolve_scope(env)
|
|
89
|
+
return @scope_for.call(env) if @scope_for.respond_to?(:call)
|
|
90
|
+
|
|
91
|
+
@scope_for
|
|
92
|
+
end
|
|
53
93
|
end
|
|
54
94
|
end
|
|
@@ -54,14 +54,21 @@ module AgentAdmit
|
|
|
54
54
|
# user's typed words would be data loss. Empty/whitespace-only strings
|
|
55
55
|
# normalize to nil and are omitted. Like purpose, it is a review-time
|
|
56
56
|
# record, never an enforcement input.
|
|
57
|
+
# @param presence [AppAttestedPresence, nil] app-attested ceremony fact:
|
|
58
|
+
# set it AFTER verifying and consuming your app's own fresh,
|
|
59
|
+
# purpose-bound WebAuthn/passkey attestation for this mint. Forwarded
|
|
60
|
+
# as presence {verified: true, uv: true, method, verified_at} and
|
|
61
|
+
# stored provenance-marked "app:<method>"; omitted when nil (omitting
|
|
62
|
+
# the field is the only way to say "no ceremony").
|
|
57
63
|
# @return [Hash] the issue response — "token" is the self-describing
|
|
58
64
|
# ag_ct_… connection token to hand to the user's agent
|
|
59
|
-
# @raise [ArgumentError] if purpose exceeds 300 characters,
|
|
60
|
-
# user_intent is a non-String (other than nil) or exceeds 300
|
|
65
|
+
# @raise [ArgumentError] if purpose exceeds 300 characters, if
|
|
66
|
+
# user_intent is a non-String (other than nil) or exceeds 300
|
|
67
|
+
# characters, or if presence is neither nil nor an AppAttestedPresence
|
|
61
68
|
# @raise [IntrospectionError] if issuance fails
|
|
62
69
|
#
|
|
63
70
|
def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil,
|
|
64
|
-
user_intent: nil)
|
|
71
|
+
user_intent: nil, presence: nil)
|
|
65
72
|
if purpose && purpose.length > PURPOSE_MAX_LENGTH
|
|
66
73
|
raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
|
|
67
74
|
end
|
|
@@ -77,10 +84,18 @@ module AgentAdmit
|
|
|
77
84
|
end
|
|
78
85
|
user_intent = nil if user_intent && user_intent.strip.empty?
|
|
79
86
|
|
|
87
|
+
# Presence is typed-only: a raw Hash is rejected so the wire contract
|
|
88
|
+
# (literal-true verified/uv, offset-carrying verified_at) stays owned
|
|
89
|
+
# by AppAttestedPresence, never hand-rolled at call sites.
|
|
90
|
+
unless presence.nil? || presence.is_a?(AppAttestedPresence)
|
|
91
|
+
raise ArgumentError, "presence must be an AgentAdmit::AppAttestedPresence or nil"
|
|
92
|
+
end
|
|
93
|
+
|
|
80
94
|
body = { "user_id" => user_id, "scopes" => scopes }
|
|
81
95
|
body["role"] = role if role
|
|
82
96
|
body["purpose"] = purpose if purpose
|
|
83
97
|
body["user_intent"] = user_intent if user_intent
|
|
98
|
+
body["presence"] = presence.to_wire if presence
|
|
84
99
|
# Tri-state: the UNSET sentinel omits the key entirely; nil survives
|
|
85
100
|
# JSON.generate as explicit JSON null (no compact, no nil-guard).
|
|
86
101
|
body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)
|
data/lib/agentadmit/version.rb
CHANGED
data/lib/agentadmit.rb
CHANGED
|
@@ -20,7 +20,84 @@ module AgentAdmit
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
##
|
|
24
|
+
# Raised when the hosted /verify returns `active: true` together with a
|
|
25
|
+
# string `error` field: the token itself is valid, but the authorization
|
|
26
|
+
# service refused THIS call. Always a denial, never a pass-through --
|
|
27
|
+
# middlewares map it to HTTP 403, and unknown error strings fail closed
|
|
28
|
+
# (forward compatible: an error code this SDK version has never heard of
|
|
29
|
+
# must never be treated as a pass).
|
|
30
|
+
#
|
|
31
|
+
# {#code} carries the machine-readable error string; {#data} carries the
|
|
32
|
+
# parsed hosted response so denial responses can pass hosted fields
|
|
33
|
+
# through; {#denial_body} is the ready-made 403 JSON body.
|
|
34
|
+
#
|
|
35
|
+
class ActiveDenialError < Error
|
|
36
|
+
# @return [String] machine-readable error code from the active response
|
|
37
|
+
attr_reader :code
|
|
38
|
+
# @return [Hash] the parsed hosted introspection response
|
|
39
|
+
attr_reader :data
|
|
40
|
+
|
|
41
|
+
def initialize(message = "Call refused by the authorization service.",
|
|
42
|
+
code: "access_denied", data: {})
|
|
43
|
+
super(message)
|
|
44
|
+
@code = code
|
|
45
|
+
@data = data
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The HTTP 403 body for this denial. Unknown codes get the generic
|
|
49
|
+
# fail-closed shape; subclasses override with their contract shape.
|
|
50
|
+
# @return [Hash]
|
|
51
|
+
def denial_body
|
|
52
|
+
{ "error" => code,
|
|
53
|
+
"error_description" => "Call refused by the authorization service." }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# `active: true` + `error: "insufficient_scope"` -- token valid, the scope
|
|
59
|
+
# this call enforces not granted. {#denial_body} is the spec step-up shape
|
|
60
|
+
# (error, required_scope, granted_scopes).
|
|
61
|
+
#
|
|
62
|
+
class InsufficientScopeError < ActiveDenialError
|
|
63
|
+
# @return [String, nil] the scope the call enforced (hosted value when
|
|
64
|
+
# present, else the scope_used this SDK sent)
|
|
65
|
+
attr_reader :required_scope
|
|
66
|
+
# @return [Array<String>, nil] granted scopes from the hosted response
|
|
67
|
+
attr_reader :granted_scopes
|
|
68
|
+
|
|
69
|
+
def initialize(message = "Scope not granted", required_scope: nil,
|
|
70
|
+
granted_scopes: nil, data: {})
|
|
71
|
+
super(message, code: "insufficient_scope", data: data)
|
|
72
|
+
@required_scope = required_scope
|
|
73
|
+
@granted_scopes = granted_scopes
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def denial_body
|
|
77
|
+
{ "error" => "insufficient_scope",
|
|
78
|
+
"required_scope" => required_scope,
|
|
79
|
+
"granted_scopes" => granted_scopes || [] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# `active: true` + `error: "bound_exceeded"` -- the hosted bounded-
|
|
85
|
+
# capabilities layer refused the call. {#denial_body} passes the hosted
|
|
86
|
+
# fields (error_description, bound, renewal) through verbatim.
|
|
87
|
+
#
|
|
88
|
+
class BoundExceededError < ActiveDenialError
|
|
89
|
+
def initialize(message = "Call refused by the authorization service.", data: {})
|
|
90
|
+
super(message, code: "bound_exceeded", data: data)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def denial_body
|
|
94
|
+
body = { "error" => "bound_exceeded", "error_description" => message }
|
|
95
|
+
body["bound"] = data["bound"] if data.key?("bound")
|
|
96
|
+
body["renewal"] = data["renewal"] if data.key?("renewal")
|
|
97
|
+
body
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
24
101
|
class IntrospectionError < Error; end
|
|
25
102
|
class ConfigurationError < Error; end
|
|
26
103
|
|
|
@@ -88,6 +165,7 @@ end
|
|
|
88
165
|
|
|
89
166
|
require_relative "agentadmit/config"
|
|
90
167
|
require_relative "agentadmit/introspection_client"
|
|
168
|
+
require_relative "agentadmit/app_attested_presence"
|
|
91
169
|
require_relative "agentadmit/tokens_client"
|
|
92
170
|
require_relative "agentadmit/alerts_client"
|
|
93
171
|
require_relative "agentadmit/webhook"
|
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.10.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-09-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -35,6 +35,7 @@ files:
|
|
|
35
35
|
- README.md
|
|
36
36
|
- lib/agentadmit.rb
|
|
37
37
|
- lib/agentadmit/alerts_client.rb
|
|
38
|
+
- lib/agentadmit/app_attested_presence.rb
|
|
38
39
|
- lib/agentadmit/caller_consent.rb
|
|
39
40
|
- lib/agentadmit/config.rb
|
|
40
41
|
- lib/agentadmit/introspection_client.rb
|