Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit2f53936

Browse files
committed
feat(oauth2): add bulk token revocation endpoint with usage tracking
Change-Id: Ia484466d0892e5043f3937b717c28fff91c17ce8Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parentfd42ee1 commit2f53936

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 aboutcustomizing 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 aboutcustomizing 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
returnoauth2provider.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+
returnoauth2provider.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+
funcTestOAuth2ProviderAppBulkRevoke(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+
defertokenResp.Body.Close()
97+
require.Equal(t,http.StatusOK,tokenResp.StatusCode)
98+
99+
vartokenDatastruct {
100+
AccessTokenstring`json:"access_token"`
101+
TokenTypestring`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+
deferauthResp.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+
deferrevokeResp.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+
deferauthResp2.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+
vartokens []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+
defertokenResp.Body.Close()
168+
require.Equal(t,http.StatusOK,tokenResp.StatusCode)
169+
170+
vartokenDatastruct {
171+
AccessTokenstring`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:=rangetokens {
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+
deferauthResp.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+
deferrevokeResp.Body.Close()
194+
require.Equal(t,http.StatusNoContent,revokeResp.StatusCode)
195+
196+
// Verify all tokens are now revoked
197+
for_,token:=rangetokens {
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+
deferauthResp.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+
deferrevokeResp.Body.Close()
220+
require.Equal(t,http.StatusNotFound,revokeResp.StatusCode)
221+
})
222+
}
223+
64224
funcTestOAuth2ProviderAppSecrets(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+
funcTestOAuth2ClientSecretUsageTracking(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+
defertokenResp.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
returnnil
223223
}
224+
225+
// RevokeAppTokens implements bulk revocation of all OAuth2 tokens and codes for a specific app and user
226+
funcRevokeAppTokens(db database.Store) http.HandlerFunc {
227+
returnfunc(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+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
239+
returnerr
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+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
248+
returnerr
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+
ifapp.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+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
260+
returnerr
261+
}
262+
}
263+
264+
returnnil
265+
},nil)
266+
iferr!=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)

[8]ページ先頭

©2009-2025 Movatter.jp