security(exchange): stop a logged-out subject seeding new delegations - #776
Conversation
Token exchange verified a subject_token's signature, issuer and token_type and nothing else. delegationSessionIsLive then refused the RESULTING token — but only at Authorizer's own surfaces. A delegated token is bound to a third-party `resource` and validated by that server, which has no view of this session store. So after a logout the user's agent kept minting fresh, externally valid credentials on their behalf until the subject_token expired, and nothing downstream could tell. The probe produced a fully-formed delegated token for https://api.example.com/v1 from an already-logged-out subject. Checked only when there is something to check. A sid is either well-formed or absent — DelegationSessionID returns "" rather than a partial value — and its absence is a documented state meaning "this subject had no session" (CreateDelegatedAccessToken omits the claim entirely then, so its presence always means checkable). Rejecting that case too would delete a supported branch rather than close the gap. Service-account subjects stay exempt: a client_credentials token has no browser session, and its liveness comes from the IsActive check above. Enforcing there would break every agent-to-agent hop. The rejection reuses the opaque invalid_grant of the invalid-subject path, so the endpoint cannot be used to probe who is currently signed in. This changes what a third-party resource server observes, so it needs a delegation-design decision, not just a code review — see specs/AGENTIC_DELEGATION_DESIGN.md. TestTokenExchangeRejectsInvalidResourceIndicator minted its subject via CreateAuthToken without registering a session, making it revoked-shaped. Every production path that issues an access token registers it, so the test was the anomaly; it now registers one.
An agent's activity has to be attributable — that is what the RFC 8693
act chain is for. Success was audited and counted; every one of the
fourteen refusal paths on /oauth/token's exchange grant was silent. No
audit row, no metric, only a Debug log. The sibling client_credentials
grant already audits its failures, so machine-identity auth failures were
attributable while delegation failures — carrying a user's authority, so
the more sensitive of the two — were not. An agent probing the
delegation endpoint left no trail.
Every refusal now routes through one helper, which is also what keeps it
true: a rejection path added later cannot be silent without deliberately
bypassing it.
- audit: token.exchange_failed, actor = the calling agent, metadata = a
fixed reason constant naming the rule that refused. Never the subject
id or the token — a refusal record must not be where an unverified
subject's identity gets written.
- metric: authorizer_auth_events_total{event=token_exchange} on both
success and failure. EventTokenIssued is unchanged and still counts all
issuance, but carries no grant label, so delegated issuance was
indistinguishable from any other grant and had no failure count to form
a rate against.
- metric: security event token_exchange_rejected, reason=<constant>.
The session-liveness refusal keeps returning the same opaque
invalid_grant as a malformed token, so the endpoint is not an oracle for
who is signed in — but its audit reason differs, because that row is
written server-side and never reaches the caller.
Also meters the agent scope-ceiling enforcement point, silent on every
transport until now: delegated_insufficient_scope with separate labels
for "not on the delegated allow-list" (a client bug, or probing) and
"reachable but this token lacks the scope" (widen the ceiling, or don't).
Operators had no way to see agents hitting their ceiling, which is the
number needed before deciding whether to widen one. Method names are
deliberately not labels — high cardinality on an internet-facing path.
And meters delegated-token validation refusals, so revocation actually
firing (logout, password reset, admin revoke taking an agent's access
down with the user's session) is externally visible rather than a Debug
line.
AuditTokenExchangeEvent stays reserved and unused: success remains
token.issued, which ROADMAP_V2.md documents, and renaming the action
would silently break any query already filtering on it.
|
Pushed a second commit adding the audit + metrics coverage for agent activity that this PR's own rejection exposed as missing. What was missingSuccess on this endpoint was audited and counted. All fourteen refusal paths were silent — no audit row, no metric, only a Debug log. The sibling Separately, the agent scope-ceiling enforcement point ( What changedToken exchange. Every refusal now routes through one
The session-liveness refusal this PR adds keeps returning the same opaque Scope ceiling. Validation. Deliberately not changed
Also still out of scope, and worth their own issue: the audit VerificationAll four new tests confirmed failing with the instrumentation reverted. |
* docs(changelog): cover #773-#783 Unreleased linked 50 PRs and none of #773-#783, so every change made after rc.22 - including four security fixes - was missing from the CHANGELOG a user reads at 2.4.0. Refs #773, #774, #775, #776, #777, #778, #779, #781, #782, #783 * chore: bump web/app to authorizer-react 2.2.0 authorizer-react 2.2.0 is published on authorizer-js 4.0.0; drop the -rc.7 pin. Also stamps the CHANGELOG's Unreleased section as 2.4.0. * test(e2e): make the authorizer host ports overridable The seven authorizer services published fixed host ports, so the suite could not run on a machine already using 8080-8086 - it failed at "address already in use" before any test ran. The mock services already take this shape. Playwright reaches every service by compose DNS, so the host mapping is for humans only and the defaults are unchanged.
F4 from the review that produced #774 and #775. This one changes what a
third-party resource server observes, so it wants a delegation-design decision,
not just a code review —
specs/AGENTIC_DELEGATION_DESIGN.mdshould be updatedwith it.
The gap
validateExchangeTokenverifies a subject_token's signature,issandtoken_typeand nothing else.delegationSessionIsLivethen refuses theresulting token if the originating session is gone — but only at Authorizer's
own surfaces. A delegated token is bound to a third-party
resourceand isvalidated by that server, which has no view of this session store.
So a user logs out, and their agent keeps minting fresh, externally valid
credentials on their behalf until the subject_token expires. Nothing downstream
can tell. The probe run against
mainproduced exactly that — a well-formeddelegated token for
https://api.example.com/v1,sub= the logged-out user,sidnaming a session that no longer exists.Why this is the narrow version
An earlier draft rejected any unparseable
sessionID. That would have deleted asupported branch:
DelegationSessionIDreturns""when the subject has nononce, and
CreateDelegatedAccessTokenthen omitssidentirely, documentedas "omitted entirely when the subject had no session, so the claim's presence
always means this is checkable." Turning documented design into dead code is not
a bug fix.
This version checks only when there is something to check:
sessionID != "", so the session-less branch behaves exactly as before.onBehalfOfTypebranch — aclient_credentialstoken has no browser session, and enforcing there wouldbreak every agent-to-agent hop.
invalid_grantof the invalid-subject path, sothe endpoint cannot be used to probe who is currently signed in.
An existing test was constructing an impossible token
TestTokenExchangeRejectsInvalidResourceIndicatorminted its subject withCreateAuthTokenand never registered the session. Every production path thatissues an access token registers it (login, signup, verify_email,
authorization_code, refresh, client_credentials), so a token without an entry is
a revoked token — the test was the anomaly, and it now registers one. Same
class of latent test bug as the one #774 corrects.
Verification
TestTokenExchangeRejectsAfterSubjectLogoutandTestTokenExchangeChainedHopFollowsTheSubjectSessionconfirmed failing with thesource reverted. The chained-hop test matters independently: a delegated token
carries no
nonce, so a check that only looked atnoncewould silently skiphop 2 and let a logout stop the first hop while every later one kept working.
TestTokenExchangeServiceAccountSubjectIsExemptFromSessionCheckpins theexemption rather than leaving it incidental.
Existing
TestTokenExchangeMultiHopDelegation(4 hops) and the delegated-tokensuite pass unchanged.