Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions coderd/oauth2_metadata_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,15 @@ func TestOAuth2ClientNameValidation(t *testing.T) {
}
}

// TestOAuth2ClientScopeValidation tests scope parameter validation
// TestOAuth2ClientScopeValidation tests scope parameter validation at
// registration time, which accepts any syntactically valid scope string.
//
// Registration performs no scope catalog validation, so these values are
// stored verbatim as the app's scope allowlist. Authorization is where the
// catalog is enforced: none of the names below is in rbac.IsExternalScope, so
// an app registered with one can no longer complete an authorization, whether
// it requests that scope or omits scope entirely. See
// TestOAuth2AuthorizeDCRScopeCompatibility in coderd/oauth2provider.
func TestOAuth2ClientScopeValidation(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -596,9 +604,11 @@ func TestOAuth2ClientScopeValidation(t *testing.T) {
expectError: false,
},
{
name: "InvalidAdmin",
scope: "admin",
expectError: false, // Admin scope should be allowed but validated during authorization
name: "InvalidAdmin",
scope: "admin",
// Registration accepts it; authorization rejects it with
// invalid_scope, since "admin" is not a grantable scope name.
expectError: false,
},
{
name: "ValidCustom",
Expand Down
127 changes: 121 additions & 6 deletions coderd/oauth2provider/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,100 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/site"
)

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.

Nit [CRF-13] validateRequestedScope's doc comment says "If the app has no allowlist configured, it preserves today's unrestricted behavior", which is only true when the client also requested no scope. (Leorio)

Leorio:

Read as a standalone sentence, it claims the no-allowlist branch preserves today's unrestricted behavior. The no-allowlist + non-empty-request branch does not: it returns the catalog-filtered request, which is narrower than coder:all. Today's behavior was coder:all regardless of what the client asked for. The behavior of the actual code is the right behavior, the sentence just overstates it.

Split the sentence so the return contract is stated per branch, or state it as a table in the comment. Either way, the reader shouldn't need to open the function body to know what each of the four branches returns.

🤖

// noScopeAllowlist reports whether an app has no scope allowlist configured.
// NULL and "" are one state, and this is the only place the two are unified:
// admin-created apps store sql.NullString{} (apps.go), while DCR-registered
// apps store Valid: true carrying a possibly-empty req.Scope
// (registration.go). Once the allowlist decides what a token may do, reading
// it is an authorization decision, so the two encodings route through one
// predicate rather than each caller flattening via .String.
//
// A whitespace-only allowlist is deliberately not this state. It is a
// configured value that grants nothing, so it falls through to
// validateRequestedScope's filtered-to-empty rejection instead of the
// unrestricted fallback.
func noScopeAllowlist(appScope sql.NullString) bool {

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.

Note [CRF-17] noScopeAllowlist is a named function for a two-clause predicate with one production caller, and the whole reason it exists lives in a comment next to it. (Luffy)

Luffy:

The body is !appScope.Valid || appScope.String == "". It is called exactly once, at line 67 in validateRequestedScope, and again from its own unit test. The docstring says "this is the only place the two are unified", which is a claim that a name enforces something. It doesn't. Nothing stops the next caller from writing !app.Scope.Valid || app.Scope.String == "" inline. What the function actually buys is that one branch label reads "no allowlist" instead of the two-clause predicate.

Either that reading is worth a function, or the same words go on the branch as a comment. Raising it as an observation, not a demand; the abstraction is single-use and the comment does the work.

🤖

return !appScope.Valid || appScope.String == ""
}

// validateRequestedScope checks each requested scope token is a recognized,
// user-requestable scope (RFC 6749 §4.1.2.1 invalid_scope), and that the full
// requested set is covered by the app's configured scope allowlist. If the
// client requested no scope, it defaults to the app's allowlist (RFC 6749
// §3.3). If the app has no allowlist configured, it preserves today's
// unrestricted behavior.
//
// The return value is written directly to a NOT NULL column whose CHECK
// constraint also rejects the empty string, so it is a string rather than a
// []string, and it is never empty alongside a nil error.
func validateRequestedScope(requested []string, appScope sql.NullString) (string, error) {
// Only names in the external scope catalog (rbac.IsExternalScope) are
// user-requestable. That is a curation, not a validity check: RBAC can
// expand internal-only names such as debug_info:read just fine, and the
// api_key_scope enum would store them, which is exactly why the catalog
// exists as a narrower list. Checking here keeps both an unrecognizable
// name and an internal-only one out of the granted scope, whether or not
// the app has an allowlist to check against.
for _, s := range requested {
if !rbac.IsExternalScope(rbac.ScopeName(s)) {
return "", xerrors.Errorf("unknown or unsupported scope: %q", s)
}
}

if noScopeAllowlist(appScope) {
if len(requested) == 0 {
// Unrestricted, the same grant this app got before scope
// enforcement existed, but stated explicitly: an empty string
// would violate the column's CHECK.
return database.OAuth2ScopeUnrestricted, nil
}
return strings.Join(requested, " "), nil

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.

P2 [CRF-1] Bare aliases all and application_connect reach the persisted oauth2_provider_app_codes.scope verbatim; they are not in the api_key_scope enum, so Phase 3's typed api_keys.scopes insert will fail on them. (Mafuuu P2, Razor P2, Kurapika P3, Melody P3)

Mafuuu, verified against a compiled binary:

IsExternalScope(all): true
IsExternalScope(application_connect): true
IsExternalScope(coder:all): true

Razor traced the pipeline: rbac.IsExternalScope accepts the pre-canonicalization names as backward-compat aliases (scopes_catalog.go:91-95), so the catalog check waves them through, and strings.Join(requested, " ") at both the no-allowlist branch (line 74) and the subset-accepted branch (line 113) writes "all" or "application_connect" into a column whose comment states values are "drawn from the api_key_scope vocabulary" (dump.sql:2616). tokens.go:378 then propagates the same string to oauth2_provider_app_tokens.scope, and Phase 3's api_keys.scopes::api_key_scope[] insert will trip the enum, with rbac.ExpandScope("all") returning "no scope named "all"".

No runtime break today because authorizationCodeGrant still mints rbac.ScopeAll, but this ships a data-vocabulary violation the next PR has to unwind. Normalize at the catalog check: map "all" -> string(rbac.ScopeAll) and "application_connect" -> string(rbac.ScopeApplicationConnect) on both the requested list and the filtered allowlist, or drop the two aliases from IsExternalScope. Add a TestValidateRequestedScope case with requested: []string{"all"} and noAllowlist asserting ExpandScope recognizes the persisted value.

🤖

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.

Nit [CRF-12] The persisted oauth2_provider_app_codes.scope string is not deduplicated. (Mafuuu Nit, Bisky Note)

Mafuuu: a request ?scope=coder:workspaces.access%20coder:workspaces.access reaches this line as strings.Join(["coder:workspaces.access", "coder:workspaces.access"], " ") and stores "coder:workspaces.access coder:workspaces.access" verbatim. Same is true for the subset-check success path at line 113. strings.Fields preserves duplicates. Dedup with a set (or slices.Compact after sort) before the join.

Bisky's related observation: no test currently asserts duplicate-handling behavior; if the enforcement layer ever moves to a strict parser, this is where the assumption is undocumented. RFC 6749 does not require deduplication, but the column's contract calls the value "space-separated scope," which is set semantics; the storage should match.

🤖

}

// Filter the allowlist through IsExternalScope before it is used for
// anything. The allowlist was stored at registration time and may contain
// a scope name since removed from the curated catalog, or never in it at
// all. Filtering only ever narrows what is granted.
allowed := strings.Fields(appScope.String)
filtered := make([]string, 0, len(allowed))
for _, a := range allowed {
if rbac.IsExternalScope(rbac.ScopeName(a)) {
filtered = append(filtered, a)
}
}
if len(filtered) == 0 {
// The app has an allowlist, but no entry in it is grantable.
// Returning the unrestricted sentinel here would grant strictly more
// than the allowlist ever permitted, so reject instead. This is the
// all-entries-dropped counterpart to the single-stale-entry case the
// filter above handles, and it must not share the no-allowlist
// branch's fallback.
return "", xerrors.New("this app's allowed scope list contains no grantable scope")

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.

P3 [CRF-8] The filter-to-empty rejection error is diagnostically insufficient for the DCR compatibility break this PR ships. (Chopper P3, Leorio Nit)

Chopper:

An integrator whose DCR-registered app registered with Scope: "openid profile email" calls /oauth2/authorize, either requesting one of those names or omitting scope. Per the accepted break in the PR description, the request now fails. The signal they receive is:

invalid_scope: this app's allowed scope list contains no grantable scope

The receiving end at scale is an integrator debugging a script that worked yesterday. They see invalid_scope, look at their client code, and see it sending exactly what it registered. Nothing in the message points at: which names in the allowlist are the problem; that the server's scope catalog is the authority they need to compare against; that re-registering the app with catalog scopes is the remedy.

Leorio adds that "grantable" is not a word the OAuth client dev reading the error_description field will translate, and the message should name the remedy. Something like "none of the app's registered scopes [%s] are recognized by this server; re-register the app with a supported scope": names the cause (the registered list), names the action (re-register), keeps the length reasonable. The sibling "unknown or unsupported scope: %q" at line 63 already names the offending token; only this filtered-to-empty branch needs the change.

🤖

}

if len(requested) == 0 {
return strings.Join(filtered, " "), nil // RFC 6749 §3.3 default
}

// The subset check runs against the filtered allowlist, not the raw one,
// so a dropped entry cannot be requested explicitly either.
allowedSet := make(map[string]bool, len(filtered))

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.

Note [CRF-18] The literal-name subset check rejects semantic narrowing across the catalog hierarchy; a client asking for less privilege gets no token and is pushed to ask for the broader composite instead. (Meruem)

Meruem:

coder:workspaces.access expands to workspace:{read,ssh,application_connect} (coderd/rbac/scopes.go:158). workspace:read sits alone in the catalog (coderd/rbac/scopes_catalog.go:13). Both are external. An admin who configures an app with Scope: "coder:workspaces.access", i.e. the natural composite an app registration UI would surface, cannot approve a client that requests scope=workspace:read: allowedSet holds only the literal composite name, so the subset check at line 109 rejects. The client's only path to a token is to re-issue the request with the broader coder:workspaces.access, which is the opposite direction from what they were asking for.

The invariant being hidden is what an allowlist entry means. Every admin who reads "allowlist" will read it as a ceiling. Class fix: normalize the allowlist to its low-level names before the subset check runs (using rbac.CompositeSitePermissions); or refuse to accept composite names in the allowlist so the ceiling is always spelled in the vocabulary the check runs in. Phase 3 hasn't wired enforcement to this column yet, so no live client is being narrowed today; the constraint should be settled before it does.

🤖

for _, a := range filtered {
allowedSet[a] = true
}
for _, s := range requested {
if !allowedSet[s] {
return "", xerrors.Errorf("scope %q is not in this app's allowed scope list", s)
}
}
return strings.Join(requested, " "), nil
}

type authorizeParams struct {
clientID string
redirectURL *url.URL
Expand Down Expand Up @@ -144,6 +234,27 @@ func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc {
return
}

// Reject a scope the app can never be granted before the consent page

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.

Nit [CRF-14] The comment claims the check "mirrors how the PKCE code_challenge requirement is already handled: inside extractAuthorizeParams, which both GET and POST call". It is not inside extractAuthorizeParams; it is called after extractAuthorizeParams returns, in both ShowAuthorizePage and ProcessAuthorize. (Meruem)

The next reader will look inside extractAuthorizeParams for it and not find it. Either move the call there (the natural class fix, per CRF-21) or reword the comment to describe what the code actually does: "check it in both handlers so a request that cannot succeed is rejected before consent renders."

🤖

// renders, rather than after the user clicks Allow. This mirrors how
// the PKCE code_challenge requirement is already handled: inside
// extractAuthorizeParams, which both GET and POST call.
if _, err := validateRequestedScope(params.scope, app.Scope); err != nil {

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.

Note [CRF-21] validateRequestedScope is called at two sites over the same inputs, and the first call discards its result. (Meruem)

Meruem: GET (ShowAuthorizePage, line 241) calls it and throws the string away. POST (ProcessAuthorize, line 336) calls it and persists the string. Both calls are validateRequestedScope(params.scope, app.Scope), and both derive params.scope from the same query parser and app.Scope from the same middleware. No bug today because the inputs are identical; the class of bug the shape invites is any future side effect added to the negotiation (telemetry, consent-page display, a signed record of what was approved) has to be added at both sites and stay in sync.

The class fix is not to move the call inside extractAuthorizeParams (which does not have app); it is to make the negotiated scope a value on authorizeParams, computed once via a method like params.negotiateScope(app.Scope) that both handlers call, or to extend extractAuthorizeParams to take the app and return an authorizeParams that already carries grantedScope. Either way: negotiate once per request, use the value or reject. This is also where CRF-5 (consent page shows scope) becomes natural.

🤖

site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
HideStatus: false,
Title: "Invalid Scope",
Description: "The requested scope is invalid or exceeds what this application is allowed to request.",

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.

P3 [CRF-7] The static "Invalid Scope" page description attributes the failure to the requester in a failure mode where the requester did nothing wrong. (Mafuuu P3, Leorio P3)

validateRequestedScope at line 95 rejects apps whose allowlist filters to zero grantable entries with this app's allowed scope list contains no grantable scope. The user reaches that error by pointing their browser at an authorize URL for a DCR-registered app whose registered scopes are all outside IsExternalScope's catalog (e.g. openid profile email, pinned in TestOAuth2AuthorizeDCRScopeCompatibility). The user cannot fix the request; the app owner has to re-register.

Mafuuu:

The Description they see says the scope they requested is invalid or exceeds the allowlist. That is not what happened. They possibly requested nothing at all, and the allowlist itself is the problem. The Warnings line does carry the raw error message, but the Description is what the user reads first, and it points them at "fix your request," which is unfixable from their side.

Leorio's suggested shape: a Description generic enough to cover both a bad request and a broken app ("This authorization request cannot proceed with the scope this application has registered."), with the Warning naming the specific cause. The internal predicate for the second case is len(filtered) == 0 at line 88, already distinguishable if you want two Descriptions.

🤖

Warnings: []string{err.Error()},
Actions: []site.Action{
{
URL: accessURL.String(),
Text: "Back to site",
},
},
})
return
}

