Skip to content

Commit cbac27e

Browse files
committed
feat(oauth2): add bulk token revocation endpoint with usage tracking
Change-Id: Ia484466d0892e5043f3937b717c28fff91c17ce8 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 168176b commit cbac27e

File tree

10 files changed

+384
-1
lines changed

10 files changed

+384
-1
lines changed

coderd/apidoc/docs.go

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,6 +1502,7 @@ func New(options *Options) *API {
15021502
r.Get("/", api.oAuth2ProviderApp())
15031503
r.Put("/", api.putOAuth2ProviderApp())
15041504
r.Delete("/", api.deleteOAuth2ProviderApp())
1505+
r.Post("/revoke", api.revokeOAuth2ProviderApp())
15051506

15061507
r.Route("/secrets", func(r chi.Router) {
15071508
r.Get("/", api.oAuth2ProviderAppSecrets())

coderd/oauth2.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc {
105105
return oauth2provider.DeleteAppSecret(api.Database, api.Auditor.Load(), api.Logger)
106106
}
107107

108+
// @Summary Revoke OAuth2 application tokens for the authenticated user.
109+
// @ID revoke-oauth2-application-tokens
110+
// @Security CoderSessionToken
111+
// @Tags Enterprise
112+
// @Param app path string true "Application ID"
113+
// @Success 204
114+
// @Router /oauth2-provider/apps/{app}/revoke [post]
115+
func (api *API) revokeOAuth2ProviderApp() http.HandlerFunc {
116+
return oauth2provider.RevokeAppTokens(api.Database)
117+
}
118+
108119
// @Summary OAuth2 authorization request (GET - show authorization page).
109120
// @ID oauth2-authorization-request-get
110121
// @Security CoderSessionToken

coderd/oauth2_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,166 @@ func TestOAuth2ProviderApps(t *testing.T) {
5959
})
6060
}
6161

62+
func TestOAuth2ProviderAppBulkRevoke(t *testing.T) {
63+
t.Parallel()
64+
65+
t.Run("ClientCredentialsAppRevocation", func(t *testing.T) {
66+
t.Parallel()
67+
client := coderdtest.New(t, nil)
68+
_ = coderdtest.CreateFirstUser(t, client)
69+
ctx := t.Context()
70+
71+
// Create an OAuth2 app with client credentials grant type
72+
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
73+
Name: fmt.Sprintf("test-revoke-app-%d", time.Now().UnixNano()),
74+
RedirectURIs: []string{"http://localhost:3000"},
75+
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
76+
})
77+
require.NoError(t, err)
78+
79+
// Create a client secret for the app
80+
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
81+
require.NoError(t, err)
82+
83+
// Request a token using client credentials flow with plain HTTP client
84+
httpClient := &http.Client{}
85+
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
86+
"grant_type": []string{"client_credentials"},
87+
"client_id": []string{app.ID.String()},
88+
"client_secret": []string{secret.ClientSecretFull},
89+
}.Encode()))
90+
require.NoError(t, err)
91+
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
92+
tokenResp, err := httpClient.Do(tokenReq)
93+
require.NoError(t, err)
94+
defer tokenResp.Body.Close()
95+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
96+
97+
var tokenData struct {
98+
AccessToken string `json:"access_token"`
99+
TokenType string `json:"token_type"`
100+
}
101+
err = json.NewDecoder(tokenResp.Body).Decode(&tokenData)
102+
require.NoError(t, err)
103+
require.NotEmpty(t, tokenData.AccessToken)
104+
require.Equal(t, "Bearer", tokenData.TokenType)
105+
106+
// Verify the token works by making an authenticated request
107+
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
108+
require.NoError(t, err)
109+
authReq.Header.Set("Authorization", "Bearer "+tokenData.AccessToken)
110+
authResp, err := httpClient.Do(authReq)
111+
require.NoError(t, err)
112+
defer authResp.Body.Close()
113+
require.Equal(t, http.StatusOK, authResp.StatusCode) // Token should work
114+
115+
// Now revoke all tokens for this app using the new bulk revoke endpoint
116+
revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", app.ID), nil)
117+
require.NoError(t, err)
118+
defer revokeResp.Body.Close()
119+
require.Equal(t, http.StatusNoContent, revokeResp.StatusCode)
120+
121+
// Verify the token no longer works
122+
authReq2, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
123+
require.NoError(t, err)
124+
authReq2.Header.Set("Authorization", "Bearer "+tokenData.AccessToken)
125+
126+
authResp2, err := httpClient.Do(authReq2)
127+
require.NoError(t, err)
128+
defer authResp2.Body.Close()
129+
require.Equal(t, http.StatusUnauthorized, authResp2.StatusCode) // Token should be revoked
130+
})
131+
132+
t.Run("MultipleTokensRevocation", func(t *testing.T) {
133+
t.Parallel()
134+
client := coderdtest.New(t, nil)
135+
_ = coderdtest.CreateFirstUser(t, client)
136+
ctx := t.Context()
137+
138+
// Create an OAuth2 app
139+
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
140+
Name: fmt.Sprintf("test-multi-revoke-app-%d", time.Now().UnixNano()),
141+
RedirectURIs: []string{"http://localhost:3000"},
142+
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
143+
})
144+
require.NoError(t, err)
145+
146+
// Create multiple secrets for the app
147+
secret1, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
148+
require.NoError(t, err)
149+
secret2, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
150+
require.NoError(t, err)
151+
152+
// Request multiple tokens using different secrets with plain HTTP client
153+
httpClient := &http.Client{}
154+
var tokens []string
155+
for _, secret := range []codersdk.OAuth2ProviderAppSecretFull{secret1, secret2} {
156+
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
157+
"grant_type": []string{"client_credentials"},
158+
"client_id": []string{app.ID.String()},
159+
"client_secret": []string{secret.ClientSecretFull},
160+
}.Encode()))
161+
require.NoError(t, err)
162+
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
163+
tokenResp, err := httpClient.Do(tokenReq)
164+
require.NoError(t, err)
165+
defer tokenResp.Body.Close()
166+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
167+
168+
var tokenData struct {
169+
AccessToken string `json:"access_token"`
170+
}
171+
err = json.NewDecoder(tokenResp.Body).Decode(&tokenData)
172+
require.NoError(t, err)
173+
tokens = append(tokens, tokenData.AccessToken)
174+
}
175+
176+
// Verify all tokens work
177+
for _, token := range tokens {
178+
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
179+
require.NoError(t, err)
180+
authReq.Header.Set("Authorization", "Bearer "+token)
181+
182+
authResp, err := httpClient.Do(authReq)
183+
require.NoError(t, err)
184+
defer authResp.Body.Close()
185+
require.Equal(t, http.StatusOK, authResp.StatusCode)
186+
}
187+
188+
// Revoke all tokens for this app using bulk revoke
189+
revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", app.ID), nil)
190+
require.NoError(t, err)
191+
defer revokeResp.Body.Close()
192+
require.Equal(t, http.StatusNoContent, revokeResp.StatusCode)
193+
194+
// Verify all tokens are now revoked
195+
for _, token := range tokens {
196+
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
197+
require.NoError(t, err)
198+
authReq.Header.Set("Authorization", "Bearer "+token)
199+
200+
authResp, err := httpClient.Do(authReq)
201+
require.NoError(t, err)
202+
defer authResp.Body.Close()
203+
require.Equal(t, http.StatusUnauthorized, authResp.StatusCode)
204+
}
205+
})
206+
207+
t.Run("AppNotFound", func(t *testing.T) {
208+
t.Parallel()
209+
client := coderdtest.New(t, nil)
210+
coderdtest.CreateFirstUser(t, client)
211+
ctx := t.Context()
212+
213+
// Try to revoke tokens for non-existent app
214+
fakeAppID := uuid.New()
215+
revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", fakeAppID), nil)
216+
require.NoError(t, err)
217+
defer revokeResp.Body.Close()
218+
require.Equal(t, http.StatusNotFound, revokeResp.StatusCode)
219+
})
220+
}
221+
62222
func TestOAuth2ProviderAppSecrets(t *testing.T) {
63223
t.Parallel()
64224

coderd/oauth2provider/provider_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,3 +770,53 @@ func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, su
770770
},
771771
}
772772
}
773+
774+
// TestOAuth2ClientSecretUsageTracking tests that OAuth2 client secrets properly track their last usage
775+
func TestOAuth2ClientSecretUsageTracking(t *testing.T) {
776+
t.Parallel()
777+
client := coderdtest.New(t, nil)
778+
_ = coderdtest.CreateFirstUser(t, client)
779+
ctx := testutil.Context(t, testutil.WaitLong)
780+
781+
// Create an OAuth2 app
782+
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
783+
Name: fmt.Sprintf("test-usage-tracking-%d", time.Now().UnixNano()),
784+
RedirectURIs: []string{"http://localhost:3000"},
785+
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
786+
})
787+
require.NoError(t, err)
788+
789+
// Create a client secret
790+
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
791+
require.NoError(t, err)
792+
793+
// Check initial state - should be "Never" (null)
794+
secrets, err := client.OAuth2ProviderAppSecrets(ctx, app.ID)
795+
require.NoError(t, err)
796+
require.Len(t, secrets, 1)
797+
require.False(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be null initially")
798+
799+
// Use the client secret in a token request
800+
httpClient := &http.Client{}
801+
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
802+
"grant_type": []string{"client_credentials"},
803+
"client_id": []string{app.ID.String()},
804+
"client_secret": []string{secret.ClientSecretFull},
805+
}.Encode()))
806+
require.NoError(t, err)
807+
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
808+
809+
tokenResp, err := httpClient.Do(tokenReq)
810+
require.NoError(t, err)
811+
defer tokenResp.Body.Close()
812+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
813+
814+
// Check if LastUsedAt is now updated
815+
secrets, err = client.OAuth2ProviderAppSecrets(ctx, app.ID)
816+
require.NoError(t, err)
817+
require.True(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be set after usage")
818+
819+
// Check that the timestamp is recent (within last minute)
820+
timeSinceUsage := time.Since(secrets[0].LastUsedAt.Time)
821+
require.True(t, timeSinceUsage < time.Minute, "LastUsedAt timestamp should be recent, but was %v ago", timeSinceUsage)
822+
}

