Repository navigation
fix(agentauth)!: stop agent sessions from writing as their user - #145
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/meand 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 newPlugin.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 themmiddleware.GuardDelegatedWrites(router). Human sessions, impersonation and service accounts acting as themselves see no change.What's in here, commit by commit:
org_idandenv_id. Switching org mints a new token, refresh re-checks membership, and an agent session can't switch org at all.principal_kindanddelegated, so client mode can apply the same rule.middleware.GuardDelegatedWrites) on the router groups. OnlyPOST /v1/signoutandPOST /v1/oauth/revokeare agent-safe; mark a route of your own withplugin.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/meamong 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.gokeeps 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.