feat: report the negotiated scope to the user and the client by BobbyHo · Pull Request #28179 · coder/coder · GitHub
Skip to content

feat: report the negotiated scope to the user and the client - #28179

Merged
BobbyHo merged 70 commits into
mainfrom
plat479-3-report-negotiated-scope
Sep 1, 2026
Merged

feat: report the negotiated scope to the user and the client#28179
BobbyHo merged 70 commits into
mainfrom
plat479-3-report-negotiated-scope

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Last of the stack, split out of #28045. #28178 decided what the scope is; this PR is everything that reports it, to the user on the consent page and to the client on rejection.

PR What it does
#28007 Schema: codes.scope and tokens.scope, so a negotiated scope has somewhere to live. Every code is still stamped unrestricted.
#28167 ScopesCover compares an allowlist against a request by permission coverage rather than by name, and fails closed on any shape it does not model. Added with no production callers.
#28178 The authorize endpoint checks the request against the scope catalog, grants it only if the app's filtered allowlist covers it, and persists the result on the code.
#28179 (this) The consent page lists the negotiated permissions, and invalid_scope reaches the client's own callback per RFC 6749 §4.1.2.1 instead of a Coder error page. Retires the interim plumbing #28178 left behind.
  • feat: negotiate and persist authorization scope #28178 has merged, so this diff is against main and reviews on its own. It is still worth reading first: nothing here decides a scope, it only reports the one negotiateScope already returns.
  • The consent page HTML is not final. Layout and wording in site/static/oauth2allow.html are still being refined. It is in this branch so the scope-enforcement flow can be exercised end to end in a browser, not because the markup is settled. The reviewable part of Half A is the Go side that decides what the page states; the presentation will change.
  • An unrestricted grant still reads as full access, not as a scope name. Nothing about what a token can do changes in this PR either.

Where in the flow

  • Authorize endpoint only, both verbs. GET decides what the consent page states; both verbs send invalid_scope to the callback. The token exchange, refresh, and revocation are untouched.
  • negotiateScope is unchanged from feat: negotiate and persist authorization scope #28178, so which requests succeed is unchanged. This PR reads its result twice: once to decide what the page lists, once to decide where the rejection goes.
  • The endpoint sits behind apiKeyMiddlewareRedirect, so the user is authenticated before any of this runs. That matters for the redirect, not just for the page.
  • Consent page rendering moves a decision into Go rather than the template: RenderOAuthAllowData carries Scopes and Unrestricted separately, and the template branches on Unrestricted alone.

What it satisfies

  • RFC 6749 §4.1.2.1 — on a failure that is not a bad redirection URI, the server "informs the client by adding the following parameters to the query component of the redirection URI". invalid_scope now goes there. feat: negotiate and persist authorization scope #28178's interim delivery, a static error page on GET and an error body on POST, is what this replaces.
  • RFC 6749 §4.1.2.1 — state is "REQUIRED if a state parameter was present in the client authorization request", so it is echoed only when the client sent one, never synthesized. Pinned by OmittedStateNotEchoed.
  • RFC 6749 §3.1.2.4 and §4.1.2.1 — on a "missing, invalid, or mismatching redirection URI" the server "MUST NOT automatically redirect the user-agent to the invalid redirection URI". This is the whole ordering argument, and the file's other error paths keep their existing delivery precisely because several of them are where that validation fails.
  • RFC 9700 §2.1 — pre-registered redirection URIs "MUST utilize exact string matching". extractAuthorizeParams already did this before the stack; Half B depends on it rather than adding it.
  • RFC 9700 §4.11.2 — names this exact hazard: an attacker who registers a client can "intentionally send an erroneous authorization request, e.g., by using an invalid scope value, thus instructing the authorization server to redirect the user agent to its phishing site". The destination is still only ever a URI that client registered, so this is a phishing surface that costs an attacker a registration, not an open redirect. The precaution the section requires, authenticating the user first, is what apiKeyMiddlewareRedirect provides.
  • RFC 9700 §4.12 — a server redirecting a request that may carry credentials "MUST NOT use the HTTP 307 status code". 302, matching the success redirect beside it.
  • RFC 6749 §4.1.2.1 — server_error exists "because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect". An unusable registered callback is the one case with nowhere to redirect, so it answers 500 on both verbs and logs the app and URL for an operator to correlate by.
  • Not yet: §1.4.1 still wants scope in the token response when the grant differs from the request. Tokens are untouched here, as in feat: negotiate and persist authorization scope #28178, so that lands with enforcement.

