Skip to content

feat: mint access tokens with the negotiated scope - #28237

Draft
BobbyHo wants to merge 10 commits into
plat479-3-report-negotiated-scopefrom
plat480-1-apply-negotiated-scope
Draft

feat: mint access tokens with the negotiated scope#28237
BobbyHo wants to merge 10 commits into
plat479-3-report-negotiated-scopefrom
plat480-1-apply-negotiated-scope

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Until now the scope negotiated at /oauth2/authorize was recorded on the grant but never reached the token: every OAuth2 access key was minted with coder:all, so a client that asked for coder:workspaces.access still got full API access. This passes the stored scope into apikey.Generate, so the api_keys row 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_scope enum is a data problem, not a malformed request, so it returns invalid_scope with HTTP 400 rather than a 500.

Depends on #28179

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check 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.

@BobbyHo

BobbyHo commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-18 03:11 UTC by @BobbyHo

Review history
  • R1 (2026-08-18): 18 reviewers, 5 Nit, 7 Note, 1 P2, 1 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 0b7827e..3e18461

Last posted: Round 1, 14 findings (1 P2, 1 P3, 5 Nit, 7 Note), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open coderd/oauth2provider/tokens.go:510 Token response omits scope, hides narrowing from client (RFC 6749 §5.1) R1 Kite P2, Kurapika P3, Hisoka P3, Mafuuu P3 Yes
CRF-2 P3 Open coderd/oauth2provider/tokens.go:568 refreshTokenGrant silently drops req.Scope; widening refresh not rejected (RFC 6749 §6) R1 Kite P3, Hisoka Note, Mafuuu Note Yes
CRF-3 Nit Open coderd/oauth2provider/tokens.go:43 errUnstorableScope name inverts what happened; doc misses empty case; message reads server-facing R1 Knov Nit, Gon Nit, Leorio Nit, Kite Nit Yes
CRF-4 Nit Open docs/admin/integrations/oauth2-provider.md:393 Refresh bullet mixes RFC-compliant behavior with the real limitation R1 Leorio Nit, Meruem Note, Mafuuu Note, Hisoka Note Yes
CRF-5 Nit Open coderd/oauth2provider/tokens.go:66 %q wrapper emits " and \ bytes in error_description, restricted by RFC 6749 §5.2 R1 Kurapika Nit Yes
CRF-6 Nit Open coderd/oauth2provider/tokens.go:420 Three new comments duplicate rationale that already lives at the definition or the next line R1 Gon P2 (three sites, downgraded) Yes
CRF-7 Nit Open coderd/oauth2provider/tokens_test.go:259 authorizeCode duplicates authorizeQuery's query builder byte-for-byte R1 Robin Nit Yes
CRF-8 Note Open coderd/oauth2provider/tokens.go:58 Doc says CHECK "rejects the empty string"; CHECK rejects only exact '', whitespace-only passes R1 Pariston Note Yes
CRF-9 Note Open coderd/oauth2provider/tokens_internal_test.go:37 EveryCatalogNameMintable comment says "whole catalog" but iterates canonical names only R1 Razor Note Yes
CRF-10 Note Open coderd/oauth2provider/tokens.go:261 invalid_scope for unstorable stored scope stretches RFC 6749 §5.2 on the refresh path R1 Razor Note, Knov Note Yes
CRF-11 Note Open coderd/oauth2provider/tokens.go:451 rbac.ScopeAll writer-actor safety is comment-only; a named exchange-writer scope would be structural R1 Meruem Note Yes
CRF-12 Note Open coderd/oauth2provider/tokens_test.go:141 BackfilledScopeRefreshesUnrestricted names migration 000569 but does not exercise its backfill R1 Kite Note Yes
CRF-13 Note Open coderd/oauth2provider/tokens_test.go:203 dbgen.OAuth2ProviderAppCode/AppToken derive ExpiresAt from CreatedAt; every consumer will grow a seedCode copy R1 Knuckle Note Yes
CRF-14 Note Open docs/admin/integrations/oauth2-provider.md:392 Web-UI-registered apps still cannot declare a scope allowlist; operator has no lever for non-DCR clients R1 Luffy Note Yes

Contested and acknowledged

None yet.

Round log

Round 1

Panel. 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-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 scopefeat(coderd/oauth2provider): ... does not contain every changed file. coderd/database/modelmethods.go and docs/admin/integrations/oauth2-provider.md are outside coderd/oauth2provider/. This is exactly the rule .github/workflows/contrib.yaml (lines 191-220) enforces mechanically, and the title CI job is currently failing on it (run 95582966188). Drop the scope (feat: mint access tokens with the negotiated scope) or split; coderd alone still excludes docs/. Same defect on commit 3e1846174f test(coderd/oauth2provider): cover the negotiated scope end to end, which also modifies the docs page and rewrites the scopeStringToAPIKeyScopes docstring ("cover the negotiated scope end to end" is not what those lines do). Split or squash into the parent feat commit; the type has to name what is inside.
  • PR body placeholderDepends 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 on plat479-* and plat478-* 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.

Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/oauth2provider/tokens.go
Comment thread coderd/oauth2provider/tokens_test.go
Comment thread coderd/oauth2provider/tokens_test.go
Comment thread docs/admin/integrations/oauth2-provider.md
@BobbyHo BobbyHo changed the title feat(coderd/oauth2provider): mint access tokens with the negotiated scope feat: mint access tokens with the negotiated scope Aug 18, 2026

BobbyHo commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Replies to the two process items in the review body.

PR title CI. Stale. The cited 95582966188 is the title job in run 32094458280, on head 3e1846174f. The title has since been changed to feat: mint access tokens with the negotiated scope, dropping the scope, which is what CLAUDE.md asks for when a change spans multiple top-level directories (coderd/ and docs/). That job passes on the current head: job 95848701756 in run 32179416656.

On the commit you flagged: 3e1846174f is genuinely mis-scoped, it touches docs/admin/integrations/oauth2-provider.md. It is already pushed, and coder/coder squash-merges, so the message does not reach main. Leaving it rather than force-pushing the branch under review.

PR body placeholder. Fixed. Depends on #<plat479-3 PR> is now Depends on #28179 (plat479-3-report-negotiated-scope).

@BobbyHo
BobbyHo force-pushed the plat479-3-report-negotiated-scope branch from 0b7827e to 464debf Compare August 19, 2026 00:09
@BobbyHo
BobbyHo force-pushed the plat480-1-apply-negotiated-scope branch from 416050a to 98868ad Compare August 19, 2026 00:09
@BobbyHo
BobbyHo force-pushed the plat479-3-report-negotiated-scope branch from 464debf to 28ef5ba Compare August 19, 2026 22:43
BobbyHo and others added 6 commits August 25, 2026 17:04
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.
@BobbyHo
BobbyHo force-pushed the plat480-1-apply-negotiated-scope branch from 98868ad to fc6eb77 Compare August 25, 2026 17:07
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.
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.
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