cancel := params.redirectURL
cancelQuery := params.redirectURL.Query()
cancelQuery.Add("error", "access_denied")
Expand Down Expand Up @@ -222,7 +333,12 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
return
}

// TODO: Ignoring scope for now, but should look into implementing.
grantedScope, err := validateRequestedScope(params.scope, app.Scope)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidScope, err.Error())

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.

P3 [CRF-6] invalid_scope is returned as a JSON body with status 400, but RFC 6749 §4.1.2.1 puts invalid_scope on the "redirect back to redirect_uri" side of the line once client_id/redirect_uri have resolved. (Chopper P3, Mafuuu P3, Razor Note, Knov Note, Melody Note, Hisoka Note)

Chopper:

The receiving end is an OAuth client library on the user's browser callback. It expects ?error=invalid_scope&error_description=...&state=... on its redirect URI. It gets a JSON response body on the Coder host instead, so its error-handling code never runs; the user is stranded at Coder with no path back to the requesting app. The state round-trip that clients use to correlate the failed request is also lost.

Razor pointed out this is a file-wide pattern: authorize.go:184, 205, 222, 269, 307, 313, 320, 332, 344, 390 all use the same non-redirecting shape, and access_denied at line 268 is the only branch that follows §4.1.2.1's redirect. Mafuuu called out the class problem explicitly: this PR does not create the class, but it extends it with a new error code that RFC 6749 §4.1.2.1 names verbatim. The GET-side path at line 246 has the same shape.