Split out of #28045 (PLAT-479). Last of three. #28178 has merged, so this reviews against main.

#28178 decided what the scope is. This PR is everything that tells someone about it.

Add ScopesCover, which reports whether every permission a requested scope
grants is also granted by at least one of a set of allowed scopes. It
expands both sides and compares the resulting permissions, so
coder:workspaces.access covers workspace:read even though it never names
it, and coder:all covers everything.

The comparison is deliberately asymmetric. Positive permissions on the
allowed side that it does not model are dropped, which can only make the
answer stricter. Anything unmodelled on the requested side is an error
instead, because ignoring it would answer "covered" about authority that
was never compared. Negative permissions are the exception and fail closed
on both sides, since dropping an anti-grant from the ceiling would widen
it rather than narrow it.

Add CanonicalScopeName, which maps the backward-compatibility aliases
IsExternalScope accepts onto the names the api_key_scope enum stores.
IsExternalScope answers whether a name may be requested, not how that name
is spelled once persisted, so a caller that stores what it validated has
to canonicalize in between.

Both functions are added without production callers. The OAuth2 authorize
endpoint uses them to negotiate a requested scope against an app's
configured allowlist, which follows in a separate change.
@BobbyHo BobbyHo changed the title feat(coderd/oauth2provider): report the negotiated scope to the user and the client feat: report the negotiated scope to the user and the client Aug 15, 2026
BobbyHo and others added 3 commits August 17, 2026 11:34
State the rule the guards enforce, site-level grants only, instead of
describing the asymmetry abstractly. The allow-list case is now covered
alongside negative permissions, which the previous wording omitted even
though the code treats them identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BobbyHo and others added 6 commits August 18, 2026 22:32
ScopesCover checked the requested scope for org and user grants but not
the allowed scopes, whose User and ByOrgID permissions were discarded
unread. A scope granting workspace:* at site level while negating
workspace:delete for the user would have covered a request for
workspace:delete, because the negative that carves the action back out
lives in the half coverage never examined.

No catalog scope populates those fields today, so nothing was
miscompared in practice. The gap mattered because these guards exist to
keep the comparison fail-closed, and this one failed open.

Both sides now run the same checkCoverable helper, which refuses a scope
carrying org or user grants, a negative permission, or a resource allow
list. The helper names the side, so an error reports which half of the
comparison was undecidable. The doc comment claimed an unmodeled grant
on the allowed side is dropped; nothing is dropped now, so it is gone.

ScopesCover builds every Scope it reads from ExpandScope, which cannot
produce these shapes, so the guards are unreachable through the public
API. scopes_internal_test.go drives synthetic Scope values through
checkCoverable instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
permissionCovered skipped negative permissions, but checkCoverable now
refuses a scope carrying one on either side, so the branch was dead. It
was never defense in depth. Had a negative reached it, skipping the
anti-grant would leave any wildcard beside it free to match, and a scope
granting workspace:* while negating workspace:delete would report
workspace:delete as covered. The skip widened the ceiling while looking
like it narrowed it.

The precondition moves to the doc comment, which names checkCoverable as
what enforces it and says why subsumption cannot answer the question an
anti-grant poses.

No behavior change: the branch was unreachable. permissionCovered goes
from 88.9% to 100% statement coverage.
Five review findings on the coverage tests, all in scopes_test.go.

