From 48cb5a473e160e6dd006bc773697e15b2135b9d1 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sat, 29 Aug 2026 03:53:41 +0000 Subject: [PATCH] feat: let an OAuth2 refresh narrow the granted scope RFC 6749 section 6 bounds a refresh by the scope originally granted, so the scope parameter may only give authority up. A refresh that names a scope now mints and persists that narrower scope, and later refreshes are bounded by it in turn; an omitted parameter keeps the grant as it stands. The comparison is coverage rather than membership, matching negotiateScope, so a grant of coder:workspaces.access can narrow to workspace:ssh and an unrestricted coder:all grant can narrow at all. A request beyond the grant is refused with invalid_scope before anything is read or issued, leaving the refresh token usable. --- coderd/oauth2provider/authorize.go | 29 +++-- .../oauth2provider/authorize_internal_test.go | 1 + coderd/oauth2provider/tokens.go | 66 ++++++++-- coderd/oauth2provider/tokens_internal_test.go | 106 ++++++++++++++++ coderd/oauth2provider/tokens_test.go | 114 +++++++++++++++--- docs/admin/integrations/oauth2-provider.md | 13 +- 6 files changed, 282 insertions(+), 47 deletions(-) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 36e09778612..93ec0236938 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -42,8 +42,10 @@ var ( // still granted when a listed composite already confers it. errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes") // A comparison that failed outright. 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") + // internals, so it is logged rather than rendered. It names no ceiling + // because the comparison runs against the app's allowlist at authorization + // and against the token's own grant at refresh. + errCoverageUndecidable = xerrors.New("scope coverage could not be determined") ) // canonicalScopes rewrites each name to the spelling the api_key_scope enum @@ -87,23 +89,24 @@ func grantableScopes(appScope string) []string { return canonicalScopes(filtered) } -// firstScopeOutsideAllowlist returns the first scope in granted that the -// allowlist does not confer, or "" when it confers all of them. The check is -// coverage rather than membership: an app allowed `coder:workspaces.access` -// covers `workspace:read`. Both arguments must already be canonical, and an -// undecidable comparison refuses rather than grants. -func firstScopeOutsideAllowlist(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, allowlist, granted []string) (string, error) { - allowedNames := make([]rbac.ScopeName, 0, len(allowlist)) - for _, a := range allowlist { +// firstScopeNotCovered returns the first scope in requested that the ceiling +// does not confer, or "" when it confers all of them. The check is coverage +// rather than membership: a ceiling of `coder:workspaces.access` covers +// `workspace:read`. Both arguments must already be canonical, and an +// undecidable comparison refuses rather than grants. The ceiling is the app's +// allowlist at authorization and the token's own grant at refresh. +func firstScopeNotCovered(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, ceiling, requested []string) (string, error) { + allowedNames := make([]rbac.ScopeName, 0, len(ceiling)) + for _, a := range ceiling { allowedNames = append(allowedNames, rbac.ScopeName(a)) } - for _, s := range granted { + for _, s := range requested { covered, err := rbac.ScopesCover(allowedNames, rbac.ScopeName(s)) if err != nil { logger.Warn(ctx, "oauth2 scope coverage could not be determined", slog.Error(err), slog.F("app_id", app.ID.String()), - slog.F("app_scope", app.Scope.String), + slog.F("ceiling", strings.Join(ceiling, " ")), slog.F("scope", s)) return "", xerrors.Errorf("%q: %w", s, errCoverageUndecidable) } @@ -160,7 +163,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, app, allowlist, granted) + outside, err := firstScopeNotCovered(ctx, logger, app, allowlist, granted) if err != nil { return "", err } diff --git a/coderd/oauth2provider/authorize_internal_test.go b/coderd/oauth2provider/authorize_internal_test.go index 55fbedca68c..7a9c5c7e13b 100644 --- a/coderd/oauth2provider/authorize_internal_test.go +++ b/coderd/oauth2provider/authorize_internal_test.go @@ -255,6 +255,7 @@ var ( ReasonNoGrantableScope = errNoGrantableScope.Error() ReasonScopeNotAllowed = errScopeNotAllowed.Error() ReasonStaleScope = errStaleScope.Error() + ReasonScopeNotGranted = errScopeNotGranted.Error() ) func TestNoScopeAllowlist(t *testing.T) { diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 52180c08968..aa221530ed0 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 its // authorization 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") + // errScopeNotGranted means a refresh asked for a scope the original grant + // does not confer. Distinct from errScopeNotAllowed, whose ceiling is the + // app's current allowlist: a refresh is bounded by what was granted. + errScopeNotGranted = xerrors.New("scope requests permissions beyond the scope originally granted") ) // scopeStillCoveredByAllowlist re-checks the scope a grant was issued with @@ -68,7 +72,7 @@ func scopeStillCoveredByAllowlist(ctx context.Context, logger slog.Logger, app d // Canonicalized rather than assumed: the row may have been written by // another version of this server. - outside, err := firstScopeOutsideAllowlist(ctx, logger, app, allowlist, canonicalScopes(strings.Fields(granted))) + outside, err := firstScopeNotCovered(ctx, logger, app, allowlist, canonicalScopes(strings.Fields(granted))) if err != nil { return err } @@ -78,6 +82,39 @@ func scopeStillCoveredByAllowlist(ctx context.Context, logger slog.Logger, app d return nil } +// narrowGrantedScope decides the scope a refreshed token carries. RFC 6749 §6 +// bounds a refresh by the scope originally granted, so the request may only +// give authority up; an omitted request keeps the grant as it stands. +// +// The comparison is coverage rather than membership, as in negotiateScope: a +// grant of `coder:workspaces.access` confers `workspace:read`, and an +// unrestricted `coder:all` grant confers every scope, which membership could +// never narrow. +func narrowGrantedScope(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, granted string, requested []string) (string, error) { + if len(requested) == 0 { + return granted, nil + } + + // Checked before the comparison so a typo reads as an unknown scope rather + // than as a coverage RBAC could not expand. + for _, s := range requested { + if !rbac.IsExternalScope(rbac.ScopeName(s)) { + return "", xerrors.Errorf("'%s': %w", s, errUnknownScope) + } + } + + narrowed := canonicalScopes(requested) + // Canonicalized rather than assumed, as in scopeStillCoveredByAllowlist. + outside, err := firstScopeNotCovered(ctx, logger, app, canonicalScopes(strings.Fields(granted)), narrowed) + if err != nil { + return "", err + } + if outside != "" { + return "", xerrors.Errorf("'%s': %w", outside, errScopeNotGranted) + } + return strings.Join(narrowed, " "), nil +} + // scopeStringToAPIKeyScopes converts the scope persisted on an authorization // code or refresh token 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; @@ -251,7 +288,7 @@ 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: @@ -280,10 +317,12 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired") return } - // The grant is well-formed and its stored scope cannot be honored, so a - // defined OAuth2 failure beats a 500. + // The grant is well-formed and either its stored scope cannot be honored + // or the request asked beyond it, so a defined OAuth2 failure beats a + // 500. if errors.Is(err, errUnmintableScope) || errors.Is(err, errStaleScope) || - errors.Is(err, errNoGrantableScope) || errors.Is(err, errCoverageUndecidable) { + errors.Is(err, errNoGrantableScope) || errors.Is(err, errCoverageUndecidable) || + errors.Is(err, errUnknownScope) || errors.Is(err, errScopeNotGranted) { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidScope, err.Error()) return } @@ -540,7 +579,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog. }, 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 { @@ -582,6 +621,11 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut } } + grantedScope, err := narrowGrantedScope(ctx, logger, app, 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) @@ -601,8 +645,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(grantedScope) if err != nil { return codersdk.OAuth2TokenResponse{}, err } @@ -652,10 +695,7 @@ 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. - Scope: dbToken.Scope, + Scope: grantedScope, }) if err != nil { return xerrors.Errorf("insert oauth2 refresh token: %w", err) @@ -671,7 +711,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: grantedScope, Expiry: &key.ExpiresAt, }, nil } diff --git a/coderd/oauth2provider/tokens_internal_test.go b/coderd/oauth2provider/tokens_internal_test.go index 3709317e79a..8e9c535710b 100644 --- a/coderd/oauth2provider/tokens_internal_test.go +++ b/coderd/oauth2provider/tokens_internal_test.go @@ -177,6 +177,112 @@ func TestScopeStillCoveredByAllowlist(t *testing.T) { } } +func TestNarrowGrantedScope(t *testing.T) { + t.Parallel() + + const ( + inCatalog = "coder:workspaces.access" + alsoInCatalog = "coder:templates.build" + ) + + 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, + }, + { + name: "LegacyAliasCanonicalized", + granted: string(database.ApiKeyScopeCoderAll), + requested: []string{"all"}, + want: "coder:all", + }, + { + name: "DuplicateRequestedScopesDeduplicated", + granted: inCatalog, + requested: []string{"workspace:ssh", "workspace:ssh"}, + want: "workspace:ssh", + }, + { + name: "GrantOutsideTheCatalogUndecidable", + granted: "some_removed_scope", + requested: []string{"workspace:ssh"}, + wantErr: errCoverageUndecidable, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := database.OAuth2ProviderApp{ID: uuid.New()} + got, err := narrowGrantedScope(t.Context(), slogtest.Make(t, nil), app, 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 c1beffe550b..bf8a7c05cd1 100644 --- a/coderd/oauth2provider/tokens_test.go +++ b/coderd/oauth2provider/tokens_test.go @@ -58,18 +58,89 @@ 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 the narrowing is a + // genuine reduction of the authority the user consented to. + t.Run("RefreshNarrowsTheScope", 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", tokenRow(ctx, t, db, refreshed.RefreshToken).Scope, + "the next refresh inherits the persisted column, so a widened one would undo the narrowing") require.Equal(t, "workspace:ssh", refreshed.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) + require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh}, + mintedKeyScopes(ctx, t, db, token.RefreshToken), + "a rejected refresh issues nothing and 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) + }) + + // RFC 6749 §5.1: a token whose scope differs from what the client asked for + // must be told what it got, which covers both a request that named nothing + // and one that narrowed. + 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) + + form := refreshForm(app, token.RefreshToken) + form.Set("scope", "workspace:ssh") + status, body := postTokenRequest(ctx, t, client, form) + require.Equal(t, "workspace:ssh", requireTokenResponse(t, status, body).Scope) + }) + // apikey.Generate defaults an empty scope list to coder:all, so this passes // even if the exchange drops the scope. It pins the unrestricted path, not // that the scope was applied. @@ -131,12 +202,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}, @@ -206,12 +272,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}, @@ -417,6 +478,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() @@ -464,7 +534,7 @@ func requireTokenScopeError(t *testing.T, status int, body string) string { return oauthErr.ErrorDescription } -func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.APIKeyScopes { +func tokenRow(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.OAuth2ProviderAppToken { t.Helper() parsed, err := oauth2provider.ParseFormattedSecret(refreshToken) @@ -472,7 +542,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 c886fef87c0..086621eb9d4 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -262,7 +262,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. Refreshing a token keeps the scope originally granted unless the refresh request names a narrower one. ## Discovery Endpoints @@ -419,6 +419,16 @@ it by the scope originally granted, so narrowing an application's registered scopes takes effect at the next authorization rather than cutting short a session already in progress. +A refresh may name a `scope` of its own to give up authority. The refreshed +token carries that narrower scope, and later refreshes are bounded by it in +turn. The request may name anything the original grant confers, including a +single permission out of a composite scope, so a token granted +`coder:workspaces.access` can refresh down to `workspace:read`. Asking for +more is refused with `scope requests permissions beyond the scope originally +granted`, and a scope this deployment does not define with `unknown or +unsupported scope`. A refused refresh mints nothing and leaves the refresh +token usable. + ### "PKCE verification failed" Verify that the `code_verifier` used in the token request matches the one used to generate the `code_challenge`. @@ -460,7 +470,6 @@ 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 requests return