If the direction of travel is to keep both surfaces, state it in a comment; otherwise this is the natural place to file the class fix (a helper that all authorize-endpoint error paths route through, covering both the JSON and static-page sites). The narrower fix for just this PR is to build the redirect (error, error_description, state query params), call http.Redirect, and stop calling WriteOAuth2Error for invalid_scope.

🤖

return
}

code, err := GenerateSecret()
if err != nil {
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 app authorization code")
Expand Down Expand Up @@ -259,11 +375,10 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
CodeChallengeMethod: sql.NullString{String: params.codeChallengeMethod, Valid: params.codeChallengeMethod != ""},
StateHash: hashOAuth2State(params.state),
RedirectUri: sql.NullString{String: params.redirectURL.String(), Valid: params.redirectURIProvided},
// Scope negotiation lands in a later phase. Until the
// requested scope is validated against the app's allowlist,
// persisting it here would store unvalidated client input, so
// the code records an unrestricted grant.
Scope: database.OAuth2ScopeUnrestricted,
// The negotiated scope, not the requested one: it has been

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.

Note [CRF-20] The persisted grantedScope has no reader yet; authorizationCodeGrant still mints tokens at rbac.ScopeAll. (Hisoka Note, Pariston Note, Ryosuke Note)

Disclosed in the PR description ("Issued tokens are still unrestricted"). Cross-linking as a Note because: (1) codes.scope and tokens.scope now carry a truthful negotiation, and api_keys.scopes (the row enforcement actually reads) does not; introspection on the token row will suggest a restriction the token does not have. (2) Under "assume no follow-up" the persisted column is metadata no enforcement path reads, and consent recorded here is not a promise the token upholds. (3) CRF-1's alias-normalization work will land in Phase 3 anyway; doing it here avoids the next PR needing to normalize on read and remember to also normalize refresh.

🤖

// checked against the scope catalog and the app's allowlist,
// and it is what the token minted from this code will carry.
Scope: grantedScope,
})
if err != nil {
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
Expand Down
182 changes: 182 additions & 0 deletions coderd/oauth2provider/authorize_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,195 @@ package oauth2provider

import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/database"
)