coderd/oauth2provider/revoke.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,54 @@ func revokeAPIKey(ctx context.Context, db database.Store, token string, appID uu
183183

184184
return nil
185185
}
186+
187+
// RevokeAppTokens implements bulk revocation of all OAuth2 tokens and codes for a specific app and user
188+
func RevokeAppTokens(db database.Store) http.HandlerFunc {
189+
return func(rw http.ResponseWriter, r *http.Request) {
190+
ctx := r.Context()
191+
apiKey := httpmw.APIKey(r)
192+
app := httpmw.OAuth2ProviderApp(r)
193+
194+
err := db.InTx(func(tx database.Store) error {
195+
// Delete all authorization codes for this app and user
196+
err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
197+
AppID: app.ID,
198+
UserID: apiKey.UserID,
199+
})
200+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
201+
return err
202+
}
203+
204+
// Delete all tokens for this app and user (handles authorization code flow)
205+
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
206+
AppID: app.ID,
207+
UserID: apiKey.UserID,
208+
})
209+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
210+
return err
211+
}
212+
213+
// For client credentials flow: if the app has an owner, also delete tokens for the app owner
214+
// Client credentials tokens are created with UserID = app.UserID.UUID (the app owner)
215+
if app.UserID.Valid && app.UserID.UUID != apiKey.UserID {
216+
// Delete client credentials tokens that belong to the app owner
217+
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
218+
AppID: app.ID,
219+
UserID: app.UserID.UUID,
220+
})
221+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
222+
return err
223+
}
224+
}
225+
226+
return nil
227+
}, nil)
228+
if err != nil {
229+
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error")
230+
return
231+
}
232+
233+
// Successful revocation returns HTTP 204 No Content
234+
rw.WriteHeader(http.StatusNoContent)
235+
}
236+
}

0 commit comments

Comments
 (0)
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