CanonicalScopeName had both alias arms at zero coverage. Its only caller
in the tests loops over ExternalScopeNames, which yields canonical names
only, so the canonicalizing call returned its input unchanged on every
iteration and read as coverage without being any. Swapping the arms, so
that `all` persisted application_connect and the reverse, kept the suite
green. TestCanonicalScopeName now pins the mapping and the loop appends
the aliases, taking the function from 50% to 100%.

The appended aliases raise branch coverage and assert a requestable name
is comparable once canonicalized, but they cannot detect a swapped
mapping, since both aliases resolve to scopes that cover themselves. The
comment says so rather than implying the loop guards more than it does.

CompositeDoesNotCoverNonMember and
CompositeDoesNotCoverWiderActionOnCoveredResource both asked for an
ungranted action on a resource coder:workspaces.access does grant, so
they tested one branch twice and left "resource not granted at all"
untested. They are now split along that line, with names that describe
which failure each one is.

The three wantErr rows shared a bare require.Error, so any error passed
any row and a bug failing every input on the requested side would have
left the allowed-side row green. wantErrContains replaces the bool and
names the side. Rewording the allowed-side message as the requested-side
one now fails three rows that previously all passed.

Alias rejection was tested for one alias on one side. Both aliases are
now tested on both sides. The allowed-side rows are the ones that earn
their place: they are what would catch someone canonicalizing inside the
allowed loop and widening the contract without a caller asking.
ScopesCover expanded and compared in a single pass, so the invariant
guards only ever ran on scopes ExpandScope had produced. Every such scope
satisfies them, which left the guards unverified in the position that
matters: the existing test called checkCoverable directly and could not
tell whether ScopesCover consulted it on both sides, or at all.

Split the comparison into scopesCoverExpanded, which takes already
expanded scopes paired with the names they came from. Tests drive
synthetic Scope values through it, so dropping the guard from either side
now fails, as does an allowed scope that grants every workspace action
except delete answering a request for delete.

Expanding every allowed scope before any guard runs reorders two error
paths against each other: a requested scope that fails a guard alongside
an unknown allowed name now reports the expansion failure rather than the
guard failure. Both return (false, error), and no ScopeName reaches that
combination today.
…ontract

The knowledge of which spellings are backward-compatibility aliases lived
in two switches, one in IsExternalScope and one in CanonicalScopeName,
kept in step by discipline. Drift between them is asymmetric: a name the
first accepts and the second does not rewrite is declared public and then
fails to expand on every request naming it. Both now read one table, so
they agree by construction, and an internal test walks that table
asserting each alias is public, resolves to a public name, and resolves
to one ExpandScope accepts. A third alias is covered the day it is added.

ScopesCover stated "names must be canonical" in prose only, which is
wrong for exactly the two inputs IsExternalScope accepts and ExpandScope
does not. The parameters are now canonicalAllowed and canonicalRequested,
so the requirement shows up in editor hints at every call site rather
than only in a doc comment the caller may not have opened.

Naming the parameters was chosen over canonicalizing inside ScopesCover.
The single downstream caller already canonicalizes both sides in bulk
before comparing, so absorbing the step would remove nothing from it
while dissolving the distinction between a public spelling and a stored
one at the layer that should hold it.
…roken

The site-only, wildcard-allow-list, no-negatives invariant was described
on ScopesCover and enforced by its guards, but ExpandScope, which is what
produces those values, had no doc comment at all. Someone adding a scope
reads ExpandScope and its neighbors; nothing there warned that populating
User or adding a negative makes the scope uncomparable. State it there,
along with the canonicalization requirement, and name the consequence
rather than just the rule.

Also note on ScopesCover that a wildcard request needs a wildcard grant.
Enumerating today's concrete actions genuinely is narrower than
`workspace:*`, so the rejection is intended. The
OneActionDoesNotCoverResourceWildcard row already pins the behavior; the
note stops the next reader of an authorize endpoint from taking it for a
bug and closing the gap.

