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)