Skip to content

feat: validate and persist OAuth2 authorization scope - #28045

Draft
BobbyHo wants to merge 2 commits into
coder-oauth2-scope-enforcement-plat-470from
coder-oauth2-scope-enforcement-plat-470-phrase-2
Draft

feat: validate and persist OAuth2 authorization scope#28045
BobbyHo wants to merge 2 commits into
coder-oauth2-scope-enforcement-plat-470from
coder-oauth2-scope-enforcement-plat-470-phrase-2

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

/oauth2/authorize parses the scope parameter and then discards it (// TODO: Ignoring scope for now), so an app's configured allowlist has never restricted anything and a client asking for more than it should be granted is never told no. Phase 1 added the columns that carry a negotiated scope from a code to the token it becomes, but nothing negotiates one yet: authorize.go writes a hardcoded coder:all onto every code it issues.

This PR makes the authorize step negotiate. validateRequestedScope checks every requested scope name against the external scope catalog (rbac.IsExternalScope), filters the app's stored allowlist through that same catalog, and requires the request to be a subset of what survives. An omitted scope defaults to the filtered allowlist per RFC 6749 section 3.3. The result is persisted on oauth2_provider_app_codes.scope, replacing Phase 1's placeholder. Both handlers validate, so a request that cannot succeed is rejected before the consent page renders rather than after the user clicks Allow, matching how this file already treats PKCE's code_challenge.

Two paths can produce an empty result, and they are deliberately not the same path. An app with no allowlist and no requested scope keeps today's unrestricted grant, written as the explicit coder:all sentinel because the column is NOT NULL with a non-empty CHECK. An app whose allowlist filters to nothing is rejected instead, since falling back there would grant strictly more than the allowlist ever permitted. NULL and '' are one "no allowlist configured" state, unified in a single predicate now that reading that column is an authorization decision rather than a display detail.

This carries an accepted compatibility break. Dynamic client registration performs no catalog validation, so apps registered with scopes such as openid, read, or admin hold allowlists this server cannot grant from. Those apps now fail authorization in both directions: requesting the scope they registered is rejected, and omitting scope hits the filtered-to-empty rejection. Grandfathering unknown names through would seed api_keys.scopes with values dbauthz cannot evaluate, trading a visible negotiation-time error for a silent enforcement-time hole. The affected population is bounded to DCR self-registered apps that sent a scope, the failure is immediate and carries a standard invalid_scope, and the remedy is re-registration with catalog scopes.

Issued tokens are still unrestricted: authorizationCodeGrant continues to mint rbac.ScopeAll and does not yet read the persisted column. This PR changes which authorization requests succeed, not what a token can do.

Phase 2 of PLAT-470, tracked as PLAT-479. Stacked on #28007. Applying the negotiated scope in authorizationCodeGrant, refresh narrowing, and the docs update follow as separate PRs.

Where this sits in the scope pipeline (green marks what this PR touches)
flowchart TD
    subgraph authorize["/oauth2/authorize"]
        AZ1["ShowAuthorizePage (GET)<br/>validates scope, then renders consent"]
        AZ2["ProcessAuthorize (POST)<br/>validates scope, then issues code"]
        V["validateRequestedScope<br/>catalog check + allowlist subset"]
        AZ1 --> V
        AZ2 --> V
    end

    APP[("oauth2_provider_apps.scope<br/>the allowlist, read as input")]
    APP --> V
    V --> CODES[("oauth2_provider_app_codes.scope<br/>negotiated value, was a placeholder")]

    subgraph codegrant["POST /oauth2/tokens, authorization_code"]
        G1["authorizationCodeGrant<br/>still hardcoded to rbac.ScopeAll"]
    end

    CODES --> G1
    G1 --> TOKENS[("oauth2_provider_app_tokens.scope")]

    subgraph enforce["Every authenticated API request"]
        E1["httpmw ExtractAPIKey"] --> E2["APIKey.ScopeSet()"] --> E3["UserRBACSubject"] --> E4["dbauthz authorize"]
    end

    TOKENS --> E1

    classDef changed fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#1b3c1e
    class AZ1,AZ2,V,CODES changed
Loading

Green is added or changed here. The grant path and the enforcement engine below it are untouched: they already read a key's scopes correctly and are waiting on real data, which the next PR feeds them.

How to review this PR

  1. coderd/oauth2provider/authorize.go: the whole behavior change. noScopeAllowlist first, since it defines what "no allowlist" means, then validateRequestedScope's four branches, then the two call sites. The catalog check runs on the request before any allowlist logic, so an internal-only name like debug_info:read is rejected whether or not the app has an allowlist. That check is a curation, not a validity check: RBAC expands such names fine and the api_key_scope enum stores them, which is exactly why the 55-name catalog is narrower than both.
  2. coderd/oauth2provider/authorize_internal_test.go: the table covers the four branches plus the cases that pin the contract the signature cannot. A rejection returns an empty string and a success never does, because the value goes straight to a CHECK (scope <> '') column.
  3. coderd/oauth2provider/authorize_test.go: HTTP-level coverage against a real database, asserting the persisted column by parsing the issued code's prefix out of the redirect rather than inferring the grant. TestOAuth2AuthorizeDCRScopeCompatibility pins the break above in executable form, registering through DCR because that is the only route that produces a non-catalog allowlist naturally.
  4. coderd/oauth2provider/validation_test.go and coderd/oauth2_metadata_validation_test.go: comments only. Both files carried a near-duplicate TestOAuth2ClientScopeValidation asserting registration accepts admin, commented "should be allowed but validated during authorization." That promised the opposite of what authorization now does. Registration-time expectations are unchanged, since this PR adds no registration-time validation.

Verified locally: the full coderd/oauth2provider package, coderd's TestOAuth2* suites, and coderd/mcp all pass against Postgres, and golangci-lint is clean on both changed packages.

/oauth2/authorize ignored the scope parameter entirely and wrote a hardcoded
coder:all onto every authorization code. It now negotiates: each requested
scope must be in the external scope catalog (rbac.IsExternalScope), and the
result must fall within the app's configured allowlist, which is itself
filtered through the same catalog. An omitted scope defaults to the filtered
allowlist per RFC 6749 section 3.3. The negotiated value is persisted on
oauth2_provider_app_codes.scope, replacing the placeholder written when the
column was added.

Both GET and POST validate, so a request that cannot succeed is rejected
before the consent page renders rather than after the user clicks Allow. This
matches how the handler already treats PKCE's code_challenge requirement.

Two cases produce an empty result and are handled deliberately differently.
An app with no allowlist and no requested scope keeps today's unrestricted
grant, spelled as the explicit coder:all sentinel because the column is NOT
NULL with a non-empty CHECK. An app whose allowlist filters to nothing is
rejected instead, since falling back there would grant strictly more than the
allowlist ever permitted. NULL and the empty string are one "no allowlist
configured" state, unified in a single predicate now that reading the column
is an authorization decision.

Accepted compatibility break: dynamic client registration performs no catalog
validation, so apps registered with scopes such as openid or admin hold
allowlists this server cannot grant from. Those apps now fail authorization in
both directions with invalid_scope. Grandfathering unknown names through would
seed api_keys.scopes with values dbauthz cannot evaluate, trading a visible
negotiation-time error for a silent enforcement-time hole. Registration-time
expectations are unchanged; the two tests asserting registration accepts these
values carried comments promising the opposite of what authorization does, and
those were corrected.

Issued tokens are not yet restricted: authorizationCodeGrant still mints
rbac.ScopeAll and does not read the persisted column. That lands with the
grant path.

Refs PLAT-479
…lat-470' into coder-oauth2-scope-enforcement-plat-470-phrase-2
@linear-code

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown

PLAT-470

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