diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go
index 9bb552899a8..a83ff020985 100644
--- a/coderd/oauth2provider/authorize.go
+++ b/coderd/oauth2provider/authorize.go
@@ -40,9 +40,9 @@ var (
errNoGrantableScope = xerrors.New("none of the scopes registered for this app are supported by this deployment; change the app's registered scopes to supported ones")
// The scope expands to permissions the allowlist does not cover.
errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes")
- // A comparison that failed outright. The underlying error names RBAC
+ // The coverage check itself failed. The underlying error names RBAC
// internals, so it is logged rather than rendered.
- errCoverageUndecidable = xerrors.New("scope coverage against this app's allowed scopes could not be determined")
+ errCoverageUndecidable = xerrors.New("scope coverage could not be determined")
)
// canonicalScopes rewrites each name to its api_key_scope enum spelling and
@@ -56,6 +56,18 @@ func canonicalScopes(names []string) []string {
return slice.Unique(canonical)
}
+// firstUnknownScope returns the first name clients may not request, and whether
+// there was one. The catalog is a curation, not a validity check: RBAC also
+// expands internal-only names such as debug_info:read.
+func firstUnknownScope(names []string) (string, bool) {
+ for _, name := range names {
+ if !rbac.IsExternalScope(rbac.ScopeName(name)) {
+ return name, true
+ }
+ }
+ return "", false
+}
+
// noScopeAllowlist reports whether an app has no scope allowlist. NULL and ""
// are the same state: admin-created apps store NULL, DCR-registered apps store
// a possibly empty req.Scope. Whitespace-only is a configured allowlist that
@@ -92,30 +104,33 @@ func grantableScopes(appScope string) []string {
return filtered
}
-// firstScopeOutsideAllowlist returns the first scope in granted that the
-// allowlist does not confer, or "" when it confers all of them. It compares
-// what the scopes grant, not their names: `coder:workspaces.access` covers
+const (
+ phaseAuthorize = "authorize"
+ phaseRedeem = "redeem"
+ phaseRefresh = "refresh"
+)
+
+// firstScopeBeyondCeiling returns the first requested scope the ceiling does not
+// confer, or "" when it confers all of them. It compares what the scopes grant,
+// not their names: a ceiling of `coder:workspaces.access` covers
// `workspace:read`. Pass both slices through canonicalScopes first, since RBAC
// expands `coder:all` but not the bare `all` alias. A comparison it cannot
// decide refuses.
-func firstScopeOutsideAllowlist(ctx context.Context, logger slog.Logger, phase string, appID uuid.UUID, allowlist, granted []string) (string, error) {
- allowedNames := make([]rbac.ScopeName, 0, len(allowlist))
- for _, a := range allowlist {
- allowedNames = append(allowedNames, rbac.ScopeName(a))
- }
- requestedNames := make([]rbac.ScopeName, 0, len(granted))
- for _, g := range granted {
- requestedNames = append(requestedNames, rbac.ScopeName(g))
- }
- // One pass over the allowlist rather than one per granted scope.
- outside, err := rbac.FirstScopeNotCovered(allowedNames, requestedNames)
+func firstScopeBeyondCeiling(ctx context.Context, logger slog.Logger, phase string, appID uuid.UUID, ceiling, requested []string) (string, error) {
+ ceilingNames := slice.StringEnums[rbac.ScopeName](ceiling)
+ requestedNames := slice.StringEnums[rbac.ScopeName](requested)
+ // One pass over the ceiling rather than one per requested scope.
+ outside, err := rbac.FirstScopeNotCovered(ceilingNames, requestedNames)
if err != nil {
logger.Warn(ctx, "oauth2 scope coverage could not be determined",
slog.Error(err),
slog.F("phase", phase),
slog.F("app_id", appID.String()),
- slog.F("allowlist", strings.Join(allowlist, " ")),
+ slog.F("ceiling", strings.Join(ceiling, " ")),
slog.F("scope", string(outside)))
+ // outside is a name from the ceiling, so it can be a stored value.
+ // Both handlers answer with a fixed string; rendering err.Error()
+ // here would echo it to the client.
return "", xerrors.Errorf("'%s': %w", outside, errCoverageUndecidable)
}
return string(outside), nil
@@ -139,13 +154,8 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2
// of the two aliases, so checking after the rewrite accepts the same names.
granted := canonicalScopes(requested)
- // The catalog is a curation, not a validity check: RBAC also expands
- // internal-only names such as debug_info:read, but clients may not request
- // them.
- for _, s := range granted {
- if !rbac.IsExternalScope(rbac.ScopeName(s)) {
- return "", xerrors.Errorf("%q: %w", s, errUnknownScope)
- }
+ if unknown, ok := firstUnknownScope(granted); ok {
+ return "", xerrors.Errorf("'%s': %w", unknown, errUnknownScope)
}
if noScopeAllowlist(app.Scope) {
@@ -169,7 +179,7 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2
return strings.Join(allowlist, " "), nil // RFC 6749 §3.3 default
}
- outside, err := firstScopeOutsideAllowlist(ctx, logger, "authorize", app.ID, allowlist, granted)
+ outside, err := firstScopeBeyondCeiling(ctx, logger, phaseAuthorize, app.ID, allowlist, granted)
if err != nil {
return "", err
}
@@ -207,6 +217,15 @@ func consentScopes(granted string) (names []string, unrestricted bool) {
// short enough for a Location header to survive the proxies in front of it.
const maxErrorDescription = 2048
+// capErrorDescription bounds a description, whose length is otherwise the
+// client's to choose.
+func capErrorDescription(description string) string {
+ if len(description) > maxErrorDescription {
+ return description[:maxErrorDescription] + " (truncated)"
+ }
+ return description
+}
+
// responseTypeCode is the only response type this server supports. response_type
// is read as text rather than through the SDK enum so every unsupported value
// takes one path, instead of splitting on whether a Go constant happens to
@@ -533,11 +552,8 @@ func (a authorizeResponse) codeURL(code string) *url.URL {
}
func redirectAuthorizeError(rw http.ResponseWriter, r *http.Request, logger slog.Logger, response authorizeResponse, code codersdk.OAuth2ErrorCode, description string) {
- // Descriptions echo values the client sent, so their length is the client's
- // to choose. Cap here, ahead of both the log field and the Location header.
- if len(description) > maxErrorDescription {
- description = description[:maxErrorDescription] + " (truncated)"
- }
+ // Capped ahead of both the log field and the Location header.
+ description = capErrorDescription(description)
app := httpmw.OAuth2ProviderApp(r)
logger.Info(r.Context(), "oauth2 authorization rejected",
diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go
index 6b759c26939..6e072d64c09 100644
--- a/coderd/oauth2provider/authorize_internal_test.go
+++ b/coderd/oauth2provider/authorize_internal_test.go
@@ -265,6 +265,9 @@ var (
ReasonCoverageUndecidable = errCoverageUndecidable.Error()
)
+// MaxErrorDescription is the description bound, for the same tests.
+const MaxErrorDescription = maxErrorDescription
+
// TestGrantableScopesNotSizedByInput pins the shape of the result, not just its
// contents. app.Scope is unvalidated registration metadata read on every
// authorization and redemption, so collecting duplicates and dropping them
@@ -383,6 +386,27 @@ func TestHashOAuth2State(t *testing.T) {
})
}
+func TestCapErrorDescription(t *testing.T) {
+ t.Parallel()
+
+ t.Run("ShortDescriptionUnchanged", func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, "unknown or unsupported scope", capErrorDescription("unknown or unsupported scope"))
+ })
+
+ t.Run("BoundIsInclusive", func(t *testing.T) {
+ t.Parallel()
+ atBound := strings.Repeat("x", maxErrorDescription)
+ assert.Equal(t, atBound, capErrorDescription(atBound))
+ })
+
+ t.Run("LongerDescriptionTruncated", func(t *testing.T) {
+ t.Parallel()
+ got := capErrorDescription(strings.Repeat("x", maxErrorDescription+1))
+ assert.Equal(t, strings.Repeat("x", maxErrorDescription)+" (truncated)", got)
+ })
+}
+
func TestSanitizeErrorDescription(t *testing.T) {
t.Parallel()
@@ -397,13 +421,13 @@ func TestSanitizeErrorDescription(t *testing.T) {
want: "Only response_type=code is supported",
},
{
- // What negotiateScope's %q produces for a well-behaved scope name.
+ // §5.2 excludes the double quote.
name: "QuotedScopeBecomesApostrophes",
description: `"openid": unknown or unsupported scope`,
want: "'openid': unknown or unsupported scope",
},
{
- // %q escapes a quote inside the value; the backslash goes with it.
+ // §5.2 excludes the backslash too.
name: "EscapedQuoteLosesItsBackslash",
description: `"\">
": unknown or unsupported scope`,
want: "''>
': unknown or unsupported scope",
diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go
index 26120e434ee..692fe7f6fa7 100644
--- a/coderd/oauth2provider/tokens.go
+++ b/coderd/oauth2provider/tokens.go
@@ -46,6 +46,10 @@ var (
// errStaleScope means the app's registered scopes narrowed after the code
// was issued and no longer cover the code's scope.
errStaleScope = xerrors.New("scope is no longer allowed by this app's registered scopes; authorize again to obtain a code within the current scopes")
+ // errScopeNotGranted means a request asked for more than the resource owner
+ // granted. The ceiling is the grant itself rather than the app's allowlist,
+ // and nothing but a new authorization raises it, so the message says so.
+ errScopeNotGranted = xerrors.New("scope requests permissions beyond the scope originally granted; a refresh cannot widen a grant, so authorize again to obtain a broader one")
)
// checkScopeStillCovered rechecks a grant's scope against the app's registered
@@ -68,7 +72,7 @@ func checkScopeStillCovered(ctx context.Context, logger slog.Logger, app databas
}
// Canonicalized because the row may have been written by an older server.
- outside, err := firstScopeOutsideAllowlist(ctx, logger, "redeem", app.ID, allowlist, canonicalScopes(strings.Fields(granted)))
+ outside, err := firstScopeBeyondCeiling(ctx, logger, phaseRedeem, app.ID, allowlist, canonicalScopes(strings.Fields(granted)))
if err != nil {
return err
}
@@ -82,6 +86,51 @@ func checkScopeStillCovered(ctx context.Context, logger slog.Logger, app databas
return nil
}
+// narrowAccessScope returns the scope for the access token this request mints.
+// A request may ask for part of the grant but never more (RFC 6749 §6), and a
+// request naming no scope gets the whole grant. The grant itself is unchanged,
+// so a later request may ask for a different part of it.
+func narrowAccessScope(ctx context.Context, logger slog.Logger, phase string, appID uuid.UUID, granted string, requested []string) (string, error) {
+ // The row may have been written by an older server.
+ ceiling := canonicalScopes(strings.Fields(granted))
+ // Before the comparison, so a stored name this deployment dropped is named
+ // in a 400 rather than failing to expand into a 500.
+ if _, err := scopeStringToAPIKeyScopes(strings.Join(ceiling, " ")); err != nil {
+ return "", err
+ }
+ if len(requested) == 0 {
+ return strings.Join(ceiling, " "), nil
+ }
+
+ // First, so a typo reads as an unknown scope rather than as a coverage
+ // check RBAC could not decide.
+ if unknown, ok := firstUnknownScope(requested); ok {
+ logger.Warn(ctx, "oauth2 token request refused: scope outside the catalog",
+ slog.F("phase", phase),
+ slog.F("app_id", appID.String()),
+ slog.F("scope", unknown))
+ return "", xerrors.Errorf("'%s': %w", unknown, errUnknownScope)
+ }
+
+ narrowed := canonicalScopes(requested)
+ outside, err := firstScopeBeyondCeiling(ctx, logger, phase, appID, ceiling, narrowed)
+ if err != nil {
+ return "", err
+ }
+ if outside != "" {
+ // Logged like every other scope refusal in this package: without it a
+ // leaked token being probed for what it can be traded up to looks the
+ // same as an ordinary client error.
+ logger.Warn(ctx, "oauth2 token request refused: scope beyond the grant",
+ slog.F("phase", phase),
+ slog.F("app_id", appID.String()),
+ slog.F("granted", granted),
+ slog.F("scope", outside))
+ return "", xerrors.Errorf("'%s': %w", outside, errScopeNotGranted)
+ }
+ return strings.Join(narrowed, " "), nil
+}
+
// scopeStringToAPIKeyScopes converts a grant's stored scope into the scope list
// an API key is minted with. Names are checked here, not in apikey.Generate,
// whose error would surface as a 500. An empty list is an error rather than an
@@ -197,6 +246,14 @@ func extractTokenRequest(r *http.Request, callbackURL *url.URL, app database.OAu
return req, nil, nil
}
+// writeTokenError renders an RFC 6749 §5.2 error body. Descriptions can quote
+// what the client sent, so they are confined and capped here rather than at each
+// call site, leaving the guarantee with the endpoint.
+func writeTokenError(ctx context.Context, rw http.ResponseWriter, status int, code codersdk.OAuth2ErrorCode, description string) {
+ // Sanitized before the cap, so the bound is on what the client receives.
+ httpapi.WriteOAuth2Error(ctx, rw, status, code, capErrorDescription(sanitizeErrorDescription(description)))
+}
+
// Tokens
// Uses Sessions.DefaultDuration for access token (API key) TTL and
// Sessions.RefreshDefaultDuration for refresh token TTL.
@@ -217,7 +274,7 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
req, validationErrs, err := extractTokenRequest(r, callbackURL, app)
if err != nil {
if errors.Is(err, errConflictingClientAuth) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Conflicting client credentials between Authorization header and request body")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Conflicting client credentials between Authorization header and request body")
return
}
@@ -225,7 +282,7 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "grant_type"
}) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, "The grant type is missing or unsupported")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, "The grant type is missing or unsupported")
return
}
@@ -234,7 +291,7 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == field
}) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, fmt.Sprintf("Missing required parameter: %s", field))
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, fmt.Sprintf("Missing required parameter: %s", field))
return
}
}
@@ -246,12 +303,13 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "code_verifier"
}) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The code_verifier parameter must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~] (RFC 7636 §4.1)")
+ // Spelled out: §5.2 excludes the section sign.
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The code_verifier parameter must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~] (RFC 7636 section 4.1)")
return
}
// Generic invalid request for other validation errors
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The request is missing required parameters or is otherwise malformed")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The request is missing required parameters or is otherwise malformed")
return
}
@@ -260,46 +318,53 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
switch req.GrantType {
// TODO: Client creds, device code.
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
- token, err = refreshTokenGrant(ctx, db, app, lifetimes, req)
+ token, err = refreshTokenGrant(ctx, db, logger, app, lifetimes, req)
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
token, err = authorizationCodeGrant(ctx, db, logger, app, lifetimes, req)
default:
// This should handle truly invalid grant types
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, fmt.Sprintf("The grant type %q is not supported", req.GrantType))
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, fmt.Sprintf("The grant type %q is not supported", req.GrantType))
return
}
if errors.Is(err, errBadSecret) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
+ writeTokenError(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
return
}
if errors.Is(err, errBadCode) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The authorization code is invalid or expired")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The authorization code is invalid or expired")
return
}
if errors.Is(err, errInvalidPKCE) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The PKCE code verifier is invalid")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The PKCE code verifier is invalid")
return
}
if errors.Is(err, errInvalidResource) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidTarget, "The resource parameter is invalid")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidTarget, "The resource parameter is invalid")
return
}
if errors.Is(err, errBadToken) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired")
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired")
return
}
- // invalid_grant, not invalid_scope: RFC 6749 §5.2 reserves invalid_scope
- // for the scope the client asked for, but these come from the stored
- // grant. The client cannot fix it by asking differently, only by
- // authorizing again.
+ // invalid_grant, not invalid_scope (RFC 6749 §5.2): all three report a
+ // problem with the stored grant, which the client cannot fix by asking
+ // differently. That includes errUnmintableScope: the catalog check runs
+ // first and every catalog name is mintable, so a requested scope never
+ // reaches it.
if errors.Is(err, errUnmintableScope) || errors.Is(err, errStaleScope) ||
errors.Is(err, errNoGrantableScope) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, err.Error())
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, err.Error())
+ return
+ }
+ // invalid_scope for these: the refresh named them itself, so the
+ // client can fix it by asking differently.
+ if errors.Is(err, errUnknownScope) || errors.Is(err, errScopeNotGranted) {
+ writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidScope, err.Error())
return
}
if errors.Is(err, errCoverageUndecidable) {
- httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "The requested scope could not be evaluated")
+ writeTokenError(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "The requested scope could not be evaluated")
return
}
if err != nil {
@@ -444,14 +509,10 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
- // Check the scope names first. RBAC cannot expand a name that is not a real
- // scope, so the allowlist check below would answer "could not be determined"
- // instead of naming the scope to fix.
- //
- // The minted key needs this list: apikey.Generate defaults to coder:all when
- // it is empty.
- scopes, err := scopeStringToAPIKeyScopes(dbCode.Scope)
- if err != nil {
+ // Before the allowlist check: RBAC cannot expand a name that is not a real
+ // scope, so that check would answer "could not be determined" rather than
+ // naming the stored scope to fix.
+ if _, err := scopeStringToAPIKeyScopes(dbCode.Scope); err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
@@ -459,6 +520,20 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.
return codersdk.OAuth2TokenResponse{}, err
}
+ // An exchange may narrow too. RFC 6749 §4.1.3 defines no scope parameter
+ // here, but the form carries one, and accepting it silently would hand back
+ // the broader token the client asked to give up.
+ accessScope, err := narrowAccessScope(ctx, logger, phaseRedeem, app.ID, dbCode.Scope, strings.Fields(req.Scope))
+ if err != nil {
+ return codersdk.OAuth2TokenResponse{}, err
+ }
+
+ // apikey.Generate defaults to coder:all when this is empty.
+ scopes, err := scopeStringToAPIKeyScopes(accessScope)
+ if err != nil {
+ return codersdk.OAuth2TokenResponse{}, err
+ }
+
// Generate a refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
@@ -553,12 +628,12 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
- Scope: dbCode.Scope,
+ Scope: accessScope,
Expiry: &key.ExpiresAt,
}, nil
}
-func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
+func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logger, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
// Validate the token.
token, err := ParseFormattedSecret(req.RefreshToken)
if err != nil {
@@ -600,6 +675,11 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
}
}
+ accessScope, err := narrowAccessScope(ctx, logger, phaseRefresh, app.ID, dbToken.Scope, strings.Fields(req.Scope))
+ if err != nil {
+ return codersdk.OAuth2TokenResponse{}, err
+ }
+
// Grab the user roles so we can perform the refresh as the user.
//nolint:gocritic // OAuth2 system context, need to read the previous API key
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID)
@@ -619,8 +699,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
return codersdk.OAuth2TokenResponse{}, err
}
- // A refresh neither widens nor narrows the original grant.
- scopes, err := scopeStringToAPIKeyScopes(dbToken.Scope)
+ scopes, err := scopeStringToAPIKeyScopes(accessScope)
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
@@ -670,9 +749,10 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
APIKeyID: newKey.ID,
UserID: dbToken.UserID,
Audience: dbToken.Audience,
- // RFC 6749 §6: a refresh with no scope parameter is granted the
- // originally granted scope. Later phases narrow this against
- // req.Scope; they never widen it.
+ // The consented grant, not accessScope: this column is the ceiling
+ // later refreshes are bounded by, and the only record of what the
+ // user approved. A rotated refresh token carries the scope of the
+ // one presented (OAuth 2.1 §4.3.3).
Scope: dbToken.Scope,
})
if err != nil {
@@ -689,7 +769,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
- Scope: dbToken.Scope,
+ Scope: accessScope,
Expiry: &key.ExpiresAt,
}, nil
}
diff --git a/coderd/oauth2provider/tokens_internal_test.go b/coderd/oauth2provider/tokens_internal_test.go
index 49fa28fa306..0debf364d7b 100644
--- a/coderd/oauth2provider/tokens_internal_test.go
+++ b/coderd/oauth2provider/tokens_internal_test.go
@@ -89,16 +89,17 @@ func TestScopeStringToAPIKeyScopes(t *testing.T) {
var (
ReasonUnmintableScope = errUnmintableScope.Error()
ReasonStaleScope = errStaleScope.Error()
+ ReasonScopeNotGranted = errScopeNotGranted.Error()
+)
+
+const (
+ inCatalog = "coder:workspaces.access"
+ alsoInCatalog = "coder:templates.build"
)
func TestCheckScopeStillCovered(t *testing.T) {
t.Parallel()
- const (
- inCatalog = "coder:workspaces.access"
- alsoInCatalog = "coder:templates.build"
- )
-
tests := []struct {
name string
granted string
@@ -205,6 +206,121 @@ func TestCheckScopeStillCovered(t *testing.T) {
}
}
+func TestNarrowAccessScope(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ granted string
+ requested []string
+ want string
+ wantErr error
+ }{
+ {
+ name: "OmittedRequestKeepsTheGrant",
+ granted: inCatalog + " " + alsoInCatalog,
+ requested: nil,
+ want: inCatalog + " " + alsoInCatalog,
+ },
+ {
+ name: "GenuineSubsetAccepted",
+ granted: inCatalog + " " + alsoInCatalog,
+ requested: []string{inCatalog},
+ want: inCatalog,
+ },
+ {
+ name: "ConstituentOfCompositeAccepted",
+ granted: inCatalog,
+ requested: []string{"workspace:ssh"},
+ want: "workspace:ssh",
+ },
+ {
+ // coder:all is a member of no other set, so membership would leave
+ // an unrestricted grant unnarrowable.
+ name: "UnrestrictedGrantNarrowed",
+ granted: string(database.ApiKeyScopeCoderAll),
+ requested: []string{"workspace:read"},
+ want: "workspace:read",
+ },
+ {
+ name: "ExpansionRejected",
+ granted: inCatalog,
+ requested: []string{alsoInCatalog},
+ wantErr: errScopeNotGranted,
+ },
+ {
+ name: "PartiallyCoveredRequestRejectedWhole",
+ granted: inCatalog,
+ requested: []string{"workspace:ssh", alsoInCatalog},
+ wantErr: errScopeNotGranted,
+ },
+ {
+ name: "UnknownRequestedScopeRejectedAsUnknown",
+ granted: string(database.ApiKeyScopeCoderAll),
+ requested: []string{"not_a_real_scope"},
+ wantErr: errUnknownScope,
+ },
+ {
+ // RBAC expands debug_info:read; only the catalog keeps it internal.
+ name: "InternalOnlyScopeRejected",
+ granted: string(database.ApiKeyScopeCoderAll),
+ requested: []string{"debug_info:read"},
+ wantErr: errUnknownScope,
+ },
+ {
+ // The catalog check runs before canonicalization, so
+ // IsExternalScope has to admit both bare aliases.
+ name: "LegacyAliasCanonicalized",
+ granted: string(database.ApiKeyScopeCoderAll),
+ requested: []string{"all"},
+ want: "coder:all",
+ },
+ {
+ name: "LegacyApplicationConnectAliasCanonicalized",
+ granted: string(database.ApiKeyScopeCoderAll),
+ requested: []string{"application_connect"},
+ want: "coder:application_connect",
+ },
+ {
+ name: "DuplicateRequestedScopesDeduplicated",
+ granted: inCatalog,
+ requested: []string{"workspace:ssh", "workspace:ssh"},
+ want: "workspace:ssh",
+ },
+ {
+ // Same error as a request naming no scope.
+ name: "GrantOutsideTheCatalogUnmintable",
+ granted: "some_removed_scope",
+ requested: []string{"workspace:ssh"},
+ wantErr: errUnmintableScope,
+ },
+ {
+ name: "GrantOutsideTheCatalogUnmintableWhenOmitted",
+ granted: "some_removed_scope",
+ requested: nil,
+ wantErr: errUnmintableScope,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := narrowAccessScope(t.Context(), slogtest.Make(t, nil), phaseRefresh, uuid.New(), test.granted, test.requested)
+ if test.wantErr != nil {
+ require.ErrorIs(t, err, test.wantErr)
+ assert.Empty(t, got, "a rejected refresh must not return a persistable scope")
+ assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()),
+ "the rejection reason must appear once, not doubled by the wrap")
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, test.want, got)
+ requirePersistableScope(t, got)
+ })
+ }
+}
+
// TestExtractTokenParams_Scopes tests OAuth2 scope parameter parsing
// to ensure RFC 6749 compliance where scopes are space-delimited
func TestExtractTokenParams_Scopes(t *testing.T) {
diff --git a/coderd/oauth2provider/tokens_test.go b/coderd/oauth2provider/tokens_test.go
index d8f41b0ff45..4de64fc8d97 100644
--- a/coderd/oauth2provider/tokens_test.go
+++ b/coderd/oauth2provider/tokens_test.go
@@ -53,6 +53,42 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
mintedKeyScopes(ctx, t, db, token.RefreshToken))
})
+ // RFC 6749 §4.1.3 defines no scope parameter here, but the form carries
+ // one, and discarding it hands back what the client gave up.
+ t.Run("ExchangeNarrowsTheAccessToken", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
+
+ form := tokenExchangeForm(app, code, verifier)
+ form.Set("scope", "workspace:ssh")
+ status, body := postTokenRequest(ctx, t, client, form)
+ token := requireTokenResponse(t, status, body)
+
+ require.Equal(t, "workspace:ssh", token.Scope)
+ require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh},
+ mintedKeyScopes(ctx, t, db, token.RefreshToken))
+ require.Equal(t, scopeInCatalog, tokenRow(ctx, t, db, token.RefreshToken).Scope,
+ "the grant is what the user consented to, not what the exchange asked for")
+ })
+
+ t.Run("ExchangeCannotWidenTheScope", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh")
+
+ form := tokenExchangeForm(app, code, verifier)
+ form.Set("scope", scopeAlsoInCatalog)
+ status, body := postTokenRequest(ctx, t, client, form)
+
+ require.Contains(t, requireTokenScopeError(t, status, body),
+ oauth2provider.ReasonScopeNotGranted)
+ })
+
t.Run("RefreshDoesNotWidenTheScope", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -61,16 +97,144 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh")
token := exchangeCode(ctx, t, client, app, code, verifier)
- form := url.Values{}
- form.Set("grant_type", "refresh_token")
- form.Set("refresh_token", token.RefreshToken)
- form.Set("client_id", app.ID.String())
- form.Set("client_secret", app.ClientSecret)
+ status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken))
+ refreshed := requireTokenResponse(t, status, body)
+ require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh},
+ mintedKeyScopes(ctx, t, db, refreshed.RefreshToken))
+ require.Equal(t, "workspace:ssh", refreshed.Scope)
+ })
+
+ // coder:workspaces.access covers workspace:ssh, so this gives up real
+ // authority. The refresh token still carries the grant.
+ t.Run("RefreshNarrowsTheAccessToken", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
+ token := exchangeCode(ctx, t, client, app, code, verifier)
+
+ form := refreshForm(app, token.RefreshToken)
+ form.Set("scope", "workspace:ssh")
status, body := postTokenRequest(ctx, t, client, form)
refreshed := requireTokenResponse(t, status, body)
+
require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh},
mintedKeyScopes(ctx, t, db, refreshed.RefreshToken))
require.Equal(t, "workspace:ssh", refreshed.Scope)
+ require.Equal(t, scopeInCatalog, tokenRow(ctx, t, db, refreshed.RefreshToken).Scope,
+ "OAuth 2.1 §4.3.3: a rotated refresh token carries the scope of the one presented")
+ })
+
+ // Narrowed earlier, now needs a different part of the same grant, which
+ // OAuth 2.1 §4.3 names as a reason to refresh.
+ t.Run("NarrowingDoesNotBindLaterRefreshes", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
+ token := exchangeCode(ctx, t, client, app, code, verifier)
+
+ form := refreshForm(app, token.RefreshToken)
+ form.Set("scope", "workspace:ssh")
+ status, body := postTokenRequest(ctx, t, client, form)
+ narrowed := requireTokenResponse(t, status, body)
+
+ // A sibling permission of the same grant, which the user consented to.
+ form = refreshForm(app, narrowed.RefreshToken)
+ form.Set("scope", "workspace:read")
+ status, body = postTokenRequest(ctx, t, client, form)
+ sibling := requireTokenResponse(t, status, body)
+ require.Equal(t, "workspace:read", sibling.Scope)
+ require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceRead},
+ mintedKeyScopes(ctx, t, db, sibling.RefreshToken))
+
+ // And the whole grant back, per RFC 6749 §6's omitted-scope default.
+ status, body = postTokenRequest(ctx, t, client, refreshForm(app, sibling.RefreshToken))
+ restored := requireTokenResponse(t, status, body)
+ require.Equal(t, scopeInCatalog, restored.Scope,
+ "an omitted scope is the scope originally granted by the resource owner")
+ require.Equal(t, scopeInCatalog, tokenRow(ctx, t, db, restored.RefreshToken).Scope)
+ })
+
+ t.Run("RefreshCannotWidenTheScope", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "workspace:ssh")
+ token := exchangeCode(ctx, t, client, app, code, verifier)
+
+ form := refreshForm(app, token.RefreshToken)
+ form.Set("scope", scopeAlsoInCatalog)
+ status, body := postTokenRequest(ctx, t, client, form)
+
+ description := requireTokenScopeError(t, status, body)
+ require.Contains(t, description, oauth2provider.ReasonScopeNotGranted)
+ require.Contains(t, description, scopeAlsoInCatalog)
+ // Without it a client retries combinations that cannot succeed.
+ require.Contains(t, description, "authorize again",
+ "the rejection must name the only way to a broader grant")
+ require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh},
+ mintedKeyScopes(ctx, t, db, token.RefreshToken),
+ "a rejected refresh issues nothing")
+
+ // Through the endpoint: reading the row would still pass if the
+ // rejection had rotated the hash or moved ExpiresAt.
+ status, body = postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken))
+ require.Equal(t, "workspace:ssh", requireTokenResponse(t, status, body).Scope,
+ "a rejected refresh leaves the original token redeemable")
+ })
+
+ t.Run("RefreshUnknownScopeRejected", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
+ token := exchangeCode(ctx, t, client, app, code, verifier)
+
+ form := refreshForm(app, token.RefreshToken)
+ form.Set("scope", "not_a_real_scope")
+ status, body := postTokenRequest(ctx, t, client, form)
+
+ description := requireTokenScopeError(t, status, body)
+ require.Contains(t, description, oauth2provider.ReasonUnknownScope)
+ // The catalog check runs first so a typo gets the name to fix.
+ require.Contains(t, description, "not_a_real_scope",
+ "the client cannot fix its request without the name that failed")
+ })
+
+ // RFC 6749 §5.1 only requires the parameter when the issued scope differs
+ // from the request, so both halves here make it differ.
+ t.Run("ResponseStatesTheScopeGranted", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
+ token := exchangeCode(ctx, t, client, app, code, verifier)
+ require.Equal(t, scopeInCatalog, token.Scope)
+
+ unrestricted := seedAppWithSecret(t, db, sql.NullString{})
+ code, verifier = authorizeCode(ctx, t, client, unrestricted.ID.String(), "")
+ granted := exchangeCode(ctx, t, client, unrestricted, code, verifier)
+ require.Equal(t, string(database.ApiKeyScopeCoderAll), granted.Scope)
+
+ // Requestable as "all", never granted under that spelling.
+ form := refreshForm(unrestricted, granted.RefreshToken)
+ form.Set("scope", "all")
+ status, body := postTokenRequest(ctx, t, client, form)
+ refreshed := requireTokenResponse(t, status, body)
+
+ require.Equal(t, string(database.ApiKeyScopeCoderAll), refreshed.Scope,
+ "the response states the granted spelling, not the requested one")
+ require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeCoderAll},
+ mintedKeyScopes(ctx, t, db, refreshed.RefreshToken),
+ "api_key_scope has no member spelled all, so an uncanonicalized mint fails here")
+ require.Equal(t, string(database.ApiKeyScopeCoderAll),
+ tokenRow(ctx, t, db, refreshed.RefreshToken).Scope)
})
// apikey.Generate defaults an empty scope list to coder:all, so this passes
@@ -122,6 +286,32 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
// Grants predating the scope columns carry what migration 000569 backfilled:
// coder:all. Seeded the way the migration leaves it rather than exchanged.
+ // An alias the api_key_scope enum does not hold, so both exits have to
+ // canonicalize it.
+ t.Run("LegacyAliasRefreshesTheSameEitherWay", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Two apps, not two tokens on one: nothing enforces a single holder
+ // of a refreshed key's name for this login type.
+ omittedApp := seedAppWithSecret(t, db, sql.NullString{})
+ narrowingApp := seedAppWithSecret(t, db, sql.NullString{})
+
+ omitted := seedRefreshToken(ctx, t, db, omittedApp, owner.UserID, "all")
+ status, body := postTokenRequest(ctx, t, client, refreshForm(omittedApp, omitted))
+ refreshed := requireTokenResponse(t, status, body)
+ require.Equal(t, string(database.ApiKeyScopeCoderAll), refreshed.Scope)
+ require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeCoderAll},
+ mintedKeyScopes(ctx, t, db, refreshed.RefreshToken))
+
+ narrowing := seedRefreshToken(ctx, t, db, narrowingApp, owner.UserID, "all")
+ form := refreshForm(narrowingApp, narrowing)
+ form.Set("scope", "workspace:read")
+ status, body = postTokenRequest(ctx, t, client, form)
+ require.Equal(t, "workspace:read", requireTokenResponse(t, status, body).Scope,
+ "the alias must resolve the same way whether or not a scope is named")
+ })
+
t.Run("BackfilledScopeRefreshesUnrestricted", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -134,12 +324,7 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
app := seedAppWithSecret(t, db, sql.NullString{})
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, string(database.ApiKeyScopeCoderAll))
- form := url.Values{}
- form.Set("grant_type", "refresh_token")
- form.Set("refresh_token", refreshToken)
- form.Set("client_id", app.ID.String())
- form.Set("client_secret", app.ClientSecret)
- status, body := postTokenRequest(ctx, t, client, form)
+ status, body := postTokenRequest(ctx, t, client, refreshForm(app, refreshToken))
refreshed := requireTokenResponse(t, status, body)
require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeCoderAll},
@@ -211,12 +396,7 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
token := exchangeCode(ctx, t, client, app, code, verifier)
setAppAllowlist(ctx, t, db, app, sql.NullString{String: scopeAlsoInCatalog, Valid: true})
- form := url.Values{}
- form.Set("grant_type", "refresh_token")
- form.Set("refresh_token", token.RefreshToken)
- form.Set("client_id", app.ID.String())
- form.Set("client_secret", app.ClientSecret)
- status, body := postTokenRequest(ctx, t, client, form)
+ status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken))
refreshed := requireTokenResponse(t, status, body)
require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh},
@@ -224,6 +404,72 @@ func TestOAuth2TokenExchangeScope(t *testing.T) {
})
}
+// The token endpoint's error_description obeys RFC 6749 §5.2, on the decoded
+// value, and is bounded.
+func TestOAuth2TokenErrorDescription(t *testing.T) {
+ t.Parallel()
+
+ db, pubsub := dbtestutil.NewDB(t)
+ client := coderdtest.New(t, &coderdtest.Options{
+ Database: db,
+ Pubsub: pubsub,
+ })
+ coderdtest.CreateFirstUser(t, client)
+
+ refreshWithScope := func(ctx context.Context, t *testing.T, scope string) string {
+ t.Helper()
+
+ app := seedAppWithSecret(t, db, sql.NullString{})
+ code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
+ token := exchangeCode(ctx, t, client, app, code, verifier)
+
+ form := refreshForm(app, token.RefreshToken)
+ form.Set("scope", scope)
+ status, body := postTokenRequest(ctx, t, client, form)
+ return requireTokenScopeError(t, status, body)
+ }
+
+ t.Run("UnknownScopeEchoIsSanitized", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // No whitespace, or strings.Fields splits it.
+ description := refreshWithScope(ctx, t, "\x07\x1b[31m\"\\caf\u00e9")
+
+ requireNQSCHAR(t, description)
+ require.Contains(t, description, oauth2provider.ReasonUnknownScope,
+ "sanitizing must not cost the client the reason")
+ })
+
+ t.Run("UnknownScopeEchoIsCapped", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ description := refreshWithScope(ctx, t, strings.Repeat("x", oauth2provider.MaxErrorDescription*8))
+
+ requireNQSCHAR(t, description)
+ require.LessOrEqual(t, len(description), oauth2provider.MaxErrorDescription+len(" (truncated)"))
+ require.Contains(t, description, "(truncated)")
+ })
+
+ // The sanitizer runs on every description, so a fixed message outside the
+ // set silently loses characters. A section sign is the easy mistake.
+ t.Run("FixedMessageIsUnchangedBySanitizing", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ app := seedAppWithSecret(t, db, sql.NullString{})
+ code, _ := authorizeCode(ctx, t, client, app.ID.String(), "")
+
+ // Rejected for its length before the code is ever looked up.
+ form := tokenExchangeForm(app, code, "too-short")
+ status, body := postTokenRequest(ctx, t, client, form)
+ description := requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidRequest)
+ requireNQSCHAR(t, description)
+ require.Contains(t, description, "RFC 7636 section 4.1")
+ })
+}
+
// The redemptions race rather than run in sequence: a sequential pair passes
// whether or not the delete arbitrates single use. barrierStore makes that
// overlap deterministic instead of probabilistic.
@@ -512,6 +758,15 @@ func tokenExchangeForm(app appWithSecret, code, verifier string) url.Values {
return form
}
+func refreshForm(app appWithSecret, refreshToken string) url.Values {
+ form := url.Values{}
+ form.Set("grant_type", "refresh_token")
+ form.Set("refresh_token", refreshToken)
+ form.Set("client_id", app.ID.String())
+ form.Set("client_secret", app.ClientSecret)
+ return form
+}
+
func exchangeCode(ctx context.Context, t *testing.T, client *codersdk.Client, app appWithSecret, code, verifier string) codersdk.OAuth2TokenResponse {
t.Helper()
@@ -564,22 +819,40 @@ func requireTokenResponse(t *testing.T, status int, body string) codersdk.OAuth2
return token
}
-// requireTokenGrantError asserts an RFC 6749 §5.2 invalid_grant response and
+// requireTokenError asserts an RFC 6749 §5.2 error response carrying want and
// returns its description.
-func requireTokenGrantError(t *testing.T, status int, body string) string {
+func requireTokenError(t *testing.T, status int, body string, want codersdk.OAuth2ErrorCode) string {
t.Helper()
require.Equal(t, http.StatusBadRequest, status, body)
- var oauthErr struct {
- Error string `json:"error"`
- ErrorDescription string `json:"error_description"`
- }
+ var oauthErr codersdk.OAuth2Error
require.NoError(t, json.Unmarshal([]byte(body), &oauthErr))
- require.Equal(t, string(codersdk.OAuth2ErrorCodeInvalidGrant), oauthErr.Error)
+ require.Equal(t, want, oauthErr.Error)
return oauthErr.ErrorDescription
}
-func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.APIKeyScopes {
+func requireTokenGrantError(t *testing.T, status int, body string) string {
+ t.Helper()
+ return requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidGrant)
+}
+
+func requireTokenScopeError(t *testing.T, status int, body string) string {
+ t.Helper()
+ return requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidScope)
+}
+
+// requireNQSCHAR asserts the set RFC 6749 Appendix A permits in
+// error_description, on the decoded value.
+func requireNQSCHAR(t *testing.T, description string) {
+ t.Helper()
+
+ for _, r := range description {
+ require.True(t, r == 0x20 || r == 0x21 || (r >= 0x23 && r <= 0x5B) || (r >= 0x5D && r <= 0x7E),
+ "%q is outside the NQSCHAR set RFC 6749 Appendix A permits", r)
+ }
+}
+
+func tokenRow(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.OAuth2ProviderAppToken {
t.Helper()
parsed, err := oauth2provider.ParseFormattedSecret(refreshToken)
@@ -587,7 +860,13 @@ func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refre
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(parsed.Prefix))
require.NoError(t, err)
- key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
+ return dbToken
+}
+
+func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.APIKeyScopes {
+ t.Helper()
+
+ key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), tokenRow(ctx, t, db, refreshToken).APIKeyID)
require.NoError(t, err)
return key.Scopes
}
diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md
index 35cb123c1c6..47f9e1bccc1 100644
--- a/docs/admin/integrations/oauth2-provider.md
+++ b/docs/admin/integrations/oauth2-provider.md
@@ -283,7 +283,7 @@ https://coder.example.com/oauth2/authorize?
An application registered through [Dynamic Client Registration](#dynamic-client-registration) can declare a `scope` field, which acts as an allowlist. The client may then request anything that allowlist covers, and is granted the whole allowlist if it requests nothing. Applications created through the web UI or the management API declare no allowlist, so any requested scope is honored and a request that names no scope is granted `coder:all`.
-The consent page states the scope being granted before the user approves it, and refreshing a token keeps the scope originally granted.
+The consent page states the scope being granted before the user approves it. A refresh keeps the scope originally granted; a refresh that names a narrower `scope` applies it to the access token it mints, leaving the grant itself unchanged.
## Discovery Endpoints
@@ -420,8 +420,9 @@ grant what was asked for, it redirects to your registered callback with
`error=invalid_scope` rather than issuing a code. The `error_description`
opens with the requested name that caused the rejection:
-- `unknown or unsupported scope`: this deployment does not offer that scope
- name. Read the current list from `scopes_supported` in
+- `unknown or unsupported scope`: this deployment does not offer that name to
+ OAuth2 clients. It may not exist, or it may exist and be internal-only, which
+ no version offers. Read the current list from `scopes_supported` in
`GET /.well-known/oauth-authorization-server`.
- `scope requests permissions beyond this app's allowed scopes`: the name is
supported, but the application was registered with a narrower `scope`.
@@ -439,6 +440,10 @@ if it was registered without any.
The negotiated scope is recorded on the authorization, shown on the consent
page, and applied to the access token issued when the code is exchanged.
+The token endpoint validates a refresh request's `scope` too, and answers
+`invalid_scope` in the response body rather than by redirect. See
+["invalid_scope" for a refresh that names a scope](#invalid_scope-for-a-refresh-that-names-a-scope).
+
### "invalid_grant" for a scope the deployment cannot mint
`POST /oauth2/tokens` mints the access token with the scope recorded on the
@@ -487,6 +492,45 @@ narrower `scope`, those codes are refused with `scope is no longer allowed by
this app's registered scopes` until they expire, which takes at most ten
minutes. Authorizing again issues a code within the current registration.
+### "invalid_scope" for a refresh that names a scope
+
+`POST /oauth2/tokens` answers HTTP 400 with `error=invalid_scope` when a refresh
+request names a `scope` the server will not grant. This is the token endpoint,
+not the authorization endpoint above: there is no redirect, and the error is in
+the response body.
+
+A refresh may name a `scope` of its own to give up authority. The narrowing
+applies to the access token that refresh mints, and to nothing else. The
+refresh token continues to represent the scope the user consented to, so the
+ceiling does not move and a later refresh may ask for a different part of the
+same grant, or omit `scope` to take the grant whole again.
+
+The request may name any scope the original grant confers **that also appears in
+`scopes_supported`**, including a single permission out of a composite scope, so
+a token granted `coder:workspaces.access` can refresh down to `workspace:read`
+for one call and to `workspace:ssh` for the next. Two descriptions can open the
+`error_description`, each opening with the requested name that caused it:
+
+- `scope requests permissions beyond the scope originally granted; a refresh
+ cannot widen a grant, so authorize again to obtain a broader one`: the name is
+ offered, but the resource owner never granted it.
+- `unknown or unsupported scope`: this deployment does not offer that name to
+ OAuth2 clients, either because it does not exist or because it is internal.
+
+A refused refresh mints nothing and leaves the refresh token usable, so a client
+that asked for too much can retry with less rather than re-authorizing.
+
+Only the resource owner lowers the ceiling, by revoking the token or authorizing
+again with less. This is also what OAuth 2.1 section 4.3.3 requires: a rotated
+refresh token carries the scope of the one presented.
+
+Narrowing a composite scope to the low-level names you can request may drop
+permissions that have no requestable name of their own. `coder:workspaces.create`
+confers `organization_member:read`, which a workspace build needs and which
+`scopes_supported` does not list, so a token narrowed to the fullest set a client
+can name will fail to create a workspace. Refresh without a `scope` to return to
+the composite.
+
### "unsupported_response_type" returned to your callback
Coder supports the authorization code flow only, so `response_type=code` is the single accepted value.
@@ -580,15 +624,27 @@ Public clients (`token_endpoint_auth_method: none`) additionally cannot register
As an experimental feature, the current implementation has limitations:
- A scope allowlist can only be declared at [Dynamic Client Registration](#dynamic-client-registration); applications created through the web UI or the management API cannot restrict which scopes a client may request
-- A client cannot narrow the token's scope on refresh; the `scope` parameter is ignored and the refreshed token always keeps the scope originally granted
- No client credentials grant support
- Implicit grant (`response_type=token`) is not supported; OAuth 2.1 deprecated this flow due to token leakage risks, and a request for it redirects to the registered callback with `unsupported_response_type`
- Limited to opaque access tokens (no JWT support)
+A `scope` on a refresh request was parsed and discarded in earlier versions, so a
+client sending one wider than its grant refreshed successfully. It is now
+enforced, and such a request answers HTTP 400 with `error=invalid_scope`. The
+refresh token is not consumed, so a client that drops the parameter or asks for
+less recovers without re-authorizing.
+
## Standards Compliance
-This implementation follows established OAuth2 standards including [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core), [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12).
-Coder enforces OAuth 2.1 requirements including mandatory PKCE for all authorization code grants, exact redirect URI string matching with the [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) loopback port exception, rejection of the implicit grant, and CSRF protections on consent pages.
+This implementation follows established OAuth2 standards including
+[RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core),
+[RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and the
+[OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16).
+Coder enforces OAuth 2.1 requirements including mandatory PKCE for all
+authorization code grants, exact redirect URI string matching with the
+[RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) loopback port
+exception, rejection of the implicit grant, and CSRF protections on consent
+pages.
## Next Steps