diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1e69491a624..a3f2e65fda0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16829,7 +16829,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/oauth2.Token" + "$ref": "#/definitions/codersdk.OAuth2TokenResponse" } } } @@ -25334,6 +25334,42 @@ const docTemplate = `{ "OAuth2TokenEndpointAuthMethodNone" ] }, + "codersdk.OAuth2TokenResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "expiry": { + "description": "Expiry is not part of RFC 6749 but is included for compatibility with\ngolang.org/x/oauth2.Token and clients that expect a timestamp.", + "type": "string", + "format": "date-time" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "token_type": { + "$ref": "#/definitions/codersdk.OAuth2TokenType" + } + } + }, + "codersdk.OAuth2TokenType": { + "type": "string", + "enum": [ + "Bearer", + "DPoP" + ], + "x-enum-varnames": [ + "OAuth2TokenTypeBearer", + "OAuth2TokenTypeDPoP" + ] + }, "codersdk.OAuthConversionResponse": { "type": "object", "properties": { @@ -32874,31 +32910,6 @@ const docTemplate = `{ } } }, - "oauth2.Token": { - "type": "object", - "properties": { - "access_token": { - "description": "AccessToken is the token that authorizes and authenticates\nthe requests.", - "type": "string" - }, - "expires_in": { - "description": "ExpiresIn is the OAuth2 wire format \"expires_in\" field,\nwhich specifies how many seconds later the token expires,\nrelative to an unknown time base approximately around \"now\".\nIt is the application's responsibility to populate\n` + "`" + `Expiry` + "`" + ` from ` + "`" + `ExpiresIn` + "`" + ` when required.", - "type": "integer" - }, - "expiry": { - "description": "Expiry is the optional expiration time of the access token.\n\nIf zero, [TokenSource] implementations will reuse the same\ntoken forever and RefreshToken or equivalent\nmechanisms for that TokenSource will not be used.", - "type": "string" - }, - "refresh_token": { - "description": "RefreshToken is a token that's used by the application\n(as opposed to the user) to refresh the access token\nif it expires.", - "type": "string" - }, - "token_type": { - "description": "TokenType is the type of token.\nThe Type method returns either this or \"Bearer\", the default.", - "type": "string" - } - } - }, "regexp.Regexp": { "type": "object" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 15a095d51f7..efcbd3e4b9e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14959,7 +14959,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/oauth2.Token" + "$ref": "#/definitions/codersdk.OAuth2TokenResponse" } } } @@ -23164,6 +23164,36 @@ "OAuth2TokenEndpointAuthMethodNone" ] }, + "codersdk.OAuth2TokenResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "expiry": { + "description": "Expiry is not part of RFC 6749 but is included for compatibility with\ngolang.org/x/oauth2.Token and clients that expect a timestamp.", + "type": "string", + "format": "date-time" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "token_type": { + "$ref": "#/definitions/codersdk.OAuth2TokenType" + } + } + }, + "codersdk.OAuth2TokenType": { + "type": "string", + "enum": ["Bearer", "DPoP"], + "x-enum-varnames": ["OAuth2TokenTypeBearer", "OAuth2TokenTypeDPoP"] + }, "codersdk.OAuthConversionResponse": { "type": "object", "properties": { @@ -30338,31 +30368,6 @@ } } }, - "oauth2.Token": { - "type": "object", - "properties": { - "access_token": { - "description": "AccessToken is the token that authorizes and authenticates\nthe requests.", - "type": "string" - }, - "expires_in": { - "description": "ExpiresIn is the OAuth2 wire format \"expires_in\" field,\nwhich specifies how many seconds later the token expires,\nrelative to an unknown time base approximately around \"now\".\nIt is the application's responsibility to populate\n`Expiry` from `ExpiresIn` when required.", - "type": "integer" - }, - "expiry": { - "description": "Expiry is the optional expiration time of the access token.\n\nIf zero, [TokenSource] implementations will reuse the same\ntoken forever and RefreshToken or equivalent\nmechanisms for that TokenSource will not be used.", - "type": "string" - }, - "refresh_token": { - "description": "RefreshToken is a token that's used by the application\n(as opposed to the user) to refresh the access token\nif it expires.", - "type": "string" - }, - "token_type": { - "description": "TokenType is the type of token.\nThe Type method returns either this or \"Bearer\", the default.", - "type": "string" - } - } - }, "regexp.Regexp": { "type": "object" }, diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d3e0834ccf8..3d413e2ef35 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -289,11 +289,10 @@ func (s APIKeyScopes) Has(target APIKeyScope) bool { } // expandRBACScope merges the permissions of all scopes in the list into a -// single RBAC scope. If the list is empty, it defaults to rbac.ScopeAll for -// backward compatibility. This method is internal; use ScopeSet() to combine -// scopes with the API key's allow list for authorization. +// single RBAC scope. An empty list is an error rather than rbac.ScopeAll, which +// would widen a key rather than fail it. This method is internal; use +// ScopeSet() to combine scopes with the API key's allow list for authorization. func (s APIKeyScopes) expandRBACScope() (rbac.Scope, error) { - // Default to ScopeAll for backward compatibility when no scopes provided. if len(s) == 0 { return rbac.Scope{}, xerrors.New("no scopes provided") } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 0df20c7c906..334ca673f3d 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -153,7 +153,7 @@ func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Param code_verifier formData string false "PKCE code verifier, required if grant_type=authorization_code. 43-128 characters per RFC 7636." // @Param refresh_token formData string false "Refresh token, required if grant_type=refresh_token" // @Param grant_type formData codersdk.OAuth2ProviderGrantType true "Grant type" -// @Success 200 {object} oauth2.Token +// @Success 200 {object} codersdk.OAuth2TokenResponse // @Router /oauth2/tokens [post] func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc { return oauth2provider.Tokens(api.Database, api.DeploymentValues.Sessions) diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index 9941da163f1..b7437caeaeb 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -65,7 +65,7 @@ func noScopeAllowlist(appScope sql.NullString) bool { // allowlist. A rejection is an RFC 6749 §4.1.2.1 invalid_scope. // // allowlist request result -// absent absent ApiKeyScopeCoderAll, the pre-enforcement grant +// absent absent ApiKeyScopeCoderAll, an unrestricted grant // absent present the request // present absent the allowlist, catalog-filtered (RFC 6749 §3.3 default) // present present the request, once shown to be within the allowlist @@ -486,8 +486,8 @@ func ProcessAuthorize(db database.Store, logger slog.Logger) http.HandlerFunc { StateHash: hashOAuth2State(params.state), RedirectUri: sql.NullString{String: params.redirectURL.String(), Valid: params.redirectURIProvided}, // The negotiated scope, not the requested one. The exchange - // copies it onto the token row but not yet onto the API key it - // mints, so this records what was agreed, not what is enforced. + // copies it onto the token row and the API key it mints, so + // this bounds the issued token. Scope: grantedScope, }) if err != nil { diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index c1485cc6c25..06c947ec8ec 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "slices" + "strings" "time" "github.com/google/uuid" @@ -39,8 +40,32 @@ var ( // errConflictingClientAuth means the client provided credentials in both the // request body and HTTP Basic, but they did not match. errConflictingClientAuth = xerrors.New("conflicting client authentication") + // errUnmintableScope means the scope stored on a grant names something no + // API key can be minted from. + errUnmintableScope = xerrors.New("scope is not a valid API key scope") ) +// 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 +// unrestricted key. +func scopeStringToAPIKeyScopes(scope string) (database.APIKeyScopes, error) { + names := strings.Fields(scope) + if len(names) == 0 { + return nil, xerrors.Errorf("'%s': %w", scope, errUnmintableScope) + } + + scopes := make(database.APIKeyScopes, 0, len(names)) + for _, name := range names { + s := database.APIKeyScope(name) + if !s.Valid() { + return nil, xerrors.Errorf("'%s': %w", name, errUnmintableScope) + } + scopes = append(scopes, s) + } + return scopes, nil +} + // extractTokenRequest parses and validates the /oauth2/tokens form. It takes // the app because whether client_secret is required depends on the client // type. @@ -227,6 +252,14 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired") return } + if errors.Is(err, errUnmintableScope) { + // Not invalid_scope: RFC 6749 §5.2 scopes that to what the client + // requested, and this value is stored state the client cannot + // change by asking differently. The grant is what is unusable, and + // re-authorizing is the only way out, so invalid_grant. + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, err.Error()) + return + } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to exchange token", @@ -375,13 +408,19 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database return codersdk.OAuth2TokenResponse{}, err } + // Without this the key defaults to coder:all, discarding the negotiation. + scopes, err := scopeStringToAPIKeyScopes(dbCode.Scope) + if err != nil { + return codersdk.OAuth2TokenResponse{}, err + } + // Generate the API key we will swap for the code. - // TODO: We are ignoring scopes for now. tokenName := fmt.Sprintf("%s_%s_oauth_session_token", dbCode.UserID, app.ID) key, sessionToken, err := apikey.Generate(apikey.CreateParams{ UserID: dbCode.UserID, LoginType: database.LoginTypeOAuth2ProviderApp, DefaultLifetime: lifetimes.DefaultDuration.Value(), + Scopes: scopes, // For now, we allow only one token per app and user at a time. TokenName: tokenName, }) @@ -389,7 +428,9 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database return codersdk.OAuth2TokenResponse{}, err } - // Grab the user roles so we can perform the exchange as the user. + // Grab the user roles so we can perform the exchange as the user. ScopeAll + // because this actor writes the key: narrowing it to the granted scope would + // deny api_key:create. The issued token is bounded by api_keys.scopes. actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, rbac.ScopeAll) if err != nil { return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err) @@ -454,6 +495,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database TokenType: codersdk.OAuth2TokenTypeBearer, RefreshToken: refreshToken.Formatted, ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()), + Scope: dbCode.Scope, Expiry: &key.ExpiresAt, }, nil } @@ -507,6 +549,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut return codersdk.OAuth2TokenResponse{}, err } + // ScopeAll for the same reason as in authorizationCodeGrant. actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll) if err != nil { return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err) @@ -518,13 +561,19 @@ 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) + if err != nil { + return codersdk.OAuth2TokenResponse{}, err + } + // Generate the new API key. - // TODO: We are ignoring scopes for now. tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID) key, sessionToken, err := apikey.Generate(apikey.CreateParams{ UserID: prevKey.UserID, LoginType: database.LoginTypeOAuth2ProviderApp, DefaultLifetime: lifetimes.DefaultDuration.Value(), + Scopes: scopes, // For now, we allow only one token per app and user at a time. TokenName: tokenName, }) @@ -582,6 +631,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, Expiry: &key.ExpiresAt, }, nil } diff --git a/coderd/oauth2provider/tokens_internal_test.go b/coderd/oauth2provider/tokens_internal_test.go index 2cab754190c..686a87f6a64 100644 --- a/coderd/oauth2provider/tokens_internal_test.go +++ b/coderd/oauth2provider/tokens_internal_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) @@ -26,6 +27,61 @@ var ( publicApp = database.OAuth2ProviderApp{ClientType: database.OAuth2ProviderAppClientTypePublic} ) +func TestScopeStringToAPIKeyScopes(t *testing.T) { + t.Parallel() + + t.Run("EveryNameKept", func(t *testing.T) { + t.Parallel() + + scopes, err := scopeStringToAPIKeyScopes("workspace:ssh template:read") + require.NoError(t, err) + require.Equal(t, database.APIKeyScopes{ + database.ApiKeyScopeWorkspaceSsh, + database.ApiKeyScopeTemplateRead, + }, scopes) + }) + + // The catalog and the api_key_scope enum are maintained separately. A name + // negotiable at authorization but unmintable at exchange leaves the client + // holding a code it can never redeem. + t.Run("EveryCatalogNameMintable", func(t *testing.T) { + t.Parallel() + + // ExternalScopeNames omits the aliases IsExternalScope accepts, and the + // catalog cannot enumerate them, so a new alias has to be added here. + names := append(rbac.ExternalScopeNames(), "all", "application_connect") + require.NotEmpty(t, names) + for _, name := range names { + require.Truef(t, rbac.IsExternalScope(rbac.ScopeName(name)), + "scope %q is not negotiable, so this loop is not driving the catalog", name) + + canonical := string(rbac.CanonicalScopeName(rbac.ScopeName(name))) + scopes, err := scopeStringToAPIKeyScopes(canonical) + require.NoErrorf(t, err, "scope %q can be negotiated but not minted", name) + require.Equal(t, database.APIKeyScopes{database.APIKeyScope(canonical)}, scopes) + } + }) + + t.Run("UnknownNameRejectsTheWholeList", func(t *testing.T) { + t.Parallel() + + _, err := scopeStringToAPIKeyScopes("workspace:ssh not_a_real_scope") + require.ErrorIs(t, err, errUnmintableScope) + require.Contains(t, err.Error(), "not_a_real_scope") + }) + + // Unreachable through the NOT NULL column, but pinned: apikey.Generate reads + // an empty list as unrestricted, so anything but an error widens the grant. + t.Run("EmptyRejected", func(t *testing.T) { + t.Parallel() + + for _, scope := range []string{"", " "} { + _, err := scopeStringToAPIKeyScopes(scope) + require.ErrorIs(t, err, errUnmintableScope, "scope %q", scope) + } + }) +} + // 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 new file mode 100644 index 00000000000..53d5d84f514 --- /dev/null +++ b/coderd/oauth2provider/tokens_test.go @@ -0,0 +1,332 @@ +package oauth2provider_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/oauth2provider" + "github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// Cases assert against the api_keys row, not the response: that row is what +// dbauthz reads on each later request. +func TestOAuth2TokenExchangeScope(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + owner := coderdtest.CreateFirstUser(t, client) + + t.Run("NegotiatedScopeMintsNarrowKey", 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) + + require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeWorkspaceSsh}, + mintedKeyScopes(ctx, t, db, token.RefreshToken)) + }) + + t.Run("RefreshDoesNotWidenTheScope", 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 := 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) + 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) + }) + + // 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. + t.Run("UnrestrictedGrantMintsCoderAll", 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) + + require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeCoderAll}, + mintedKeyScopes(ctx, t, db, token.RefreshToken)) + }) + + // coder:workspaces.access carries template:read but not template:delete. The + // grant's user owns the deployment, so the role permits both and the scope is + // all that stands between the client and the deletion. + t.Run("IssuedTokenBoundsTheAPI", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }) + + 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, + "RFC 6749 §5.1: a request that named no scope must be told what it got") + + asApp := codersdk.New(client.URL) + asApp.SetSessionToken(token.AccessToken) + + got, err := asApp.Template(ctx, tpl.ID) + require.NoError(t, err, "template:read is within the negotiated scope") + require.Equal(t, tpl.ID, got.ID) + + err = asApp.DeleteTemplate(ctx, tpl.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + // Grants predating the scope columns carry what migration 000569 backfilled: + // coder:all. Seeded the way the migration leaves it rather than exchanged. + t.Run("BackfilledScopeRefreshesUnrestricted", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }) + + 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) + refreshed := requireTokenResponse(t, status, body) + + require.Equal(t, database.APIKeyScopes{database.ApiKeyScopeCoderAll}, + mintedKeyScopes(ctx, t, db, refreshed.RefreshToken)) + + asApp := codersdk.New(client.URL) + asApp.SetSessionToken(refreshed.AccessToken) + require.NoError(t, asApp.DeleteTemplate(ctx, tpl.ID), + "an unrestricted grant must still reach what it reached before") + }) + + // Authorization cannot write such a row, so it is seeded: the case covers a + // name removed since, or a row written by another version of this server. + t.Run("StoredScopeOutsideEnumRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + app := seedAppWithSecret(t, db, sql.NullString{String: scopeInCatalog, Valid: true}) + verifier, challenge := oauth2providertest.GeneratePKCE(t) + code := seedCode(ctx, t, db, app.ID, owner.UserID, challenge, scopeOutOfCatalog) + + status, body := postTokenRequest(ctx, t, client, tokenExchangeForm(app, code, verifier)) + + require.Equal(t, http.StatusBadRequest, status, body) + var oauthErr struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + require.NoError(t, json.Unmarshal([]byte(body), &oauthErr)) + require.Equal(t, string(codersdk.OAuth2ErrorCodeInvalidGrant), oauthErr.Error) + require.Contains(t, oauthErr.ErrorDescription, scopeOutOfCatalog, + "an operator cannot act on this without knowing which stored name is the problem") + }) +} + +// appWithSecret is seeded directly because the management API registers no +// scope allowlist, and the allowlist is what these tests turn. +type appWithSecret struct { + database.OAuth2ProviderApp + ClientSecret string + SecretID uuid.UUID +} + +func seedAppWithSecret(t *testing.T, db database.Store, allowlist sql.NullString) appWithSecret { + t.Helper() + + app := dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{ + Name: testutil.GetRandomName(t), + CallbackURL: appCallbackURL, + Scope: allowlist, + }) + + secret, err := oauth2provider.GenerateSecret() + require.NoError(t, err) + dbSecret := dbgen.OAuth2ProviderAppSecret(t, db, database.OAuth2ProviderAppSecret{ + AppID: app.ID, + SecretPrefix: []byte(secret.Prefix), + HashedSecret: secret.Hashed, + }) + + return appWithSecret{ + OAuth2ProviderApp: app, + ClientSecret: secret.Formatted, + SecretID: dbSecret.ID, + } +} + +// Returns the secret that redeems the row. dbgen is unusable here: it derives +// expires_at from created_at, leaving the row already expired. +func seedRefreshToken(ctx context.Context, t *testing.T, db database.Store, app appWithSecret, userID uuid.UUID, scope string) string { + t.Helper() + + key, _ := dbgen.APIKey(t, db, database.APIKey{ + UserID: userID, + LoginType: database.LoginTypeOAuth2ProviderApp, + }) + + secret, err := oauth2provider.GenerateSecret() + require.NoError(t, err) + + _, err = db.InsertOAuth2ProviderAppToken(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppTokenParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + ExpiresAt: dbtime.Now().Add(time.Hour), + HashPrefix: []byte(secret.Prefix), + RefreshHash: secret.Hashed, + AppID: app.ID, + AppSecretID: uuid.NullUUID{UUID: app.SecretID, Valid: true}, + APIKeyID: key.ID, + UserID: userID, + Scope: scope, + }) + require.NoError(t, err) + return secret.Formatted +} + +// Returns the issued code with the verifier that redeems it. authorizeQuery +// discards its own verifier, so the challenge is swapped for one kept here. +func authorizeCode(ctx context.Context, t *testing.T, client *codersdk.Client, clientID, scope string) (code, verifier string) { + t.Helper() + + verifier, challenge := oauth2providertest.GeneratePKCE(t) + query := authorizeQuery(t, clientID, scope) + query.Set("code_challenge", challenge) + + resp := sendAuthorizeRequest(ctx, t, client, http.MethodPost, query) + defer resp.Body.Close() + + require.Equal(t, http.StatusFound, resp.StatusCode) + location, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + code = location.Query().Get("code") + require.NotEmpty(t, code, "authorization did not issue a code") + return code, verifier +} + +// Writes a code the authorize endpoint would refuse to write. dbgen is unusable +// for the same reason as in seedRefreshToken. +func seedCode(ctx context.Context, t *testing.T, db database.Store, appID, userID uuid.UUID, challenge, scope string) string { + t.Helper() + + secret, err := oauth2provider.GenerateSecret() + require.NoError(t, err) + + _, err = db.InsertOAuth2ProviderAppCode(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppCodeParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + ExpiresAt: dbtime.Now().Add(time.Hour), + SecretPrefix: []byte(secret.Prefix), + HashedSecret: secret.Hashed, + AppID: appID, + UserID: userID, + CodeChallenge: sql.NullString{String: challenge, Valid: true}, + CodeChallengeMethod: sql.NullString{String: "S256", Valid: true}, + Scope: scope, + }) + require.NoError(t, err) + return secret.Formatted +} + +func tokenExchangeForm(app appWithSecret, code, verifier string) url.Values { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", app.ID.String()) + form.Set("client_secret", app.ClientSecret) + form.Set("code_verifier", verifier) + return form +} + +func exchangeCode(ctx context.Context, t *testing.T, client *codersdk.Client, app appWithSecret, code, verifier string) codersdk.OAuth2TokenResponse { + t.Helper() + + status, body := postTokenRequest(ctx, t, client, tokenExchangeForm(app, code, verifier)) + return requireTokenResponse(t, status, body) +} + +func postTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values) (int, string) { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/tokens", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + return resp.StatusCode, readBody(t, resp) +} + +func requireTokenResponse(t *testing.T, status int, body string) codersdk.OAuth2TokenResponse { + t.Helper() + + require.Equal(t, http.StatusOK, status, body) + var token codersdk.OAuth2TokenResponse + require.NoError(t, json.Unmarshal([]byte(body), &token)) + require.NotEmpty(t, token.AccessToken) + require.NotEmpty(t, token.RefreshToken) + return token +} + +func mintedKeyScopes(ctx context.Context, t *testing.T, db database.Store, refreshToken string) database.APIKeyScopes { + t.Helper() + + parsed, err := oauth2provider.ParseFormattedSecret(refreshToken) + require.NoError(t, err) + + dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(parsed.Prefix)) + require.NoError(t, err) + key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.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 a3a89275324..bfe6e2eeb5f 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -261,6 +261,28 @@ confidential clients must include PKCE parameters: "$CODER_URL/oauth2/tokens" ``` +## Scopes + +An access token is bounded by the scope negotiated when the user authorized it, on top of that user's own permissions. A token can never do more than its user can. + +Scope names come from the same vocabulary as [API key scopes](../users/sessions-tokens.md#api-key-scopes): individual `resource:action` names such as `workspace:ssh`, and `coder:` composites such as `coder:workspaces.access` that stand for a set of them. `coder:all` records an unrestricted grant. + +A client asks for a scope with the `scope` parameter on the authorization request, space separated: + +```txt +https://coder.example.com/oauth2/authorize? + client_id=your-client-id& + response_type=code& + scope=coder:workspaces.access& + code_challenge=$CODE_CHALLENGE& + code_challenge_method=S256& + redirect_uri=https://yourapp.example.com/callback +``` + +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. + ## Discovery Endpoints Coder provides OAuth2 discovery endpoints for programmatic integration: @@ -404,9 +426,19 @@ opens with the name that caused the rejection: Omitting `scope` requests the application's registered scopes, or full access if it was registered without any. -The negotiated scope is recorded on the authorization and shown on the consent -page. It does not yet restrict what the issued token can do (see -[Limitations](#limitations)). +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. + +### "invalid_grant" for a scope the deployment cannot mint + +`POST /oauth2/tokens` mints the access token with the scope recorded on the +authorization code, or on the refresh token when refreshing. If that stored +scope names something this deployment cannot mint, the exchange answers HTTP +400 with `error=invalid_grant` and an `error_description` naming the value. + +The usual cause is a grant made against a scope the deployment has since +dropped. Authorize again to negotiate a scope it still supports; the stored +scope is not something the client can change by requesting a different one. ### "PKCE verification failed" @@ -448,7 +480,8 @@ Public clients (`token_endpoint_auth_method: none`) additionally cannot register As an experimental feature, the current implementation has limitations: -- No scope system - all tokens have full API access +- 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 diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 1285d36ae17..8093e5f4466 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -5303,17 +5303,18 @@ grant_type: authorization_code { "access_token": "string", "expires_in": 0, - "expiry": "string", + "expiry": "2019-08-24T14:15:22Z", "refresh_token": "string", - "token_type": "string" + "scope": "string", + "token_type": "Bearer" } ``` ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|----------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [oauth2.Token](schemas.md#oauth2token) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2TokenResponse](schemas.md#codersdkoauth2tokenresponse) | ## Delete OAuth2 application tokens diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 935129126a4..c98b3ba8fc0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -11156,6 +11156,44 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-----------------------------------------------------| | `client_secret_basic`, `client_secret_post`, `none` | +## codersdk.OAuth2TokenResponse + +```json +{ + "access_token": "string", + "expires_in": 0, + "expiry": "2019-08-24T14:15:22Z", + "refresh_token": "string", + "scope": "string", + "token_type": "Bearer" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------|------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `access_token` | string | false | | | +| `expires_in` | integer | false | | | +| `expiry` | string | false | | Expiry is not part of RFC 6749 but is included for compatibility with golang.org/x/oauth2.Token and clients that expect a timestamp. | +| `refresh_token` | string | false | | | +| `scope` | string | false | | | +| `token_type` | [codersdk.OAuth2TokenType](#codersdkoauth2tokentype) | false | | | + +## codersdk.OAuth2TokenType + +```json +"Bearer" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|------------------| +| `Bearer`, `DPoP` | + ## codersdk.OAuthConversionResponse ```json @@ -20972,29 +21010,6 @@ None | `udp` | boolean | false | | a UDP STUN round trip completed | | `upnP` | string | false | | Upnp is whether UPnP appears present on the LAN. Empty means not checked. | -## oauth2.Token - -```json -{ - "access_token": "string", - "expires_in": 0, - "expiry": "string", - "refresh_token": "string", - "token_type": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------|---------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `access_token` | string | false | | Access token is the token that authorizes and authenticates the requests. | -| `expires_in` | integer | false | | Expires in is the OAuth2 wire format "expires_in" field, which specifies how many seconds later the token expires, relative to an unknown time base approximately around "now". It is the application's responsibility to populate `Expiry` from `ExpiresIn` when required. | -|`expiry`|string|false||Expiry is the optional expiration time of the access token. -If zero, [TokenSource] implementations will reuse the same token forever and RefreshToken or equivalent mechanisms for that TokenSource will not be used.| -|`refresh_token`|string|false||Refresh token is a token that's used by the application (as opposed to the user) to refresh the access token if it expires.| -|`token_type`|string|false||Token type is the type of token. The Type method returns either this or "Bearer", the default.| - ## regexp.Regexp ```json