Skip to content

security: keep machine identity on delegated tokens and the required_relations gate (GHSA-vq29-8q3c-3hrm) - #789

Merged
lakhansamani merged 3 commits into
mainfrom
security/delegation-machine-identity
Sep 3, 2026
Merged

security: keep machine identity on delegated tokens and the required_relations gate (GHSA-vq29-8q3c-3hrm)#789
lakhansamani merged 3 commits into
mainfrom
security/delegation-machine-identity

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Fixes GHSA-vq29-8q3c-3hrm (high), plus a second instance of the same primitive found in review. Developed in the advisory private fork; opened here to merge and ship in 2.4.1.

The vulnerability

An RFC 8693 delegated token minted from a service-account subject dropped the login_method claim. service.resolveFgaCaller treats a missing login_method as a human user, so a machine identity laundered into user:<sub> — flipping OpenFGA decisions deny→allow and slipping past every login_method-keyed guard.

The reported root cause was wrong

The report framed this as self-delegation (subject == actor) and proposed rejecting that shape. That is a symptom patch: the laundering lives in CreateDelegatedAccessToken omitting the claim, so it applies identically to the multi-hop A→B agent chain the design explicitly supports and which the proposed check does not catch. Both shapes are asserted in tests; both fail against pre-fix code.

The self-delegation rejection is deliberately not added — once the subject keeps its real identity, subject == actor mints an ordinary attenuated, resource-bound machine token.

Second instance, found in review

enforceRequiredRelations resolved the caller and then discarded caller.subject, hardcoding "user:"+userID. machineFgaSubject never ran on that surface, so a machine identity was answered as a human user there too — with no delegation at all: a plain client_credentials token presented to validate_jwt_token with required_relations was satisfied by a tuple written for user:<service-account-row-id>, while check_permissions denied the identical token. That is the "two answers to one authority question" the function's own doc comment warns about, and a gateway gating on required_relations would admit requests the permission API refuses.

Classification is now extracted into fgaSubjectFor, so all three FGA decision surfaces share one source of truth.

Three claims in the report that are wrong

  • "Silent revocation misfire" — false. Machine tokens are registered at service_account:<sa.ID> / access_token_<nonce> (token.go:1138,1164), exactly what delegationSessionIsLive looks up. TestDelegatedMachineTokenRevocationWorks passes on unpatched code.
  • "Bare-origin resource is out of scope" — by design. delegated_access_token.go states it verbatim; first-party acceptance is the documented contract.
  • requireOrgAdmin bypass — zero impact. The membership lookup already denies, since a laundered token's sub is a client id.

Version range correction

Submitted as > 2.3.0-rc.10. Wrong in both directions: 2.3.0-rc.10 is affected, and 2.3.0 is not — it is not a descendant of 2.3.0-rc.10 (divergent release lines) and contains no token-exchange code at all. Affected: 2.3.0-rc.10, and 2.4.0-rc.02.4.0.

Backward compatibility

CreateDelegatedAccessToken has exactly one caller, so authorization_code, implicit, refresh_token, client_credentials, OIDC id_token/userinfo/discovery/JWKS, SAML, SCIM, magic link and social login are untouched. User-subject delegation is unchanged — still no login_method, still user:<sub> — and asserted so a future change cannot stamp it.

Intentional changes:

  1. A machine-subject delegated token resolves to service_account:<client_id>. Tuples written against user:<sa-row-id> stop matching — the vulnerability closing.
  2. A chained machine-subject re-exchange now succeeds where it returned 400. Previously the token carried no login_method, so hop 2 took the user branch, did GetUserByID(<service-account row id>), found nothing and rejected — machine chains only ever worked for one hop. Still bounded: subject liveness re-checked per hop, monotonic scope attenuation, maxActChainDepth. Pinned by TestChainedMachineSubjectReExchange.

resolveFgaCaller now retains actorID in the machine branch. It dropped it before, on reasoning true of client_credentials but false of a machine-subject delegation, which now reaches that branch — dropping it would collapse authority from perms(agent) ∩ perms(subject) to perms(subject), trading identity laundering for privilege widening. Guarded by a test.

Verification

go build / go vet clean; make test 0 failures; make smoke all pass (incl. mcp_delegated, oauth_authorization_code_+_PKCE, scim_provisioning, org-admin matrix); make lint clean in changed files. Every regression test confirmed to fail pre-fix.

https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR

A delegated token minted from a SERVICE-ACCOUNT subject dropped the
login_method claim. service/fga.go treats a missing login_method as a
human user, so an autonomous machine laundered itself into "user:<sub>":
OpenFGA decisions flipped deny -> allow and every login_method-keyed
guard was bypassed.

The report framed this as self-delegation (subject == actor) and proposed
rejecting that shape. That is a symptom patch. The laundering lives in
CreateDelegatedAccessToken omitting the claim, so it applies identically
to the multi-hop agent chain the design explicitly supports and which the
proposed check does not cover. Both shapes are now covered by tests, and
the self-delegation check is not added: once the subject keeps its real
identity, subject == actor yields an ordinary attenuated, resource-bound
machine token.

resolveFgaCaller's machine branch now RETAINS actorID. It used to drop
it, on the reasoning that a machine subject is never delegated — true of
client_credentials, false of a machine-subject delegation, which now
reaches that branch. Dropping it would collapse authority from
perms(agent) n perms(subject) to perms(subject), trading identity
laundering for privilege widening.

Blast radius is one grant: CreateDelegatedAccessToken has exactly one
caller (handleTokenExchangeGrant). authorization_code, implicit, refresh,
client_credentials, OIDC id_token/userinfo, SAML and SCIM never reach it.
A USER-subject delegation still carries no login_method and is unchanged.

Claude-Session: https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR
Stamping login_method makes a previously-broken path work: before, a
machine-subject delegated token carried no login_method, so re-exchanging
it took the USER branch, did GetUserByID(<service-account row id>), found
nothing and rejected the hop. A machine-subject chain only ever worked
for its first hop (400 on hop 2).

It now takes the agent branch and succeeds, which is what the multi-hop
design intends. Recorded as a test rather than left as a surprise, along
with the controls that bound it: subject liveness is re-checked every
hop, scope still attenuates monotonically, and maxActChainDepth still
caps the chain.

Claude-Session: https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR
enforceRequiredRelations resolved the caller and then discarded
caller.subject, hardcoding "user:"+userID. machineFgaSubject therefore
never ran on this surface, so a machine identity was answered as a human
user — the same laundering primitive as the delegated-token bug, but
reachable with NO delegation at all: a plain client_credentials token
presented to validate_jwt_token with required_relations was satisfied by
a tuple written for "user:<service-account-row-id>", while
check_permissions denied the identical token.

That is the "two answers to one authority question" this function's own
doc comment warns about, and a gateway gating on required_relations
would admit requests the permission API refuses.

Classification is extracted into fgaSubjectFor so all three decision
surfaces share one source of truth, and the presented token's own
login_method is threaded in from validate_jwt_token. The browser-session
callers pass "" — a session subject is always a user.

Found by adversarial review, not by the original report. Pre-existing on
main rather than introduced by the delegation fix, but the same class,
so it ships with it.

Claude-Session: https://claude.ai/code/session_016dbRdiyrapnT7J3tAyZhdR
@lakhansamani
lakhansamani merged commit f6b60cf into main Sep 3, 2026
4 checks passed
@lakhansamani
lakhansamani deleted the security/delegation-machine-identity branch September 3, 2026 11:33
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