Comments only. Checked that the documented invariant actually holds for
all three builtin scopes and all seven composites.
@BobbyHo
BobbyHo force-pushed the plat479-2-negotiate-scope branch from cdac751 to c75a5fd Compare August 19, 2026 00:09
@BobbyHo
BobbyHo force-pushed the plat479-3-report-negotiated-scope branch from 0b7827e to 464debf Compare August 19, 2026 00:09
BobbyHo and others added 12 commits August 18, 2026 17:28
TestScopesCoverAllowedNegativeDoesNotWiden drove the same scope shape as
the NegativeUserPermission row of TestScopesCoverGuards, but asserted only
that some error came back. The row asserts the message, the side it names,
and that the comparison reports no coverage, and it runs the shape on both
sides rather than one. The weaker copy could pass on a regression that
returned the wrong error or stopped naming the side. Fold the scenario it
documented into the row's comment and drop the copy.

Rename the shared permission fixtures after the value they hold. The site
prefix read as "belongs in Role.Site", while two of the three are placed in
Role.User to build the shapes the guards refuse, and the No suffix gave no
hint that it means Negate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The allowed-side wrap printed the scope name and then wrapped an error that
prints it again, so the two sides of one comparison read differently:

  expand allowed scope "foo": no scope named "foo"
  expand requested scope: no scope named "foo"

Drop the redundant verb and let the inner error carry the name on both
sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docstring said the list includes the `all` and `application_connect`
special scopes. It appends ScopeAll and ScopeApplicationConnect, which are
the `coder:` spellings, so the bare aliases are absent. Two callers already
compensate by appending them by hand, one of them with a comment stating
the mismatch. Describe what the function returns and name the helper that
bridges the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The invariant that expansion populates Site only was stated in full on
ExpandScope, checkCoverable and ScopesCover, and the "everything except
delete" example appeared on checkCoverable and again on permissionCovered
twenty lines below. State it once on ScopesCover, which is the function
whose behaviour depends on it, and cross-reference from the other two. Drop
framing that ranked implementation choices nobody proposed, and cut the two
test comments down to the facts the assertions do not already carry.

Kept in full: what each guard in checkCoverable defends, since no other
comment says it, and the wildcard rule on ScopesCover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
checkCoverable said a negative site permission would be skipped, naming
a branch permissionCovered no longer has. A negative reaching it matches
on resource type and action like any other grant, so the anti-grant
would read as a grant. Name that instead, so the cross-reference lands
on a doc that matches the code.
The docstring listed the aliases and the low-level scopes, omitting the
curated composites the function also accepts. A caller consulting it to
decide whether coder:workspaces.access is public read no from the doc
and yes from the code.
ExternalScopeNames promises it offers each scope under one canonical
spelling, and no test held it to that. TestScopesCoverEveryExternalScope
appended the two aliases, but canonicalized them back into names the
list already carries, so it re-ran assertions the list iteration had
made and left the promise itself unpinned.

Assert on the alias table instead: the list omits the alias and offers
its canonical target. Every offered name is already proven coverable, so
the aliases inherit coverage, and a third alias inherits both invariants
the day it is added rather than needing a third hardcoded pair here.
The authorize endpoint parsed the scope parameter and discarded it, so an
app's configured allowlist never restricted anything and a client asking
for more than it should get was never told no. Phase 1 added the columns
that carry a negotiated scope from a code to the token it becomes, but
nothing wrote one, so every code was stamped unrestricted.

Negotiate the scope at authorization time and persist the result:

- Requested names must be in the external scope catalog, and the app's
  stored allowlist is filtered through that same catalog. Filtering only
  ever narrows what can be granted.
- The allowlist bounds authority, not spelling. A request is granted when
  every permission it grants is also granted by the allowlist, whether or
  not the allowlist names it, so an app allowed coder:workspaces.access
  can approve a client asking only for workspace:ssh.
- Omitting scope grants the filtered allowlist, per RFC 6749 section 3.3.
- Both handlers negotiate, so a request that cannot succeed fails before
  the consent page renders rather than after the user clicks Allow. Each
  reports the failure the way it already reports its own errors: a static
  error page on the GET side, an OAuth2 error body on the POST side.
