From 4bd7c7b7e08a9113db0e5c0bbb40ba140acfcd53 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 22 Oct 2025 15:18:42 -0500 Subject: [PATCH] feat: implement oauth2 RFC 7009 token revocation endpoint (#20362) Adds RFC 7009 token revocation endpoint --- coderd/apidoc/docs.go | 42 ++++++ coderd/apidoc/swagger.json | 38 +++++ coderd/coderd.go | 10 ++ coderd/database/db2sdk/db2sdk.go | 3 + coderd/database/dbauthz/dbauthz.go | 34 +++++ coderd/oauth2.go | 13 ++ coderd/oauth2_test.go | 77 +++++++++- coderd/oauth2provider/registration.go | 31 +--- coderd/oauth2provider/revoke.go | 199 ++++++++++++++++++++++++++ coderd/oauth2provider/secrets.go | 75 +++++++--- coderd/oauth2provider/tokens.go | 18 +-- coderd/rbac/authz.go | 1 + codersdk/oauth2.go | 22 +++ docs/reference/api/enterprise.md | 49 ++++++- docs/reference/api/schemas.md | 7 +- site/src/api/typesGenerated.ts | 1 + site/src/testHelpers/entities.ts | 1 + 17 files changed, 558 insertions(+), 63 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c086141a9b..fed42d7550 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3082,6 +3082,45 @@ const docTemplate = `{ } } }, + "/oauth2/revoke": { + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "tags": [ + "Enterprise" + ], + "summary": "Revoke OAuth2 tokens (RFC 7009).", + "operationId": "oauth2-token-revocation", + "parameters": [ + { + "type": "string", + "description": "Client ID for authentication", + "name": "client_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The token to revoke", + "name": "token", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Hint about token type (access_token or refresh_token)", + "name": "token_type_hint", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "Token successfully revoked" + } + } + } + }, "/oauth2/tokens": { "post": { "produces": [ @@ -15353,6 +15392,9 @@ const docTemplate = `{ }, "token": { "type": "string" + }, + "token_revoke": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3edb176257..6b37aa45c7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2720,6 +2720,41 @@ } } }, + "/oauth2/revoke": { + "post": { + "consumes": ["application/x-www-form-urlencoded"], + "tags": ["Enterprise"], + "summary": "Revoke OAuth2 tokens (RFC 7009).", + "operationId": "oauth2-token-revocation", + "parameters": [ + { + "type": "string", + "description": "Client ID for authentication", + "name": "client_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The token to revoke", + "name": "token", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Hint about token type (access_token or refresh_token)", + "name": "token_type_hint", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "Token successfully revoked" + } + } + } + }, "/oauth2/tokens": { "post": { "produces": ["application/json"], @@ -13911,6 +13946,9 @@ }, "token": { "type": "string" + }, + "token_revoke": { + "type": "string" } } }, diff --git a/coderd/coderd.go b/coderd/coderd.go index dd8d053624..fd1cdccff3 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -985,6 +985,16 @@ func New(options *Options) *API { r.Post("/", api.postOAuth2ProviderAppToken()) }) + // RFC 7009 Token Revocation Endpoint + r.Route("/revoke", func(r chi.Router) { + r.Use( + // RFC 7009 endpoint uses OAuth2 client authentication, not API key + httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)), + ) + // POST /revoke is the standard OAuth2 token revocation endpoint per RFC 7009 + r.Post("/", api.revokeOAuth2Token()) + }) + // RFC 7591 Dynamic Client Registration - Public endpoint r.Post("/register", api.postOAuth2ClientRegistration()) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 77ddcbbf4a..a58d7967e6 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -383,6 +383,9 @@ func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) cod }).String(), // We do not currently support DeviceAuth. DeviceAuth: "", + TokenRevoke: accessURL.ResolveReference(&url.URL{ + Path: "/oauth2/revoke", + }).String(), }, } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f1b262047a..2025b15376 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -446,6 +446,34 @@ var ( Scope: rbac.ScopeAll, }.WithCachedASTValue() + subjectSystemOAuth2 = rbac.Subject{ + Type: rbac.SubjectTypeSystemOAuth, + FriendlyName: "System OAuth2", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "system-oauth2"}, + DisplayName: "System OAuth2", + Site: rbac.Permissions(map[string][]policy.Action{ + // OAuth2 resources - full CRUD permissions + rbac.ResourceOauth2App.Type: rbac.ResourceOauth2App.AvailableActions(), + rbac.ResourceOauth2AppSecret.Type: rbac.ResourceOauth2AppSecret.AvailableActions(), + rbac.ResourceOauth2AppCodeToken.Type: rbac.ResourceOauth2AppCodeToken.AvailableActions(), + + // API key permissions needed for OAuth2 token revocation + rbac.ResourceApiKey.Type: {policy.ActionRead, policy.ActionDelete}, + + // Minimal read permissions that might be needed for OAuth2 operations + rbac.ResourceUser.Type: {policy.ActionRead}, + rbac.ResourceOrganization.Type: {policy.ActionRead}, + }), + User: []rbac.Permission{}, + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + subjectSystemReadProvisionerDaemons = rbac.Subject{ Type: rbac.SubjectTypeSystemReadProvisionerDaemons, FriendlyName: "Provisioner Daemons Reader", @@ -643,6 +671,12 @@ func AsSystemRestricted(ctx context.Context) context.Context { return As(ctx, subjectSystemRestricted) } +// AsSystemOAuth2 returns a context with an actor that has permissions +// required for OAuth2 provider operations (token revocation, device codes, registration). +func AsSystemOAuth2(ctx context.Context) context.Context { + return As(ctx, subjectSystemOAuth2) +} + // AsSystemReadProvisionerDaemons returns a context with an actor that has permissions // to read provisioner daemons. func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context { diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 1e28f9b65b..ac0c87545e 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -160,6 +160,19 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc { return oauth2provider.RevokeApp(api.Database) } +// @Summary Revoke OAuth2 tokens (RFC 7009). +// @ID oauth2-token-revocation +// @Accept x-www-form-urlencoded +// @Tags Enterprise +// @Param client_id formData string true "Client ID for authentication" +// @Param token formData string true "The token to revoke" +// @Param token_type_hint formData string false "Hint about token type (access_token or refresh_token)" +// @Success 200 "Token successfully revoked" +// @Router /oauth2/revoke [post] +func (api *API) revokeOAuth2Token() http.HandlerFunc { + return oauth2provider.RevokeToken(api.Database, api.Logger) +} + // @Summary OAuth2 authorization server metadata. // @ID oauth2-authorization-server-metadata // @Produce json diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index d5755b695d..c03dcf7e3a 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -720,7 +720,7 @@ func TestOAuth2ProviderRevoke(t *testing.T) { }, }, { - name: "DeleteToken", + name: "DeleteApp", fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) { err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID) require.NoError(t, err) @@ -1603,5 +1603,80 @@ func TestOAuth2RegistrationAccessToken(t *testing.T) { }) } +// TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client. +func TestOAuth2CoderClient(t *testing.T) { + t.Parallel() + + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + + // Setup an oauth app + ctx := testutil.Context(t, testutil.WaitLong) + app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "new-app", + CallbackURL: "http://localhost", + }) + require.NoError(t, err) + + appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + + cfg := &oauth2.Config{ + ClientID: app.ID.String(), + ClientSecret: appsecret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: app.Endpoints.Authorization, + DeviceAuthURL: app.Endpoints.DeviceAuth, + TokenURL: app.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: app.CallbackURL, + Scopes: []string{}, + } + + // Make a new user + client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + // Do an OAuth2 token exchange and get a new client with an oauth token + state := uuid.NewString() + + // Get an OAuth2 code for a token exchange + code, err := oidctest.OAuth2GetCode( + cfg.AuthCodeURL(state), + func(req *http.Request) (*http.Response, error) { + // Change to POST to simulate the form submission + req.Method = http.MethodPost + + // Prevent automatic redirect following + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + return client.Request(ctx, req.Method, req.URL.String(), nil) + }, + ) + require.NoError(t, err) + + token, err := cfg.Exchange(ctx, code) + require.NoError(t, err) + + // Use the oauth client's authentication + // TODO: The SDK could probably support this with a better syntax/api. + oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + usingOauth := codersdk.New(owner.URL) + usingOauth.HTTPClient = oauthClient + + me, err := usingOauth.User(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, user.ID, me.ID) + + // Revoking the refresh token should prevent further access + // Revoking the refresh also invalidates the associated access token. + err = usingOauth.RevokeOAuth2Token(ctx, app.ID, token.RefreshToken) + require.NoError(t, err) + + _, err = usingOauth.User(ctx, codersdk.Me) + require.Error(t, err) +} + // NOTE: OAuth2 client registration validation tests have been migrated to // oauth2provider/validation_test.go for better separation of concerns diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go index 63d2de4f48..c433cae8ff 100644 --- a/coderd/oauth2provider/registration.go +++ b/coderd/oauth2provider/registration.go @@ -26,12 +26,6 @@ import ( "github.com/coder/coder/v2/cryptorand" ) -// Constants for OAuth2 secret generation (RFC 7591) -const ( - secretLength = 40 // Length of the actual secret part - displaySecretLength = 6 // Length of visible part in UI (last 6 characters) -) - // CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { @@ -121,7 +115,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi } // Create client secret - parse the formatted secret to get components - parsedSecret, err := parseFormattedSecret(clientSecret) + parsedSecret, err := ParseFormattedSecret(clientSecret) if err != nil { writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to parse generated secret") @@ -132,7 +126,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi _, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{ ID: uuid.New(), CreatedAt: now, - SecretPrefix: []byte(parsedSecret.prefix), + SecretPrefix: []byte(parsedSecret.Prefix), HashedSecret: []byte(hashedSecret), DisplaySecret: createDisplaySecret(clientSecret), AppID: clientID, @@ -551,27 +545,6 @@ func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, sta _ = json.NewEncoder(rw).Encode(errorResponse) } -// parsedSecret represents the components of a formatted OAuth2 secret -type parsedSecret struct { - prefix string - secret string -} - -// parseFormattedSecret parses a formatted secret like "coder_prefix_secret" -func parseFormattedSecret(secret string) (parsedSecret, error) { - parts := strings.Split(secret, "_") - if len(parts) != 3 { - return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) - } - if parts[0] != "coder" { - return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0]) - } - return parsedSecret{ - prefix: parts[1], - secret: parts[2], - }, nil -} - // createDisplaySecret creates a display version of the secret showing only the last few characters func createDisplaySecret(secret string) string { if len(secret) <= displaySecretLength { diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 243ce75028..2dbe4827b1 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -1,15 +1,214 @@ package oauth2provider import ( + "context" + "crypto/sha256" + "crypto/subtle" "database/sql" "errors" "net/http" + "strings" + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/userpassword" ) +var ( + // ErrTokenNotBelongsToClient is returned when a token does not belong to the requesting client + ErrTokenNotBelongsToClient = xerrors.New("token does not belong to requesting client") + // ErrInvalidTokenFormat is returned when a token has an invalid format + ErrInvalidTokenFormat = xerrors.New("invalid token format") +) + +// RevokeToken implements RFC 7009 OAuth2 Token Revocation +// Authentication is unique for this endpoint in that it does not use the +// standard token authentication middleware. Instead, it expects the token that +// is being revoked to be valid. +// TODO: Currently the token validation occurs in the revocation logic itself. +// This code should be refactored to share token validation logic with other parts +// of the OAuth2 provider/http middleware. +func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + + // RFC 7009 requires POST method with application/x-www-form-urlencoded + if r.Method != http.MethodPost { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusMethodNotAllowed, "invalid_request", "Method not allowed") + return + } + + if err := r.ParseForm(); err != nil { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid form data") + return + } + + // RFC 7009 requires 'token' parameter + token := r.Form.Get("token") + if token == "" { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing token parameter") + return + } + + // Determine if this is a refresh token (starts with "coder_") or API key + // APIKeys do not have the SecretIdentifier prefix. + const coderPrefix = SecretIdentifier + "_" + isRefreshToken := strings.HasPrefix(token, coderPrefix) + + // Revoke the token with ownership verification + err := db.InTx(func(tx database.Store) error { + if isRefreshToken { + // Handle refresh token revocation + return revokeRefreshTokenInTx(ctx, tx, token, app.ID) + } + // Handle API key revocation + return revokeAPIKeyInTx(ctx, tx, token, app.ID) + }, nil) + if err != nil { + if errors.Is(err, ErrTokenNotBelongsToClient) { + // RFC 7009: Return success even if token doesn't belong to client (don't reveal token existence) + logger.Debug(ctx, "token revocation failed: token does not belong to requesting client", + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + rw.WriteHeader(http.StatusOK) + return + } + if errors.Is(err, ErrInvalidTokenFormat) { + // Invalid token format should return 400 bad request + logger.Debug(ctx, "token revocation failed: invalid token format", + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid token format") + return + } + logger.Error(ctx, "token revocation failed with internal server error", + slog.Error(err), + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error") + return + } + + // RFC 7009: successful revocation returns HTTP 200 + rw.WriteHeader(http.StatusOK) + } +} + +func revokeRefreshTokenInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error { + // Parse the refresh token using the existing function + parsedToken, err := ParseFormattedSecret(token) + if err != nil { + return ErrInvalidTokenFormat + } + + // Try to find refresh token by prefix + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(parsedToken.Prefix)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // Token not found - return success per RFC 7009 (don't reveal token existence) + return nil + } + return xerrors.Errorf("get oauth2 provider app token by prefix: %w", err) + } + + equal, err := userpassword.Compare(string(dbToken.RefreshHash), parsedToken.Secret) + if err != nil { + return xerrors.Errorf("invalid refresh token: %w", err) + } + if !equal { + return xerrors.Errorf("invalid refresh token") + } + + // Verify ownership + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) + if err != nil { + return xerrors.Errorf("get oauth2 provider app secret: %w", err) + } + if appSecret.AppID != appID { + return ErrTokenNotBelongsToClient + } + + // Delete the associated API key, which should cascade to remove the refresh token + // According to RFC 7009, when a refresh token is revoked, associated access tokens should be invalidated + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete api key: %w", err) + } + + return nil +} + +func revokeAPIKeyInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error { + keyID, secret, err := httpmw.SplitAPIToken(token) + if err != nil { + return ErrInvalidTokenFormat + } + + // Get the API key + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), keyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // API key not found - return success per RFC 7009 (don't reveal token existence) + return nil + } + return xerrors.Errorf("get api key by id: %w", err) + } + + // Checking to see if the provided secret matches the stored hashed secret + hashedSecret := sha256.Sum256([]byte(secret)) + if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 { + return xerrors.Errorf("invalid api key") + } + + // Verify the API key was created by OAuth2 + if apiKey.LoginType != database.LoginTypeOAuth2ProviderApp { + return xerrors.New("api key is not an oauth2 token") + } + + // Find the associated OAuth2 token to verify ownership + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + dbToken, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // No associated OAuth2 token - return success per RFC 7009 + return nil + } + return xerrors.Errorf("get oauth2 provider app token by api key id: %w", err) + } + + // Verify the token belongs to the requesting app + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) + if err != nil { + return xerrors.Errorf("get oauth2 provider app secret for api key verification: %w", err) + } + + if appSecret.AppID != appID { + return ErrTokenNotBelongsToClient + } + + // Delete the API key + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete api key for revocation: %w", err) + } + + return nil +} + func RevokeApp(db database.Store) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/coderd/oauth2provider/secrets.go b/coderd/oauth2provider/secrets.go index a360c0b325..56c5231b25 100644 --- a/coderd/oauth2provider/secrets.go +++ b/coderd/oauth2provider/secrets.go @@ -2,32 +2,68 @@ package oauth2provider import ( "fmt" + "strings" + + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/cryptorand" ) -type AppSecret struct { - // Formatted contains the secret. This value is owned by the client, not the - // server. It is formatted to include the prefix. - Formatted string - // Prefix is the ID of this secret owned by the server. When a client uses a - // secret, this is the matching string to do a lookup on the hashed value. We - // cannot use the hashed value directly because the server does not store the - // salt. - Prefix string +const ( + // SecretIdentifier is the prefix added to all generated secrets. + SecretIdentifier = "coder" +) + +// Constants for OAuth2 secret generation +const ( + secretLength = 40 // Length of the actual secret part + displaySecretLength = 6 // Length of visible part in UI (last 6 characters) +) + +type HashedAppSecret struct { + AppSecret // Hashed is the server stored hash(secret,salt,...). Used for verifying a // secret. Hashed string } +type AppSecret struct { + // Formatted contains the secret. This value is owned by the client, not the + // server. It is formatted to include the prefix. + Formatted string + // Secret is the raw secret value. This value should only be known to the client. + Secret string + // Prefix is the ID of this secret owned by the server. When a client uses a + // secret, this is the matching string to do a lookup on the hashed value. We + // cannot use the hashed value directly because the server does not store the + // salt. + Prefix string +} + +// ParseFormattedSecret parses a formatted secret like "coder__ Body parameter + +```yaml +client_id: string +token: string +token_type_hint: string + +``` + +### Parameters + +| Name | In | Type | Required | Description | +|---------------------|------|--------|----------|-------------------------------------------------------| +| `body` | body | object | true | | +| `» client_id` | body | string | true | Client ID for authentication | +| `» token` | body | string | true | The token to revoke | +| `» token_type_hint` | body | string | false | Hint about token type (access_token or refresh_token) | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|----------------------------|--------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Token successfully revoked | | + ## OAuth2 token exchange ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 12fca35a84..86efc00f52 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5272,7 +5272,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" } ``` @@ -5283,6 +5284,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `authorization` | string | false | | | | `device_authorization` | string | false | | Device authorization is optional. | | `token` | string | false | | | +| `token_revoke` | string | false | | | ## codersdk.OAuth2AuthorizationServerMetadata @@ -5594,7 +5596,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "endpoints": { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" }, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 91b685c0f5..f5e11dbefc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2889,6 +2889,7 @@ export interface NullHCLString { export interface OAuth2AppEndpoints { readonly authorization: string; readonly token: string; + readonly token_revoke: string; /** * DeviceAuth is optional. */ diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 17e8c68315..2b3ff41dda 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4528,6 +4528,7 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [ authorization: "http://localhost:3001/oauth2/authorize", token: "http://localhost:3001/oauth2/token", device_authorization: "", + token_revoke: "http://localhost:3001/oauth2/revoke", }, }, ];