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

Commit7f46f86

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

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
@@ -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
@@ -104,6 +104,17 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc {
104104
returnoauth2provider.DeleteAppSecret(api.Database,api.Auditor.Load(),api.Logger)
105105
}
106106

107+
// @Summary Revoke OAuth2 application tokens for the authenticated user.
108+
// @ID revoke-oauth2-application-tokens
109+
// @Security CoderSessionToken
110+
// @Tags Enterprise
111+
// @Param app path string true "Application ID"
112+
// @Success 204
113+
// @Router /oauth2-provider/apps/{app}/revoke [post]
114+
func (api*API)revokeOAuth2ProviderApp() http.HandlerFunc {
115+
returnoauth2provider.RevokeAppTokens(api.Database)
116+
}
117+
107118
// @Summary OAuth2 authorization request (GET - show authorization page).
108119
// @ID oauth2-authorization-request-get
109120
// @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+
funcTestOAuth2ProviderAppBulkRevoke(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+
defertokenResp.Body.Close()
95+
require.Equal(t,http.StatusOK,tokenResp.StatusCode)
96+
97+
vartokenDatastruct {
98+
AccessTokenstring`json:"access_token"`
99+
TokenTypestring`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+
deferauthResp.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+
deferrevokeResp.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+
deferauthResp2.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+
vartokens []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+
defertokenResp.Body.Close()
166+
require.Equal(t,http.StatusOK,tokenResp.StatusCode)
167+
168+
vartokenDatastruct {
169+
AccessTokenstring`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:=rangetokens {
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+
deferauthResp.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+
deferrevokeResp.Body.Close()
192+
require.Equal(t,http.StatusNoContent,revokeResp.StatusCode)
193+
194+
// Verify all tokens are now revoked
195+
for_,token:=rangetokens {
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+
deferauthResp.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+
deferrevokeResp.Body.Close()
218+
require.Equal(t,http.StatusNotFound,revokeResp.StatusCode)
219+
})
220+
}
221+
62222
funcTestOAuth2ProviderAppSecrets(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+
funcTestOAuth2ClientSecretUsageTracking(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+
defertokenResp.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
returnnil
185185
}
186+
187+
// RevokeAppTokens implements bulk revocation of all OAuth2 tokens and codes for a specific app and user
188+
funcRevokeAppTokens(db database.Store) http.HandlerFunc {
189+
returnfunc(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+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
201+
returnerr
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+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
210+
returnerr
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+
ifapp.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+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
222+
returnerr
223+
}
224+
}
225+
226+
returnnil
227+
},nil)
228+
iferr!=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)

[8]ページ先頭

©2009-2025 Movatter.jp