- Two paths produce an empty result and are deliberately distinct. No
  allowlist and no request keeps the previous unrestricted grant, written
  as an explicit sentinel because the column is NOT NULL with a non-empty
  CHECK. An allowlist that filters to nothing is rejected, since falling
  back would grant strictly more than the allowlist ever permitted.

Dynamic client registration performs no catalog validation, so apps
registered with scopes such as openid or admin hold allowlists this
server cannot grant from. They now fail authorization in both directions.
Grandfathering unknown names would seed the enforcement path with values
it cannot evaluate, trading a visible negotiation-time error for a silent
enforcement-time hole. The failure names the registered scopes and the
remedy.

Issued tokens are still unrestricted: the exchange copies the negotiated
scope onto the token record, but the API key it mints carries no scope.
This changes which authorization requests succeed, not what a token can
do.
The consent page told every user the app was getting full access to their
account, which stopped being true once the authorize endpoint began
negotiating a narrower scope. A user approving a request has no other place
to learn what they are handing over, so the page has to follow the grant
rather than a fixed sentence.

List the negotiated permissions when the grant is bounded, and keep the
original full-access wording when it is not. An unrestricted grant is
reported as full access rather than as "coder:all", since the scope name
tells a user less than the sentence does. The list collapses to the
full-access wording whenever the unrestricted scope is present, not only
when it stands alone: an allowlist registered as `coder:all
coder:workspaces.access` grants everything, and naming the narrower entry
beside it would describe the grant as bounded.

role="list" and role="listitem" are explicit because WebKit drops the
implicit list semantics from a list styled with list-style: none, which
would otherwise leave VoiceOver announcing the permissions as loose text.

Also narrow the fragment the tests match for one rejection branch. The GET
side renders its description into HTML, which escapes the apostrophe in
"this app's allowed scope list", so the fragment stops before it.
…back

A rejected authorization request answered on Coder, which reaches only the
user's screen. The client's error handling never ran, and the state it sent
was dropped, so it could not correlate the failure with the request that
caused it. RFC 6749 section 4.1.2.1 requires the error be delivered to the
client's redirect URI once the client is known.

Redirect to the app's registered callback with error, error_description,
and the state exactly as it arrived. Both handlers use this, replacing the
static error page on the GET side and the OAuth2 error body on the POST
side.

This is safe here specifically because of ordering: extractAuthorizeParams
exact-matches the redirect URI against the app's registered callback, and
it runs before the scope check, so the destination is the app's own no
matter what the request carried. Only errors raised after that point may
use this helper, which its precondition states. Errors from
extractAuthorizeParams itself must not, since the URI is unvalidated
there. MismatchedRedirectURINotRedirected pins the ordering: an
unregistered redirect_uri fails on Coder with no Location header on either
verb, even when the same request also carries a scope the app cannot be
granted.

The other error paths in this file are unchanged, since several of them are
where redirect URI validation fails.
@BobbyHo
BobbyHo force-pushed the plat479-2-negotiate-scope branch from c75a5fd to ca4dc52 Compare August 19, 2026 22:43
@BobbyHo
BobbyHo force-pushed the plat479-3-report-negotiated-scope branch from 464debf to 28ef5ba Compare August 19, 2026 22:43
permissionCovered could drop its action comparison and the suite stayed
green: no ScopeName expands to {*, <specific action>}, since the wildcard
entry in policy.RBACPermissions carries no actions and coder:all is the
only wildcard resource the catalog spells. Reach the shape through
scopesCoverExpanded instead, with a positive control so the case fails on
the action rather than on resource matching, and a mirror pinning that a
single-resource grant does not cover a request for every resource.

Every allowed-side error row named the bad scope as the only entry, so an
implementation that answers as soon as one entry covers the request never
reached it. Add a row where the bad name sits behind coder:all, the only
row that fails when ScopesCover expands inside the comparison loop rather
than up front.
Comment thread site/static/oauth2allow.html
html, body used height: 100% with align-items: center, so a page taller
than the viewport overflowed past both edges while scroll origin stayed
at the top. With 56 scopes at a 696px viewport, 453px was clipped above
the fold and the "Authorize <app>" heading was unreachable at every
scroll offset, leaving the user able to click Allow without ever seeing
which application they were authorizing.

