Skip to content

feat(coderd/oauth2provider): report the negotiated scope to the user and the client - #28179

Draft
BobbyHo wants to merge 2 commits into
plat479-2-negotiate-scopefrom
plat479-3-report-negotiated-scope
Draft

feat(coderd/oauth2provider): report the negotiated scope to the user and the client#28179
BobbyHo wants to merge 2 commits into
plat479-2-negotiate-scopefrom
plat479-3-report-negotiated-scope

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Split out of #28045 (PLAT-479). Last of three, stacked on #28178. Read #28167 and #28178 first; this diff is against #28178.

#28178 decided what the scope is. This PR is everything that tells someone about it. Two halves, one commit each.

Half A: the consent page states the scope (a3fcdeb, ~190 lines)

  • Lists the negotiated permissions when the grant is bounded; keeps the original full-access wording when it is not.
  • An unrestricted grant reads as full access, not as "coder:all", since the scope name tells a user less than the sentence does. Collapses whenever the unrestricted scope is present, not only when it stands alone: an allowlist of 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 implicit list semantics when list-style is none, which would leave VoiceOver reading the permissions as loose text.

Half B: invalid_scope goes to the client's callback (0b7827e, ~90 lines)

This half wants a security reviewer. It is the only part of #28045 with an open-redirect failure mode, and its safety is entirely an ordering argument:

  • extractAuthorizeParams exact-matches redirect_uri against the app's registered callback, and it runs before the scope check, so the destination is always the app's own regardless of what the request carried.
  • If that ordering ever inverted, an attacker-supplied redirect_uri plus a deliberately invalid scope would turn /oauth2/authorize into an open redirect.
  • MismatchedRedirectURINotRedirected pins it: 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. It also asserts the rejection comes from redirect_uri validation rather than any 400.
  • Only errors raised after extractAuthorizeParams may use the helper, which its precondition documents. The file's other error paths are unchanged, since several of them are where that validation fails.

Retires the interim plumbing #28178 left behind: the GET side's static error page, requireInvalidScope's 400-and-body assertion, and the Location assertions in RejectionNamesTheRegisteredScopes.

The three PRs together reproduce #28045 byte for byte.

Stack: #28167, #28178, this PR.

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