func TestValidateRequestedScope(t *testing.T) {

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.

Nit [CRF-16] The catalog-membership scope constants (inCatalog, alsoInCatalog, notInCatalog, neverInCatalog) are duplicated in authorize_test.go:56 under slightly different names (scopeInCatalog, scopeAlsoInCatalog, scopeOutOfCatalog). (Knov)

Both files test the same predicate under two different transports, and both pick coder:workspaces.access and coder:templates.build for "in catalog". If a future rename lands in externalComposite without a paired update here, one file will drift ahead of the other and the corresponding test file will start failing for the wrong reason. A shared oauth2providertest constant, or one file re-exporting from the other, removes the duplication without changing coverage.

🤖

t.Parallel()

// Every scope name below is either in rbac.IsExternalScope's curated
// catalog or deliberately outside it; the test's meaning depends on which,
// so they are named rather than inlined.
const (
inCatalog = "coder:workspaces.access"
alsoInCatalog = "coder:templates.build"
notInCatalog = "some_removed_scope"
neverInCatalog = "openid"
)

noAllowlist := sql.NullString{}
emptyAllowlist := sql.NullString{String: "", Valid: true}

tests := []struct {
name string
requested []string
appScope sql.NullString
want string
wantErr bool
}{
{
name: "UnknownRequestedScopeRejected",
requested: []string{"not_a_real_scope"},
appScope: sql.NullString{String: inCatalog, Valid: true},
wantErr: true,
},
{
// The catalog check does not depend on the allowlist, so an
// unknown scope is rejected even where there is nothing to
// check it against.
name: "UnknownRequestedScopeRejectedWithoutAllowlist",
requested: []string{"not_a_real_scope"},
appScope: noAllowlist,
wantErr: true,
},
{
// A different rejection from the case above, and the one that
// matters more: debug_info:read is a real scope RBAC can expand
// and the api_key_scope enum can store. Only the catalog's
// curation keeps a client from negotiating an internal-only
// permission for itself.
name: "InternalOnlyScopeRejected",
requested: []string{"debug_info:read"},
appScope: noAllowlist,
wantErr: true,
},
{
// AC3/AC16: the literal return value matters. "" is exactly what

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.

P2 [CRF-4] Same plan-doc labels (AC*, Edge Case, §4.2.2) appear on table-case comments in this file. (Gon)

Same rule and same fix as the sibling in authorize_test.go. Instances:

  • :65 // AC3/AC16: the literal return value matters...
  • :74 // AC16/Edge Case 22: '' is the DCR-registered encoding...
  • :120 // Edge Case 20: catalog drift. The stale entry is dropped by the filter...
  • :136 // AC15/Edge Case 19: the all-entries-dropped counterpart to the case above...
  • :145 // §4.2.2's compatibility break, in its most direct form: a DCR client requesting exactly what it registered.

Drop the prefixes; each remaining sentence stands on its own.

🤖

// the column's CHECK rejects, so asserting only "no error" would
// let a DB-level 500 through.
name: "NoAllowlistOmittedRequestIsUnrestricted",
requested: nil,
appScope: noAllowlist,
want: database.OAuth2ScopeUnrestricted,
},
{
// AC16/Edge Case 22: '' is the DCR-registered encoding of the
// same "no allowlist configured" state NULL expresses for
// admin-created apps. Both must reach the same branch.
name: "EmptyAllowlistBehavesAsNoAllowlist",
requested: nil,
appScope: emptyAllowlist,
want: database.OAuth2ScopeUnrestricted,
},
{
name: "NoAllowlistExplicitRequestPassesThrough",
requested: []string{inCatalog},
appScope: noAllowlist,
want: inCatalog,
},
{
name: "EmptyAllowlistExplicitRequestPassesThrough",
requested: []string{inCatalog},
appScope: emptyAllowlist,
want: inCatalog,
},
{
// RFC 6749 §3.3: an omitted scope defaults to the app's allowlist.
name: "OmittedRequestDefaultsToAllowlist",
requested: nil,
appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true},
want: inCatalog + " " + alsoInCatalog,
},
{
name: "ExactMatchAccepted",
requested: []string{inCatalog},
appScope: sql.NullString{String: inCatalog, Valid: true},
want: inCatalog,
},
{
name: "GenuineSubsetAccepted",
requested: []string{alsoInCatalog},
appScope: sql.NullString{String: inCatalog + " " + alsoInCatalog, Valid: true},
want: alsoInCatalog,
},
{
name: "PartiallyOutOfAllowlistRejected",
requested: []string{inCatalog, "template:read"},
appScope: sql.NullString{String: inCatalog, Valid: true},
wantErr: true,
},
{
// Edge Case 20: catalog drift. The stale entry is dropped by the
// filter, and the surviving entry is still granted.
name: "StaleAllowlistEntryDroppedNotGranted",
requested: nil,
appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true},
want: inCatalog,
},
{
// The filter applies to the subset check too, so a dropped entry
// cannot be reached by requesting it explicitly either.
name: "StaleAllowlistEntryNotRequestableExplicitly",
requested: []string{notInCatalog},
appScope: sql.NullString{String: inCatalog + " " + notInCatalog, Valid: true},
wantErr: true,
},
{
// AC15/Edge Case 19: the all-entries-dropped counterpart to the
// case above. Falling back to the unrestricted sentinel here would
// grant strictly more than this allowlist ever permitted.
name: "AllowlistFilteringToEmptyRejected",
requested: nil,
appScope: sql.NullString{String: "openid profile email", Valid: true},
wantErr: true,
},
{
// §4.2.2's compatibility break, in its most direct form: a DCR
// client requesting exactly what it registered.
name: "NonCatalogScopeRequestedAsRegistered",
requested: []string{neverInCatalog},
appScope: sql.NullString{String: neverInCatalog, Valid: true},
wantErr: true,
},
{
// A whitespace-only allowlist is a configured value that grants
// nothing, not an unset one, so it rejects rather than falling
// back to unrestricted.
name: "WhitespaceOnlyAllowlistRejected",
requested: nil,
appScope: sql.NullString{String: " ", Valid: true},
wantErr: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

got, err := validateRequestedScope(test.requested, test.appScope)
if test.wantErr {
require.Error(t, err)
assert.Empty(t, got, "a rejected request must not return a persistable scope")
return
}
require.NoError(t, err)
assert.Equal(t, test.want, got)
// The return value goes straight to a NOT NULL column carrying
// CHECK (scope <> ''), so an empty success is never legal.
assert.NotEmpty(t, got, "a successful negotiation must never return an empty scope")
})
}
}