min-height: 100% drops the clipping to 0 and leaves short-page centering
unchanged.
@linear-code

linear-code Bot commented Aug 27, 2026

Copy link
Copy Markdown

BobbyHo added a commit that referenced this pull request Aug 31, 2026
**TL;DR**

Middle of the stack, split out of #28045, that makes an OAuth2 app's
scope allowlist actually restrict what a client can be granted. The
authorize endpoint has always parsed `scope` and then thrown it away.

| PR | What it does |
|---|---|
| #28007 | Schema: `codes.scope` and `tokens.scope`, so a negotiated
scope has somewhere to live. Every code is still stamped unrestricted. |
| #28167 | `ScopesCover` compares an allowlist against a request by
permission coverage rather than by name, and fails closed on any shape
it does not model. Added with no production callers. |
| **#28178 (this)** | The authorize endpoint checks the request against
the scope catalog, grants it only if the app's filtered allowlist covers
it, and persists the result on the code. Both GET and POST negotiate, so
a doomed request fails before the consent page renders. |
| #28179 | The consent page states what was negotiated, and
`invalid_scope` is delivered to the client's own callback per RFC 6749
§4.1.2.1 instead of a Coder error page. |

- Each earlier PR is inert alone: the columns carry a placeholder, the
coverage rules have no caller.
- Visible on upgrade, unlike the rest of the stack. A scope name outside
the catalog, OIDC vocabulary included, now gets `invalid_scope`, on
admin-created apps as well as DCR ones.
- Tokens are unaffected. This changes which authorization requests
succeed, not what a token can do.

**Where in the flow**

- Authorize endpoint only, both legs: GET before the consent page
renders, POST before the code is minted. The token exchange, refresh,
and revocation are untouched.
- The result is persisted on the authorization code. The exchange copies
it onto the token row, but the API key it mints still carries no scope,
so nothing is enforced at request time yet.
- Registration and discovery are unchanged. The catalog checked against
is the `scopes_supported` both discovery documents already advertise.

**What it satisfies**

- OAuth 2.1 §1.4.1 — the server "MAY fully or partially ignore the scope
requested by the client, based on the authorization server policy". The
app's allowlist is that policy; until now the server ignored scope in
every direction.
- OAuth 2.1 §1.4.1 — on an omitted scope it "MUST either process the
request using a pre-defined default value or fail the request". The
filtered allowlist is that default; an allowlist filtering to nothing
takes the second branch. Same rule as RFC 6749 §3.3.
- OAuth 2.1 §1.4.1 — scope values "are defined by the authorization
server", and OIDC is named there as an extension that defines its own.
Hence `invalid_scope` for openid/profile/email rather than silent
acceptance.
- OAuth 2.1 §4.1.2.1 — `invalid_scope` is "invalid, unknown, or
malformed", which now covers an uncatalogued name and an over-broad one
alike. Delivery is interim here (static page on GET, error body on
POST); §4.1.2.1 wants it redirected to the client's callback, which is
#28179.
- OAuth 2.1 §4.1.1 — `scope` stays OPTIONAL. Omitting it selects a
default, never an error.
- RFC 7591 §2 — a registered `scope` is the list "the client can use
when requesting access tokens", a ceiling rather than a menu. Enforced
here for the first time; a client that registered an unsupportable one
repairs it via RFC 7592.
- RFC 8414 §2 — `scopes_supported` is that catalog, advertised since
before this stack, so a conformant client had no grounds to send a name
outside it.
- Not yet: §1.4.1 also requires `scope` in the token response when the
grant differs from the request. Tokens are untouched here, so that lands
with enforcement.

---

