feat: mint access tokens with the negotiated scope - #28237
Conversation
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 14 findings (1 P2, 1 P3, 5 Nit, 7 Note), COMMENT. Review Finding inventoryFindings
Contested and acknowledgedNone yet. Round logRound 1Panel. Netero clean at first pass (Nit on PR title only). 16 trigger-matched reviewers + Luffy and Knuckle as wildcards. 1 P2, 1 P3, 6 Nit, 7 Note new. No dropped findings. Reviewed against 0b7827e..3e18461. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
This PR closes a real gap: the scope negotiated at /oauth2/authorize is finally what the minted api_keys.scopes carries, and refresh no longer widens a narrow token to coder:all on hour two. The tests assert against the api_keys row rather than the token response, and IssuedTokenBoundsTheAPI drives the issued token through dbauthz against a real API call, which is where the assertion actually earns its keep. Removing the two TODO: We are ignoring scopes for now. comments together with the fix that makes them true is the shape of the change I want to see.
One fun observation from Luffy: "A client asked for workspace:ssh, walked out with coder:all, and the consent page had already told the user 'you're granting workspace:ssh.' That is worse than having no scope system at all: users saw a narrow grant, clients believed they had a narrow grant, dbauthz saw an unrestricted one."
Severity: 1 P2, 1 P3, 6 Nit, 7 Note. No P0/P1 on the code itself; the review is a COMMENT rather than REQUEST_CHANGES. The most consequential finding is that the OAuth2 token response omits the scope field even though the granted scope now routinely differs from the request. Fixing it is a one-line addition on the same struct literals that already write dbCode.Scope / dbToken.Scope to the DB. Details inline.
Process observations, kept in the body so they do not clutter the diff:
- PR title scope —
feat(coderd/oauth2provider): ...does not contain every changed file.coderd/database/modelmethods.goanddocs/admin/integrations/oauth2-provider.mdare outsidecoderd/oauth2provider/. This is exactly the rule.github/workflows/contrib.yaml(lines 191-220) enforces mechanically, and thetitleCI job is currently failing on it (run 95582966188). Drop the scope (feat: mint access tokens with the negotiated scope) or split;coderdalone still excludesdocs/. Same defect on commit3e1846174f test(coderd/oauth2provider): cover the negotiated scope end to end, which also modifies the docs page and rewrites thescopeStringToAPIKeyScopesdocstring ("cover the negotiated scope end to end" is not what those lines do). Split or squash into the parentfeatcommit; the type has to name what is inside. - PR body placeholder —
Depends on #<plat479-3 PR>shipped unfilled. A reviewer cannot tell whether the depended-on PR exists, is merged, or is the reason the change is safe to merge without asking, and the base branch is stacked onplat479-*andplat478-*so the actual dependency is knowable. Wire in the number or drop the line before merge; it will otherwise render as literal text in the merged commit body.
On the code itself, the analysis by the panel converges on the same story: the scope now really binds the token, but the client-facing signal that it binds anything (the scope field in the token response) still is not there. That is what makes CRF-1 the load-bearing finding for a follow-up, and CRF-2 the smaller sibling that goes with it.
🤖 This review was automatically generated with Coder Agents.
|
Replies to the two process items in the review body. PR title CI. Stale. The cited On the commit you flagged: PR body placeholder. Fixed. |
0b7827e to
464debf
Compare
416050a to
98868ad
Compare
464debf to
28ef5ba
Compare
Authorization has negotiated and persisted a scope since "negotiate and persist authorization scope", but the token exchange ignored it: apikey.Generate was called with no scopes, which defaults to coder:all. Every token issued therefore carried full account authority regardless of what the consent page told the user they were approving. The refresh grant had the same gap against a column that always held the right value, so even a correctly scoped key widened to coder:all on its first refresh. Convert the scope stored on the code, and on the refresh row, into the scope list the key is minted with. The names are checked against the api_key_scope enum here rather than left to apikey.Generate, which returns a bare error the grant surfaces as a 500. That is the wrong answer for a value read back out of the database: the request is well-formed and the deployment's data is not. errUnstorableScope is returned instead and Tokens() maps it to invalid_scope, naming the offending scope so an operator can find the row. An empty list is rejected for the same reason expandRBACScope rejects one: empty is what apikey.Generate reads as unrestricted, so accepting it would widen a grant rather than fail it. Apps with no allowlist negotiate the unrestricted sentinel and still mint coder:all, so nothing narrows for a deployment that has not configured one. The actor both grants fetch stays rbac.ScopeAll. It is the writer of the exchange's own rows, not the grant the key carries: scope is a hard constraint in the policy, so narrowing that actor would deny api_key:create and fail every exchange. What bounds the issued token is api_keys.scopes, read back on each request by key.ScopeSet().
Assert the issued access token against the live API rather than only against the api_keys row: coder:workspaces.access reads a template and is refused the deletion. Drive every name the scope catalog offers through the conversion so a name added to the catalog but not the api_key_scope enum fails here instead of leaving a client holding an unredeemable code. Refresh a token row seeded the way migration 000569 leaves a pre-existing grant to confirm the backfilled coder:all still means unrestricted. Document the scope parameter, the registration-time allowlist, and the two limitations that remain: only Dynamic Client Registration can declare an allowlist, and a scope parameter on refresh is ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RFC 6749 §5.1 makes the scope parameter REQUIRED in the token response whenever the granted scope differs from what the client requested. Neither grant sets it, so a client whose request was narrowed against the app's allowlist, or whose empty request was defaulted to the allowlist or to the unrestricted sentinel, cannot tell what authority the token it just received actually carries. It finds out as an unexplained 403 later. Both grants already hold the value: the authorization code path writes dbCode.Scope onto the refresh row, and the refresh path carries dbToken.Scope onto its replacement.
The sentinel called the scope "unstorable", but storing it is exactly what already happened; what fails is minting a key from it. Rename it to errUnmintableScope and drop "stored" from the message it puts on the wire. Its doc described only the enum-outside branch even though the empty branch returns it too. Both that doc and the test comment justified the empty case with the CHECK (scope <> '') constraint, which does not reject the whitespace-only input the test exercises. Justify it by the writers instead, which is what actually holds. Format the offending name with '%s' rather than %q. RFC 6749 section 5.2 limits error_description to %x20-21 / %x23-5B / %x5D-7E, which excludes the double quote %q emits, and this value reaches the client through that field. State what the refresh path does with the scope rather than narrating what it used to do without it.
…Query authorizeCode rebuilt authorizeQuery's body so it could keep the PKCE verifier the exchange redeems the code with. Call authorizeQuery and override the challenge instead, which is the extension point its doc already names, so the authorize query shape lives in one place. EveryCatalogNameMintable drove ExternalScopeNames, which returns canonical names only. The aliases IsExternalScope also accepts, "all" and "application_connect", are not api_key_scope members and so never reached the conversion the subtest exists to guard. Drive them too, spelled through CanonicalScopeName the way authorization spells them before persisting, and assert each name in the loop is one IsExternalScope accepts.
The Limitations bullet listed the refreshed token keeping its original scope as a limitation, which RFC 6749 section 6 mandates and the Scopes section already states as expected behavior. State only the part that is limited: the client cannot narrow on refresh.
98868ad to
fc6eb77
Compare
The explanatory blocks over the scope tests restated what the assertions already show. Drop the godoc-style headers on the test functions and keep only what a reader cannot derive from the code: the permissions coder:workspaces.access actually carries, the migration that backfilled coder:all, and why dbgen cannot seed rows whose expiry matters. Two subtests now carry their intent in the name instead of a preamble: RefreshKeepsTheGrant becomes RefreshDoesNotWidenTheScope, and UnknownNameRejected becomes UnknownNameRejectsTheWholeList. The RFC 6749 section 5.1 rationale moves onto the assertion message, so it prints on failure rather than only being readable in the source. The production comments keep every load-bearing claim, compressed: why names are checked before apikey.Generate, why an empty list is an error, and why the exchange actor stays ScopeAll. No behavior change.
…-negotiated-scope
The authorize comment and the integration guide both still said the negotiated scope was recorded but not enforced. This PR mints the API key with it, so that is no longer true. Also document the invalid_scope the token endpoint now returns when a stored scope cannot be minted.
The handler returns codersdk.OAuth2TokenResponse, but the annotation said oauth2.Token, so the API reference showed no scope field on the endpoint this PR starts filling it for.
Until now the scope negotiated at
/oauth2/authorizewas recorded on the grant but never reached the token: every OAuth2 access key was minted withcoder:all, so a client that asked forcoder:workspaces.accessstill got full API access. This passes the stored scope intoapikey.Generate, so theapi_keysrow that dbauthz reads on every request carries what was actually granted.The token response now states the granted scope, which RFC 6749 section 5.1 requires whenever it differs from what the client requested. Without it, a client whose request was narrowed against the app's allowlist, or defaulted because it requested nothing, first learns its bounds from an unexplained 403.
A refresh keeps the scope the grant was issued with, per RFC 6749 section 6. A scope persisted on a row that is not in the
api_key_scopeenum is a data problem, not a malformed request, so it returnsinvalid_scopewith HTTP 400 rather than a 500.Depends on #28179