Skip to content

Commit 2f53936

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 fd42ee1 commit 2f53936

10 files changed

Lines changed: 384 additions & 1 deletion

File tree

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
@@ -1575,6 +1575,7 @@ func New(options *Options) *API {
15751575
r.Get("/", api.oAuth2ProviderApp())
15761576
r.Put("/", api.putOAuth2ProviderApp())
15771577
r.Delete("/", api.deleteOAuth2ProviderApp())
1578+
r.Post("/revoke", api.revokeOAuth2ProviderApp())
15781579

15791580
r.Route("/secrets", func(r chi.Router) {
15801581
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
@@ -61,6 +61,166 @@ func TestOAuth2ProviderApps(t *testing.T) {
6161
})
6262
}
6363

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

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 revokeAPIKeyInTx(ctx context.Context, db database.Store, token string, appI
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)