Split out of #28045
([PLAT-479](https://linear.app/codercom/issue/PLAT-479)). #28167 has
merged, so this diff is against main and reviews on its own.

The authorize endpoint parsed scope and threw it away, so an app's
allowlist never restricted anything and a client asking for too much was
never told no. Phase 1 added the columns that carry a negotiated scope
from a code to the token, but nothing wrote one.

**What changes**

- Requested names must be in the external scope catalog. The app's
stored allowlist is filtered through that same catalog, which only ever
narrows.
- The allowlist bounds authority, not spelling. A request is granted
when every permission it grants is also granted by the allowlist, named
or not, so an app allowed coder:workspaces.access can approve a client
asking only for workspace:ssh.
- Omitting scope grants the filtered allowlist (RFC 6749 §3.3).
- Both handlers negotiate, so a request that cannot succeed fails before
the consent page renders rather than after the user clicks Allow.
- Two paths produce an empty result and stay distinct. No allowlist and
no request keeps the previous unrestricted grant, written as an explicit
sentinel because the column is NOT NULL with a non-empty CHECK. An
allowlist filtering to nothing is rejected, since falling back would
grant more than it ever permitted.

**Accepted compatibility break, and it is wider than DCR.** The catalog
check runs before the allowlist is consulted, so it covers admin-created
apps too.

- Admin-created apps store no allowlist and had scope discarded
outright. A client sending OIDC vocabulary (openid, profile, email) now
gets invalid_scope on upgrade.
- DCR apps registered with openid, read or admin also hold allowlists
this server cannot grant from, and fail in both directions.
- scopes_supported has always been advertised on both discovery
documents, predating this stack, so a conformant client had no grounds
to send an uncatalogued name. RFC 6749 §4.1.2.1 makes invalid_scope the
answer for one that does.
- Grandfathering would seed the enforcement path with values it cannot
evaluate: a visible error now, a silent hole later.
- The rejection names the registered scopes and the remedy. Pinned in
TestOAuth2AuthorizeDCRScopeCompatibility.

**Interim error delivery, replaced in PR 3.** Rejections go out however
each handler already reports errors: a static error page on GET, an
OAuth2 error body on POST. #28179 delivers them to the client's own
callback (RFC 6749 §4.1.2.1) and rewrites the requireInvalidScope
helper. About 25 lines here are interim on purpose and flagged as such
in that helper.

**Not changed.** Issued tokens are still unrestricted. The exchange
copies the negotiated scope onto the token record, but the API key it
mints carries no scope. This changes which authorization requests
succeed, not what a token can do.

Stack: #28167 (merged), this PR, then #28179 for the consent page and
the invalid_scope redirect.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
Base automatically changed from plat479-2-negotiate-scope to main August 31, 2026 16:29
…ated-scope

# Conflicts:
#	coderd/oauth2provider/authorize.go
#	coderd/rbac/scopes_internal_test.go
…tiated-scope

# Conflicts:
#	coderd/oauth2provider/authorize.go
#	coderd/oauth2provider/authorize_test.go
#	coderd/rbac/scopes_internal_test.go
Shorten the comments on the scope negotiation helpers to plain sentences and
drop restatements of what the code shows. Also trim the PKCE revocation
comments in tokens.go.
@BobbyHo
BobbyHo marked this pull request as ready for review August 31, 2026 18:53

@dylanhuff-at-coder dylanhuff-at-coder 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.

At the risk of sounding like a broken record, two small comments but nothing blocking, lgtm

Comment thread coderd/oauth2provider/authorize.go
Comment thread coderd/oauth2provider/authorize.go Outdated
… coverage

negotiateScope returns errCoverageUndecidable when the coverage comparison
against the app's allowlist fails outright, which is this server failing to
decide rather than a defect in the request. Both authorize call sites reported
it as invalid_scope and echoed the sentinel text, which names RBAC internals.

Map it to server_error (RFC 6749 §4.1.2.1) with a fixed description. The other
rejections keep invalid_scope and their existing text.
@BobbyHo
BobbyHo merged commit 9368917 into main Sep 1, 2026
31 checks passed
@BobbyHo
BobbyHo deleted the plat479-3-report-negotiated-scope branch September 1, 2026 16:16
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants