Skip to content

security(exchange): stop a logged-out subject seeding new delegations - #776

Merged
lakhansamani merged 2 commits into
mainfrom
security/exchange-session-liveness
Aug 15, 2026
Merged

security(exchange): stop a logged-out subject seeding new delegations#776
lakhansamani merged 2 commits into
mainfrom
security/exchange-session-liveness

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

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.md should be updated
with it.

The gap

validateExchangeToken verifies a subject_token's signature, iss and
token_type and nothing else. delegationSessionIsLive then refuses the
resulting token if the originating session is gone — but only at Authorizer's
own surfaces. A delegated token is bound to a third-party resource and is
validated 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 main produced exactly that — a well-formed
delegated token for https://api.example.com/v1, sub = the logged-out user,
sid naming a session that no longer exists.

Why this is the narrow version

An earlier draft rejected any unparseable sessionID. That would have deleted a
supported branch: DelegationSessionID returns "" when the subject has no
nonce, and CreateDelegatedAccessToken then omits sid entirely, documented
as "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:

  • Guarded on sessionID != "", so the session-less branch behaves exactly as before.
  • Service-account subjects exempt via the existing onBehalfOfType branch — a
    client_credentials token has no browser session, and 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.
  • Gates minting only, so nothing already issued breaks on deploy.

An existing test was constructing an impossible token

TestTokenExchangeRejectsInvalidResourceIndicator minted its subject with
CreateAuthToken and never registered the session. Every production path that
issues 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

TestTokenExchangeRejectsAfterSubjectLogout and
TestTokenExchangeChainedHopFollowsTheSubjectSession confirmed failing with the
source reverted. The chained-hop test matters independently: a delegated token
carries no nonce, so a check that only looked at nonce would silently skip
hop 2 and let a logout stop the first hop while every later one kept working.
TestTokenExchangeServiceAccountSubjectIsExemptFromSessionCheck pins the
exemption rather than leaving it incidental.

go build ./...   OK
go vet ./...     OK
make test        exit 0 — 43 packages, 0 FAIL
make lint        exit 0
make smoke       exit 0

Existing TestTokenExchangeMultiHopDelegation (4 hops) and the delegated-token
suite pass unchanged.

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.
@lakhansamani

Copy link
Copy Markdown
Contributor Author

Pushed a second commit adding the audit + metrics coverage for agent activity that this PR's own rejection exposed as missing.

What was missing

Success on this endpoint was audited and counted. All fourteen refusal paths were silent — no audit row, no metric, only a Debug log. The sibling client_credentials grant already audits its failures (AuditTokenClientCredentialsFailedEvent), so machine-identity auth failures were attributable while delegation failures — which carry a user's authority, so the more sensitive of the two — were not. An agent probing the delegation endpoint left no trail at all.

Separately, the agent scope-ceiling enforcement point (insufficient_scope) was unmetered on every transport, and delegated-token validation refusals were Debug-only.

What changed

Token exchange. Every refusal now routes through one rejectExchange helper — which is also what keeps this true, since a rejection path added later cannot be silent without deliberately bypassing it. Each records:

  • token.exchange_failed audit row, 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 the place an unverified subject's identity gets written.
  • authorizer_auth_events_total{event="token_exchange",status="success|failure"}. EventTokenIssued is unchanged and still counts all issuance, but it carries no grant label — delegated issuance was indistinguishable from authorization_code or client_credentials, and there was no failure count to form a rate against.
  • authorizer_security_events_total{event="token_exchange_rejected",reason=<constant>}.

The session-liveness refusal this PR adds keeps returning the same opaque invalid_grant as a malformed token, so the endpoint is still 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. TestTokenExchangeSessionRejectionCarriesItsOwnReason pins exactly that split.

Scope ceiling. delegated_insufficient_scope at both choke points (gRPC/REST/MCP interceptor and GraphQL), with separate labels for not_delegatable (the operation is not on the delegated allow-list — a client bug, or probing) and scope_missing (reachable, but this token was not granted it). Those call for opposite actions, so one label would have been useless. Method names are deliberately not labels — high cardinality on an internet-facing path.

Validation. delegated_token_rejected{reason} on audience mismatch, revoked session, and inactive subject — so revocation actually firing is externally visible instead of a Debug line.

Deliberately not changed

AuditTokenExchangeEvent ("token.exchange") stays reserved and unused. Success remains token.issued, which ROADMAP_V2.md documents as the issuance event; renaming the action would silently break any query already filtering on it. The constant now carries a comment saying so, so it is not re-flagged as dead.

Also still out of scope, and worth their own issue: the audit Metadata column holds delegation info in two incompatible formats — JSON at mint ({"on_behalf_of":...}) and space-separated key=value at use (delegated_user_id=...), because mergeAuditMetadata only appends. Unifying that, and promoting on-behalf-of to indexed columns, is a schema change across six providers.

Verification

All four new tests confirmed failing with the instrumentation reverted.

go build ./...   OK
go vet ./...     OK
make test        exit 0 — 43 packages, 0 FAIL
make lint        exit 0
make smoke       exit 0

@lakhansamani
lakhansamani merged commit 3e41e73 into main Aug 15, 2026
4 checks passed
@lakhansamani
lakhansamani deleted the security/exchange-session-liveness branch August 15, 2026 10:32
lakhansamani added a commit that referenced this pull request Aug 19, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant