Skip to content

Commit b4a9dbd

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 cc78865 commit b4a9dbd

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
@@ -1522,6 +1522,7 @@ func New(options *Options) *API {
15221522
r.Get("/", api.oAuth2ProviderApp())
15231523
r.Put("/", api.putOAuth2ProviderApp())
15241524
r.Delete("/", api.deleteOAuth2ProviderApp())
1525+
r.Post("/revoke", api.revokeOAuth2ProviderApp())
15251526

15261527
r.Route("/secrets", func(r chi.Router) {
15271528
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-for-the-authenticated-user
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
@@ -929,3 +929,53 @@ func TestOAuth2ProviderAppOwnershipAuthorization(t *testing.T) {
929929
require.Error(t, err)
930930
require.Contains(t, err.Error(), "not found")
931931
}
932+
933+
// TestOAuth2ClientSecretUsageTracking tests that OAuth2 client secrets properly track their last usage
934+
func TestOAuth2ClientSecretUsageTracking(t *testing.T) {
935+
t.Parallel()
936+
client := coderdtest.New(t, nil)
937+
_ = coderdtest.CreateFirstUser(t, client)
938+
ctx := testutil.Context(t, testutil.WaitLong)
939+
940+
// Create an OAuth2 app
941+
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
942+
Name: fmt.Sprintf("test-usage-tracking-%d", time.Now().UnixNano()),
943+
RedirectURIs: []string{"http://localhost:3000"},
944+
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
945+
})
946+
require.NoError(t, err)
947+
948+
// Create a client secret
949+
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
950+
require.NoError(t, err)
951+
952+
// Check initial state - should be "Never" (null)
953+
secrets, err := client.OAuth2ProviderAppSecrets(ctx, app.ID)
954+
require.NoError(t, err)
955+
require.Len(t, secrets, 1)
956+
require.False(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be null initially")
957+
958+
// Use the client secret in a token request
959+
httpClient := &http.Client{}
960+
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
961+
"grant_type": []string{"client_credentials"},
962+
"client_id": []string{app.ID.String()},
963+
"client_secret": []string{secret.ClientSecretFull},
964+
}.Encode()))
965+
require.NoError(t, err)
966+
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
967+
968+
tokenResp, err := httpClient.Do(tokenReq)
969+
require.NoError(t, err)
970+
defer tokenResp.Body.Close()
971+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
972+
973+
// Check if LastUsedAt is now updated
974+
secrets, err = client.OAuth2ProviderAppSecrets(ctx, app.ID)
975+
require.NoError(t, err)
976+
require.True(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be set after usage")
977+
978+
// Check that the timestamp is recent (within last minute)
979+
timeSinceUsage := time.Since(secrets[0].LastUsedAt.Time)
980+
require.True(t, timeSinceUsage < time.Minute, "LastUsedAt timestamp should be recent, but was %v ago", timeSinceUsage)
981+
}

coderd/oauth2provider/revoke.go

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

222222
return nil
223223
}
224+
225+
// RevokeAppTokens implements bulk revocation of all OAuth2 tokens and codes for a specific app and user
226+
func RevokeAppTokens(db database.Store) http.HandlerFunc {
227+
return func(rw http.ResponseWriter, r *http.Request) {
228+
ctx := r.Context()
229+
apiKey := httpmw.APIKey(r)
230+
app := httpmw.OAuth2ProviderApp(r)
231+
232+
err := db.InTx(func(tx database.Store) error {
233+
// Delete all authorization codes for this app and user
234+
err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
235+
AppID: app.ID,
236+
UserID: apiKey.UserID,
237+
})
238+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
239+
return err
240+
}
241+
242+
// Delete all tokens for this app and user (handles authorization code flow)
243+
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
244+
AppID: app.ID,
245+
UserID: apiKey.UserID,
246+
})
247+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
248+
return err
249+
}
250+
251+
// For client credentials flow: if the app has an owner, also delete tokens for the app owner
252+
// Client credentials tokens are created with UserID = app.UserID.UUID (the app owner)
253+
if app.UserID.Valid && app.UserID.UUID != apiKey.UserID {
254+
// Delete client credentials tokens that belong to the app owner
255+
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
256+
AppID: app.ID,
257+
UserID: app.UserID.UUID,
258+
})
259+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
260+
return err
261+
}
262+
}
263+
264+
return nil
265+
}, nil)
266+
if err != nil {
267+
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error")
268+
return
269+
}
270+
271+
// Successful revocation returns HTTP 204 No Content
272+
rw.WriteHeader(http.StatusNoContent)
273+
}
274+
}

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