func TestNoScopeAllowlist(t *testing.T) {
t.Parallel()

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.

P2 [CRF-2] Every wantErr: true case is asserted with require.Error(t, err) and nothing else, so the three distinct rejection paths in validateRequestedScope are indistinguishable to the test. (Chopper)

Chopper:

validateRequestedScope emits three separately-worded errors:

  1. "unknown or unsupported scope: %q" (catalog check, authorize.go:63)
  2. "this app's allowed scope list contains no grantable scope" (all-entries filtered, authorize.go:95)
  3. "scope %q is not in this app's allowed scope list" (subset check, authorize.go:110)

The table has eight wantErr: true cases spanning all three paths, but the loop asserts only require.Error(t, err) and assert.Empty(t, got). Cases that must land in different branches (InternalOnlyScopeRejected, PartiallyOutOfAllowlistRejected, AllowlistFilteringToEmptyRejected, WhitespaceOnlyAllowlistRejected) are structurally interchangeable at the assertion. A refactor that routed WhitespaceOnlyAllowlist through the "unknown scope" branch, or PartiallyOutOfAllowlist through the "no grantable scope" branch, would still pass every test.

The point of naming InternalOnlyScopeRejected vs UnknownRequestedScopeRejected in the table is that they hit the same code path today but are distinct guarantees; the assertions do not enforce that. Add a per-case wantErrContains string field (or typed sentinels) so each rejection pins the branch it claims to cover. The HTTP-level suite in authorize_test.go extends the same class through requireInvalidScope: OutOfAllowlistRejected, UnknownScopeRejected, AllowlistFilteringToEmptyRejected, and both DCR subtests all reach different rejection reasons but assert the same invalid_scope body without touching ErrorDescription. Adding a wantContains on the description closes both.

🤖

// NULL and '' are one state. Both are produced in the tree today:
// sql.NullString{} by admin-created apps, Valid-with-empty-string by DCR
// registration that sent no scope.
assert.True(t, noScopeAllowlist(sql.NullString{}))
assert.True(t, noScopeAllowlist(sql.NullString{String: "", Valid: true}))
assert.False(t, noScopeAllowlist(sql.NullString{String: "coder:workspaces.access", Valid: true}))
assert.False(t, noScopeAllowlist(sql.NullString{String: " ", Valid: true}))
}

func TestHashOAuth2State(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading