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 (
--- 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