Skip to content

fix(agentauth)!: stop agent sessions from writing as their user - #145

Merged
juicycleff merged 10 commits into
mainfrom
fix/agent-delegation-guards
Nov 3, 2026
Merged

juicycleff merged 10 commits into
mainfrom
fix/agent-delegation-guards

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

Agent and token-exchange sessions can no longer write as their user. Every authsome POST, PUT, PATCH and DELETE route, core and plugin, now answers a delegated session with 403 unless the route is marked agent-safe. Reads stay open, so an agent still sees GET /v1/me and anything else its user can read.

This is a breaking change for agent integrations that used their user's session on core write routes (profile, sessions, devices, environments, webhooks, orgs, billing, consent, social links). Move that work behind your own routes with agentauth's new Plugin.GuardRoute(action, resource) and a scope on the grant, or let the user's own session do it. If you mount authsome's plugins on a router yourself rather than through the Forge extension, pass them middleware.GuardDelegatedWrites(router). Human sessions, impersonation and service accounts acting as themselves see no change.

What's in here, commit by commit:

  • JWTs carry org_id and env_id. Switching org mints a new token, refresh re-checks membership, and an agent session can't switch org at all.
  • Agent sessions can't mint user credentials: the routes and dashboard paths that issued keys, codes or sessions as the user refuse them.
  • Admin routes refuse delegated sessions before the permission check, so a 403 no longer tells an agent which permissions its user holds.
  • Introspection reports principal_kind and delegated, so client mode can apply the same rule.
  • Deny by default for writes, through an interceptor (middleware.GuardDelegatedWrites) on the router groups. Only POST /v1/signout and POST /v1/oauth/revoke are agent-safe; mark a route of your own with plugin.AgentSafe().
  • agentauth's Guard now checks exchanged sessions and needs a delegation scope that covers the route.

We went to deny by default because review kept finding writes the route-by-route refusals missed, DELETE /v1/me among them. A route somebody adds next month now starts closed to agents.

Rebased onto main after #143. Its rule that sign-up gives no session until the email is verified meant a few of these tests had to sign in after verifying, and one conflict in service.go keeps both changes. A table test sends 152 write routes through three kinds of caller, and a second test boots the real extension and fails if a route loses its guard. go test ./... passes.

One thing is still open and isn't part of this PR: an agent can read environments and webhooks as its user, and those reads show what the user is allowed to do.

TokenClaims has always declared org_id and env_id, and the docs said
the token carried them, but none of the three signing sites set them.
A JWT validated offline had no way to tell which tenant it was for.

Before this, a JWT from sign-up, sign-in, refresh or the OAuth2
provider carried no org_id and no env_id, whatever the session held.
Switching org updated the session row and minted nothing, so a JWT
client kept acting in the old org, and the middleware's JWT path saw
no org at all.

Now each of those tokens carries env_id from the session, plus org_id
once the session has an active org (omitted until then, never sent
empty). Refresh keeps both. The OAuth2 provider stamps env_id only,
because an authorization code or device code records no org.

On a JWT app, POST /v1/me/switch-org signs a new access token for the
new org, rotates it onto the session row and returns it as
session_token with expires_at. It resets the session cookie too. The
new token keeps the old expiry. The old one isn't revoked: it keeps
verifying, with the old org_id, until it expires. Both response fields
are new and optional, so existing clients still decode the response,
but a client has to pick up session_token to act in the new org.
Opaque apps see no change. The response omits both fields and the
token stays the same.

If you verify these JWTs yourself, expect two new claims. If your
tests compare decoded claims exactly, add env_id (and org_id after a
switch). A switch that loses a race with a concurrent refresh returns
409 and leaves the row alone.

The SDKs are regenerated for the two new response fields.
Review of ced9f3f1 found three holes in the org_id work.

An agent session could trade itself for the user. SwitchActiveOrg only
skipped minting for sessions with an actor chain, and agent sessions
carry none. On a JWT app the agent got back a token with sub set to the
delegating user and no pk claim, which the middleware reads as the
user's own login, in any org that user belongs to. On an opaque app it
could still move its session off its grant's org. SwitchActiveOrg now
returns ErrOrgSwitchNotUser for any session that isn't a user's, and
POST /v1/me/switch-org answers 403 (the handler also refuses when the
middleware already resolved an agent session). The row is untouched and
nothing is minted.

Refresh trusted the org on the row. Removing a member clears that org
best-effort, and a refresh that read the row first writes the whole row
back on rotation, org included, so every later refresh re-signed org_id
for an org the user had left. Refresh now asks the organization plugin
about the membership whenever the session has an org, and drops the org
(token and row) when the user is no longer in it or no plugin is
registered. A failed membership lookup fails the refresh.

The middleware scoped JWT requests by the token's org_id without
comparing it to the session row it had just loaded. With the session
check on (session.jwt_require_active_session, the default) a JWT whose
org_id differs from the row's active org is now refused.

Consumers will notice a few things. Authsome's own middleware now puts the org on the
context (OrgIDFrom, and forge's org scope) for JWT requests, because
the tokens carry org_id since ced9f3f1. Forge apps that read the scope
will see an org scope where they used to get an app scope. After a
switch the old JWT stops authenticating against authsome right away,
though an offline verifier still accepts it until it expires. A session
that switched org before this release still holds a JWT with no org_id,
and that token gets refused once; the client refreshes and carries on.
Tests that switch org and keep calling with the pre-switch JWT need the
new session_token. An agent calling switch-org gets 403, not 200 or 500.

The docs now say all of this, including the 409 when a switch races a
refresh.
An agent session carries its delegating user's ID, and the auth middleware puts that ID on the
context, so any route that reads the user ID took the agent for the user. 08921af1 closed this for
switch-org. Every other route that mints something in the user's name was still open, and what
came out of it outlived the agent's grant.

Until now an agent could:

- create an API key owned by the user (POST /v1/keys)
- authorize an OAuth2 client it controls, through /v1/oauth/authorize, the consent page or device
  approval, and get an ordinary user JWT back with no pk claim
- create OAuth2 clients or rotate their secrets, when the user held manage:oauth2_client
- impersonate anyone in the app, mint service-account keys or create a user with a password it
  picked, when the user held manage:user
- change the password, enroll an MFA factor, regenerate recovery codes or turn MFA off (agent
  sessions are minted fresh, so they passed the recent-sign-in step-up)
- open the dashboard as the user, and mint API keys from there

All of these now answer 403 to an agent session, and nothing gets written. A session minted by
token exchange gets the same answer, since its actor chain says somebody else is calling. Your
own session, an admin's impersonation session and a service account acting as itself work as they
did. Passkey registration refuses both kinds as well. An agent couldn't reach it before, because
it has no user record on the context, but an exchanged session could.

The guard is middleware.DenyDelegatedSession, on top of a new Session.IsDelegated. It replaces
agentauth's private denyAgentPrincipal, so /v1/me/agents and /v1/admin/agents now refuse exchanged
sessions too. Engine.Impersonate and Engine.ChangePassword return authsome.ErrDelegatedSession when
the context carries one, and the OAuth2 plugin checks again before it issues a code or approves a
device code.

If you drive any of these routes with an agent or exchanged session, you got 200 or 201 before and
get 403 now, so tests that did that need the new status. Human sessions see no change, and the
OpenAPI spec is the same.

Two gaps are left. The rest of /v1/admin still treats an agent as its user. A client-mode
dashboard checks tokens by introspection, which doesn't report the principal kind, so it still
can't tell an agent token from the user's.
…em in introspection

8ba4807b kept agent sessions from minting credentials in their user's name. This takes the rest
of the admin surface away from them and tells remote callers which tokens are delegated. All of it
applies to delegated sessions in general, meaning an agent session or a session minted by token
exchange, whose actor chain names the principal that's really calling. An admin's impersonation
session and a service account acting as itself are still let through.

An agent's authority is its grant. Every admin route checked permissions against the delegating
user, though, so an agent acting for an admin could assign roles, grant platform ownership, ban or
delete users and change app settings. Every route under /v1/admin, the role writes under
/v1/roles, and the organization, waitlist and shared-signals admin routes now answer 403 to a
delegated session and change nothing. plugin.AdminGuard ends with middleware.DenyDelegatedSession,
which is how the OAuth2, SSO, social and agent admin groups pick it up.

An agent token used to introspect as its user's user_id with nothing else to go on, so a
client-mode dashboard opened as the user. POST /v1/introspect now returns principal_kind on every
active token, and delegated: true on a delegated one, to unprivileged callers too since they're the
ones enforcing it. The client-mode dashboard checker gives no identity when either field says the
token isn't the user's own. A server that predates the fields sends neither, and its tokens resolve
as before. The SDKs are regenerated and the diff only adds the two fields.

The handlers that write credentials straight to the store (API key create, MFA enroll, disable and
recovery-code regeneration, passkey registration, the dashboard's API key create) now call
middleware.RefuseDelegated themselves, and Engine.AdminCreateUser returns
authsome.ErrDelegatedSession. Mount one of them without the route guard and it still refuses.

If you drive an admin route or a role write with an agent or exchanged session, you used to get
whatever the user would have got and now get 403 (PERMISSION_DENIED from the dashboard contract),
so those tests need the new status. Introspection responses gain principal_kind, plus delegated
when it's true. Human admin sessions see no change.

Still open: role reads, environments, webhooks, org membership and the subscription admin writes
treat an agent as its user. ClientAuthMiddleware in client mode doesn't read the new fields and
puts no session on the context, so DenyDelegatedSession can't see a delegated token there yet.
…sion shape

Review of ad519f69 drove real tokens through the auth middleware and found the guard blind in
client mode, plus a classification that refused the wrong callers.

A client-mode service (the forgery services run this way) learns about a token from introspection.
ClientAuthMiddleware ignored the new principal_kind and delegated fields and put no session on the
context, so DenyDelegatedSession and RefuseDelegated saw nothing and an agent token went through as
its user. When introspection says delegated, the middleware now marks the request, and
RefuseDelegated reads that marker as well as the session. A JWT with an RFC 8693 act claim gets the
same marker on the JWT path, which also has no session row to read.

IsDelegated now looks at the session's shape instead of its kind. Any delegation marker counts
(AgentID, GrantID, or a DelegationID that isn't an impersonation's), as do an actor chain and a
non-user principal that names a user. That matches agentauth's Authorize, which already treats
markers without the agent kind as inconsistent. A principal acting as itself is not delegated,
whatever its kind. Before this, a service account registered with the agent kind and no user got
403 on admin routes while introspection called the same token not delegated. It now gets through,
and introspection agrees. JWT introspection follows the same rule through a new
TokenClaims.IsDelegated: an act claim, or a machine token whose sub is a user. A machine token whose
sub is itself is not delegated.

The delegated-session deny now runs before the permission check in plugin.AdminGuard and in the
organization and waitlist admin groups, still after the session is resolved. A delegated caller
gets the same refusal whatever its user may do.

If you run authsome in client mode, an agent or exchanged token now gets 403 from your service on
any route that mounts DenyDelegatedSession or calls RefuseDelegated, where before it passed as its
user. You need an identity server on this release for the delegated field; an older one sends
nothing and its tokens read as before. Agent-kind service accounts acting as themselves stop getting
403 on admin routes. Nothing about the OpenAPI spec changes.

New tests send a real agent token from agentauth and a real exchanged token through
engine.AuthMiddleware, and cover the organization, waitlist and shared-signals admin groups the same
way. The admin route tests now check that nothing changed and that the owner's own session still
works, and the passkey tests check that no ceremony is stored. Two em dashes that crept into the
agentauth guard test comments are gone.
… marked agent-safe

Agent and token-exchange sessions now get 403 on every authsome POST, PUT, PATCH and DELETE
route, core and plugin, unless the route is marked agent-safe. GET, HEAD and OPTIONS stay open, so
an agent still reads GET /v1/me and anything else its user can read. An agent's authority comes
from its grant, through agentauth's Guard on your own routes, and nowhere else.

The earlier commits refused delegated sessions route by route. Review kept finding writes they
missed: DELETE /v1/me deleted the user's account, and an agent could also create environments,
orgs, webhooks and billing objects as its user. Listing routes one at a time doesn't end, so the
rule is now the other way round. A route somebody adds next month is refused to agents until
somebody marks it.

The rule is an interceptor, middleware.GuardDelegatedWrites, put on a router group. It runs after
the route's middleware, so the group's session guard has already resolved the caller, and it can
read the route's metadata, which is where the marker lives. The API registers its own routes
through one. The Forge extension and testutil hand one to every plugin's RegisterRoutes, and the
root route providers and the contract server get one too. It reads the same delegated check as
everything else (RefuseDelegated), so it holds in client mode through the introspection marker and
on the JWT path through the act claim.

Two routes are agent-safe, because the caller can only touch its own credential there:
POST /v1/signout ends its own session, and POST /v1/oauth/revoke revokes a token it holds. Token
exchange, refresh and the OAuth2 token endpoint stay unmarked. An agent session never reaches them
with a session on the request, a workload exchanging tokens acts as itself, and Engine.Refresh
already refuses agent sessions.

To open a route of your own to agents, add plugin.AgentSafe() (or middleware.AgentSafe()) to its
options. For a route that enforces the grant, agentauth's new Plugin.GuardRoute(action, resource)
mounts Guard and the marker together. Guard on its own now leaves a write route on an authsome
router closed to agents.

What changes for you: an agent integration that acted as its user on core write routes (profile
updates, sessions, devices, environments, webhooks, orgs, billing, consent, social linking and so
on) now gets 403. Move that work behind your own routes with GuardRoute and a scope on the grant,
or have the user's own session do it. If you mount authsome's plugins on a router yourself instead
of through the extension, pass them middleware.GuardDelegatedWrites(router), or their write routes
won't carry the rule. Human sessions, impersonation sessions and service accounts acting as
themselves see no change, and the OpenAPI spec is the same.

A new test enumerates every write route the router registers with all plugins loaded and sends a
real agent token to each: 152 routes, all 403 except the two agent-safe ones. Billing writes
answer with their own permission refusal first, since nobody holds manage:billing in the seeded
roles, and nothing runs either way. The review's proven cases (DELETE /v1/me, environments, orgs,
webhooks, billing plans) are checked one by one with nothing written, and the owner's own token
still works on them.
…sion's write guard

Review of 82f8a910 and 2852a273 found two holes and a few smaller things.

A session minted by token exchange could write through GuardRoute with no scope check. Authorize
only knew agent sessions; anything else without agent markers passed straight through, and
GuardRoute's agent-safe marker then let it past the deny-by-default interceptor. A real exchanged
token holding invoices:read got 200 on a GuardRoute("write", "invoice") route and the handler ran.
Authorize now treats every delegated session that isn't an agent's the way it treats an agent:
one of the session's own scopes has to be a scope registered with WithScope that covers the route,
and the user it acts for has to be allowed too. Exchange narrows those scopes to the delegation's,
so that's the grant here. No covering scope means 403. A service account that names a user is
delegated the same way and gets the same check; one acting as itself still passes.

Nothing tested the extension's real wiring. The deny-by-default table in api builds its own, so
taking GuardDelegatedWrites out of the extension left every test green. A new test boots the
extension with every route-serving plugin and checks the routes it registered: every write route
carries the delegated-write interceptor, none skips it, and the agent-safe marker sits on exactly
the allowlist. With the extension's two guards swapped for raw routers it reports 90 unguarded
routes.

The interceptor runs after route middleware, and forge doesn't expose the matched route any
earlier, so the refusal can't move ahead of it. The billing write routes were the only ones whose
permission check answered first, which told an agent whether its user held manage:billing. They
now put DenyDelegatedSession ahead of that check, the table test insists every refusal is the
delegated-session one, and GuardDelegatedWrites documents the rule for route middleware: change no
state, and keep permission checks behind the deny.

POST /v1/introspect is now agent-safe. It only reads, and it's a POST so the token travels in the
body. For the record, the public writes (sign-up, sign-in, password reset, email verification,
magic link, phone OTP, passkey login, social and SSO sign-in, the OAuth2 token, device and
registration endpoints, the waitlist join) answer 403 to a request that carries a delegated bearer
token, as they have since 2852a273.

ClientAuthMiddleware now logs a warning the first time an active introspection response has no
principal_kind. That's an identity server too old to report delegation, so the guard can't fire.
The README says a client-mode service is covered only on the routes it wraps in
GuardDelegatedWrites.

If you call a Guard'ed route with an exchanged token, register the delegation's scope names with
WithScope or that token now gets 403. Agents can call POST /v1/introspect again. Nothing else
changes for callers, and the OpenAPI spec is the same.
…re admin routes

Re-review of 85b96963 found 36 core write routes where the permission check still answered
before the delegated-session refusal: the admin, bulk, settings and app session-config and
client-config groups, plus environments and webhooks, which had no delegated deny of their own.
An agent acting for an unprivileged user got "insufficient permissions" there, and an agent acting
for an admin got the delegated refusal, so the answer told it what its user may do. That also
makes the previous commit wrong to say billing was the only route whose permission check
answered first.

In those five admin groups DenyDelegatedSession now runs right after RequireAuth and ahead of
RequirePermission. Environments and webhooks get a new middleware.DenyDelegatedWrites in the same
place. It refuses a delegated session only on POST, PUT, PATCH and DELETE, so their reads stay
open, and it leaves agent-safe routes to the interceptor (neither group has one).

The deny-by-default table test only ran as an agent for the platform owner, whose permission
checks always pass, so it couldn't see any of this. It now runs every write route three ways: an
agent for the owner, an agent for a user with no admin rights, and a token-exchange session for
that user. Every refused write has to answer with the delegated-session refusal. Before the
reorder the two unprivileged callers failed on 36 routes each.

The extension wiring test now checks every route authsome mounts, reads included, for the
interceptor. Root routes only serve GETs today, so mounting the root router without the guard
used to slip past it; now the well-known routes show up as unguarded.

GuardDelegatedWrites' doc and the agentauth README say what holds now: every authsome write
route refuses a delegated caller before any permission check, and the test proves it with an
unprivileged user. Nothing changes for human sessions or for the OpenAPI spec. A delegated
session that hit these routes still gets 403, just always the same one.
The seeded environment requires a verified email, so SignUp returns no session. Verify the address and sign in for the opaque org switch test and the OAuth2 JWT helper, which both assumed the sign-up session.
@juicycleff
juicycleff merged commit 169c9c8 into main Nov 3, 2026
21 checks passed
@juicycleff
juicycleff deleted the fix/agent-delegation-guards branch November 3, 2026 04:58
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