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": { + "security": [ + { + "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": { "security": [ 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": { + "security": [ + { + "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": { "security": [ 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 +// @Security 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 // @Security 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 ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy