Skip to content

feat: let an OAuth2 refresh narrow the granted scope - #28751

Draft
BobbyHo wants to merge 1 commit into
plat480-3-single-use-codefrom
plat481-1-narrow-refresh-scope
Draft

feat: let an OAuth2 refresh narrow the granted scope#28751
BobbyHo wants to merge 1 commit into
plat480-3-single-use-codefrom
plat481-1-narrow-refresh-scope

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

TL;DR

First of three in PLAT-481, the last phase of PLAT-470. POST /oauth2/tokens parsed the refresh request's scope and discarded it, so a client asking for less was silently handed the broader token it tried to give up. A refresh may now narrow, and may still not widen.

PR What it does
#28237 The code exchange mints from the negotiated scope, and a refresh inherits the grant instead of widening back to coder:all.
#28740 Redemption re-checks the code's scope against the app's current allowlist. Refresh deliberately does not.
#28744 Code redemption is single-use under concurrency.
this A refresh may name a narrower scope. It is minted, persisted, and stated back per RFC 6749 §5.1.
  • Diff is against fix: make OAuth2 code redemption single-use under concurrency #28744, not main.
  • Resolves Open Question 7 as coverage, not membership. Membership would leave the narrowing unreachable for most apps: coder:all covers every scope by wildcard but is a member of no set but its own, and every app without a configured allowlist holds exactly that grant, so those clients could never ask for less. Coverage also reuses the authorize-side comparison rather than introducing a third.
  • Nothing widens. RFC 6749 §6 bounds the request by the scope originally granted, and an omitted scope still keeps the grant as it stands.

The narrowing (narrowGrantedScope, tokens.go)

  • Catalog-checks each requested name before the coverage comparison. Coverage does not reject an unknown name for free the way membership did: it would reach rbac.ExpandScope, fail there, and tell a client that typo'd a scope that the server could not decide, rather than that the name is unknown.
  • Returns the string to persist rather than an APIKeyScopes, so scopeStringToAPIKeyScopes converts forward for apikey.Generate and nothing converts backward. The rbacScopesToStrings helper the plan called for is not needed.
  • Runs before GetAPIKeyByID, so a rejection costs no further reads and issues nothing. The refresh token stays redeemable.
  • firstScopeOutsideAllowlist becomes firstScopeNotCovered and takes its ceiling explicitly. On refresh the ceiling is the token's own grant, so an app_scope log field would have named something the comparison never read.

Tests

  • TestNarrowGrantedScope: a genuine subset, a composite narrowed to a constituent it covers, coder:all narrowed to a real scope (the case coverage exists for), an expansion rejected, an unknown name rejected as unknown rather than undecidable, an omitted request left untouched.
  • RefreshNarrowsTheScope asserts all three of the minted key, the persisted oauth2_provider_app_tokens.scope column and the response scope. The next refresh inherits that column, so a widened one would undo the narrowing.
  • RefreshCannotWidenTheScope asserts the 400 and that the original token is still redeemable.
  • ResponseStatesTheScopeGranted pins RFC 6749 §5.1 for both an omitted-scope exchange and a narrowed refresh, which no test asserted on purpose before.

Docs: the Limitations bullet saying a client cannot narrow on refresh is now false and is deleted; the token-endpoint invalid_scope section documents the narrowing and both rejection wordings.

Stack: #28237, #28740, #28744, this PR.

RFC 6749 section 6 bounds a refresh by the scope originally granted, so
the scope parameter may only give authority up. A refresh that names a
scope now mints and persists that narrower scope, and later refreshes
are bounded by it in turn; an omitted parameter keeps the grant as it
stands.

The comparison is coverage rather than membership, matching
negotiateScope, so a grant of coder:workspaces.access can narrow to
workspace:ssh and an unrestricted coder:all grant can narrow at all. A
request beyond the grant is refused with invalid_scope before anything
is read or issued, leaving the refresh token usable.
@github-actions

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.

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