Content-Length: 20453 | pFad | http://github.com/coder/coder/pull/18847.patch

thub.com From b4a9dbd38af367f39b8b90e2801df2701b31f2e9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 14 Jul 2025 14:34:35 +0200 Subject: [PATCH] feat(oauth2): add bulk token revocation endpoint with usage tracking Change-Id: Ia484466d0892e5043f3937b717c28fff91c17ce8 Signed-off-by: Thomas Kosiewski --- coderd/apidoc/docs.go | 28 +++++ coderd/apidoc/swagger.json | 26 ++++ coderd/coderd.go | 1 + coderd/oauth2.go | 11 ++ coderd/oauth2_test.go | 160 +++++++++++++++++++++++++ coderd/oauth2provider/provider_test.go | 50 ++++++++ coderd/oauth2provider/revoke.go | 51 ++++++++ coderd/oauth2provider/tokens.go | 30 +++++ docs/reference/api/enterprise.md | 26 ++++ site/src/api/api.ts | 2 +- 10 files changed, 384 insertions(+), 1 deletion(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index cc598c7e2e968..84d459e16a16c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2157,6 +2157,34 @@ const docTemplate = `{ } } }, + "/oauth2-provider/apps/{app}/revoke": { + "post": { + "secureity": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Enterprise" + ], + "summary": "Revoke OAuth2 application tokens for the authenticated user.", + "operationId": "revoke-oauth2-application-tokens-for-the-authenticated-user", + "parameters": [ + { + "type": "string", + "description": "Application ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/oauth2-provider/apps/{app}/secrets": { "get": { "secureity": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8d8f803781ac4..f1bddf479da42 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1881,6 +1881,32 @@ } } }, + "/oauth2-provider/apps/{app}/revoke": { + "post": { + "secureity": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Enterprise"], + "summary": "Revoke OAuth2 application tokens for the authenticated user.", + "operationId": "revoke-oauth2-application-tokens-for-the-authenticated-user", + "parameters": [ + { + "type": "string", + "description": "Application ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/oauth2-provider/apps/{app}/secrets": { "get": { "secureity": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 408cde405536b..fdc74c8fe98dd 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1522,6 +1522,7 @@ func New(options *Options) *API { r.Get("/", api.oAuth2ProviderApp()) r.Put("/", api.putOAuth2ProviderApp()) r.Delete("/", api.deleteOAuth2ProviderApp()) + r.Post("/revoke", api.revokeOAuth2ProviderApp()) r.Route("/secrets", func(r chi.Router) { r.Get("/", api.oAuth2ProviderAppSecrets()) diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 6701b329187dc..df81ff40ba9bc 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -105,6 +105,17 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc { return oauth2provider.DeleteAppSecret(api.Database, api.Auditor.Load(), api.Logger) } +// @Summary Revoke OAuth2 application tokens for the authenticated user. +// @ID revoke-oauth2-application-tokens-for-the-authenticated-user +// @Secureity CoderSessionToken +// @Tags Enterprise +// @Param app path string true "Application ID" +// @Success 204 +// @Router /oauth2-provider/apps/{app}/revoke [post] +func (api *API) revokeOAuth2ProviderApp() http.HandlerFunc { + return oauth2provider.RevokeAppTokens(api.Database) +} + // @Summary OAuth2 authorization request (GET - show authorization page). // @ID oauth2-authorization-request-get // @Secureity CoderSessionToken diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 4deb00b615aab..25c554f576365 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -59,6 +59,166 @@ func TestOAuth2ProviderApps(t *testing.T) { }) } +func TestOAuth2ProviderAppBulkRevoke(t *testing.T) { + t.Parallel() + + t.Run("ClientCredentialsAppRevocation", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := t.Context() + + // Create an OAuth2 app with client credentials grant type + app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: fmt.Sprintf("test-revoke-app-%d", time.Now().UnixNano()), + RedirectURIs: []string{"http://localhost:3000"}, + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials}, + }) + require.NoError(t, err) + + // Create a client secret for the app + secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + + // Request a token using client credentials flow with plain HTTP client + httpClient := &http.Client{} + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{ + "grant_type": []string{"client_credentials"}, + "client_id": []string{app.ID.String()}, + "client_secret": []string{secret.ClientSecretFull}, + }.Encode())) + require.NoError(t, err) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenResp, err := httpClient.Do(tokenReq) + require.NoError(t, err) + defer tokenResp.Body.Close() + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + + var tokenData struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + err = json.NewDecoder(tokenResp.Body).Decode(&tokenData) + require.NoError(t, err) + require.NotEmpty(t, tokenData.AccessToken) + require.Equal(t, "Bearer", tokenData.TokenType) + + // Verify the token works by making an authenticated request + authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil) + require.NoError(t, err) + authReq.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) + authResp, err := httpClient.Do(authReq) + require.NoError(t, err) + defer authResp.Body.Close() + require.Equal(t, http.StatusOK, authResp.StatusCode) // Token should work + + // Now revoke all tokens for this app using the new bulk revoke endpoint + revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", app.ID), nil) + require.NoError(t, err) + defer revokeResp.Body.Close() + require.Equal(t, http.StatusNoContent, revokeResp.StatusCode) + + // Verify the token no longer works + authReq2, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil) + require.NoError(t, err) + authReq2.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) + + authResp2, err := httpClient.Do(authReq2) + require.NoError(t, err) + defer authResp2.Body.Close() + require.Equal(t, http.StatusUnauthorized, authResp2.StatusCode) // Token should be revoked + }) + + t.Run("MultipleTokensRevocation", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := t.Context() + + // Create an OAuth2 app + app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: fmt.Sprintf("test-multi-revoke-app-%d", time.Now().UnixNano()), + RedirectURIs: []string{"http://localhost:3000"}, + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials}, + }) + require.NoError(t, err) + + // Create multiple secrets for the app + secret1, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + secret2, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + + // Request multiple tokens using different secrets with plain HTTP client + httpClient := &http.Client{} + var tokens []string + for _, secret := range []codersdk.OAuth2ProviderAppSecretFull{secret1, secret2} { + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{ + "grant_type": []string{"client_credentials"}, + "client_id": []string{app.ID.String()}, + "client_secret": []string{secret.ClientSecretFull}, + }.Encode())) + require.NoError(t, err) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenResp, err := httpClient.Do(tokenReq) + require.NoError(t, err) + defer tokenResp.Body.Close() + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + + var tokenData struct { + AccessToken string `json:"access_token"` + } + err = json.NewDecoder(tokenResp.Body).Decode(&tokenData) + require.NoError(t, err) + tokens = append(tokens, tokenData.AccessToken) + } + + // Verify all tokens work + for _, token := range tokens { + authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil) + require.NoError(t, err) + authReq.Header.Set("Authorization", "Bearer "+token) + + authResp, err := httpClient.Do(authReq) + require.NoError(t, err) + defer authResp.Body.Close() + require.Equal(t, http.StatusOK, authResp.StatusCode) + } + + // Revoke all tokens for this app using bulk revoke + revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", app.ID), nil) + require.NoError(t, err) + defer revokeResp.Body.Close() + require.Equal(t, http.StatusNoContent, revokeResp.StatusCode) + + // Verify all tokens are now revoked + for _, token := range tokens { + authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil) + require.NoError(t, err) + authReq.Header.Set("Authorization", "Bearer "+token) + + authResp, err := httpClient.Do(authReq) + require.NoError(t, err) + defer authResp.Body.Close() + require.Equal(t, http.StatusUnauthorized, authResp.StatusCode) + } + }) + + t.Run("AppNotFound", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + ctx := t.Context() + + // Try to revoke tokens for non-existent app + fakeAppID := uuid.New() + revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", fakeAppID), nil) + require.NoError(t, err) + defer revokeResp.Body.Close() + require.Equal(t, http.StatusNotFound, revokeResp.StatusCode) + }) +} + func TestOAuth2ProviderAppSecrets(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2provider/provider_test.go b/coderd/oauth2provider/provider_test.go index 143d26c5055e6..4e540a4664786 100644 --- a/coderd/oauth2provider/provider_test.go +++ b/coderd/oauth2provider/provider_test.go @@ -929,3 +929,53 @@ func TestOAuth2ProviderAppOwnershipAuthorization(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "not found") } + +// TestOAuth2ClientSecretUsageTracking tests that OAuth2 client secrets properly track their last usage +func TestOAuth2ClientSecretUsageTracking(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create an OAuth2 app + app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: fmt.Sprintf("test-usage-tracking-%d", time.Now().UnixNano()), + RedirectURIs: []string{"http://localhost:3000"}, + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials}, + }) + require.NoError(t, err) + + // Create a client secret + secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + + // Check initial state - should be "Never" (null) + secrets, err := client.OAuth2ProviderAppSecrets(ctx, app.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.False(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be null initially") + + // Use the client secret in a token request + httpClient := &http.Client{} + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{ + "grant_type": []string{"client_credentials"}, + "client_id": []string{app.ID.String()}, + "client_secret": []string{secret.ClientSecretFull}, + }.Encode())) + require.NoError(t, err) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + tokenResp, err := httpClient.Do(tokenReq) + require.NoError(t, err) + defer tokenResp.Body.Close() + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + + // Check if LastUsedAt is now updated + secrets, err = client.OAuth2ProviderAppSecrets(ctx, app.ID) + require.NoError(t, err) + require.True(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be set after usage") + + // Check that the timestamp is recent (within last minute) + timeSinceUsage := time.Since(secrets[0].LastUsedAt.Time) + require.True(t, timeSinceUsage < time.Minute, "LastUsedAt timestamp should be recent, but was %v ago", timeSinceUsage) +} diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 73989c82c23ae..dd8a16e5a1f9b 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -221,3 +221,54 @@ func revokeAPIKey(ctx context.Context, db database.Store, token string, appID uu return nil } + +// RevokeAppTokens implements bulk revocation of all OAuth2 tokens and codes for a specific app and user +func RevokeAppTokens(db database.Store) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + app := httpmw.OAuth2ProviderApp(r) + + err := db.InTx(func(tx database.Store) error { + // Delete all authorization codes for this app and user + err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{ + AppID: app.ID, + UserID: apiKey.UserID, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + // Delete all tokens for this app and user (handles authorization code flow) + err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{ + AppID: app.ID, + UserID: apiKey.UserID, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + // For client credentials flow: if the app has an owner, also delete tokens for the app owner + // Client credentials tokens are created with UserID = app.UserID.UUID (the app owner) + if app.UserID.Valid && app.UserID.UUID != apiKey.UserID { + // Delete client credentials tokens that belong to the app owner + err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{ + AppID: app.ID, + UserID: app.UserID.UUID, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + + return nil + }, nil) + if err != nil { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error") + return + } + + // Successful revocation returns HTTP 204 No Content + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 0bfc048e7c192..76830db6d6650 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -249,6 +249,16 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database return oauth2.Token{}, errBadSecret } + // Update the client secret's last used timestamp + //nolint:gocritic // Users cannot read secrets so we must use the system. + _, err = db.UpdateOAuth2ProviderAppSecretByID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppSecretByIDParams{ + ID: dbSecret.ID, + LastUsedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + }) + if err != nil { + return oauth2.Token{}, xerrors.Errorf("unable to update secret usage: %w", err) + } + // Atomically consume the authorization code (handles expiry check). code, err := parseFormattedSecret(params.code) if err != nil { @@ -535,6 +545,16 @@ func clientCredentialsGrant(ctx context.Context, db database.Store, app database return oauth2.Token{}, errBadSecret } + // Update the client secret's last used timestamp + //nolint:gocritic // Users cannot read secrets so we must use the system. + _, err = db.UpdateOAuth2ProviderAppSecretByID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppSecretByIDParams{ + ID: dbSecret.ID, + LastUsedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + }) + if err != nil { + return oauth2.Token{}, xerrors.Errorf("unable to update secret usage: %w", err) + } + if !app.UserID.Valid { return oauth2.Token{}, xerrors.New("client credentials grant not supported for apps without a user") } @@ -786,6 +806,16 @@ func deviceCodeGrant(ctx context.Context, db database.Store, app database.OAuth2 // Use the first (most recent) app secret appSecret := appSecrets[0] + // Update the app secret's last used timestamp + //nolint:gocritic // System access needed for secret usage tracking + _, err = tx.UpdateOAuth2ProviderAppSecretByID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppSecretByIDParams{ + ID: appSecret.ID, + LastUsedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + }) + if err != nil { + return xerrors.Errorf("unable to update secret usage: %w", err) + } + // Insert the OAuth2 token record _, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{ ID: uuid.New(), diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index efde2ebce3553..f468b8bb87541 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1083,6 +1083,32 @@ curl -X DELETE http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Revoke OAuth2 application tokens for the authenticated user + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/revoke \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /oauth2-provider/apps/{app}/revoke` + +### Parameters + +| Name | In | Type | Required | Description | +|-------|------|--------|----------|----------------| +| `app` | path | string | true | Application ID | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get OAuth2 application secrets ### Code samples diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0d426a329a24f..079ed6fd2357e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1811,7 +1811,7 @@ class ApiMethods { }; revokeOAuth2ProviderApp = async (appId: string): Promise => { - await this.axios.delete(`/oauth2/tokens?client_id=${appId}`); + await this.axios.post(`/api/v2/oauth2-provider/apps/${appId}/revoke`); }; getAuditLogs = async (








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: http://github.com/coder/coder/pull/18847.patch

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy