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

feat: add OAuth2 token bulk revocation endpoint#18847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Open
ThomasK33 wants to merge1 commit intothomask33/07-14-feat_oauth2_add_frontend_ui_for_client_credentials_applications
base:thomask33/07-14-feat_oauth2_add_frontend_ui_for_client_credentials_applications
Choose a base branch
Loading
fromthomask33/07-14-feat_oauth2_add_bulk_token_revocation_endpoint_with_usage_tracking
Open
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletionscoderd/apidoc/docs.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

26 changes: 26 additions & 0 deletionscoderd/apidoc/swagger.json
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

1 change: 1 addition & 0 deletionscoderd/coderd.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -1510,6 +1510,7 @@ func New(options *Options) *API {
r.Get("/", api.oAuth2ProviderApp())
r.Put("/", api.putOAuth2ProviderApp())
r.Delete("/", api.deleteOAuth2ProviderApp())
r.Post("/revoke", api.revokeOAuth2ProviderApp())

r.Route("/secrets", func(r chi.Router) {
r.Get("/", api.oAuth2ProviderAppSecrets())
Expand Down
11 changes: 11 additions & 0 deletionscoderd/oauth2.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,17 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc {
return oauth2provider.DeleteAppSecret(api.Database, api.Auditor.Load(), api.Logger)
}

// @Summary Revoke OAuth2 application tokens for the authenticated user.
// @ID revoke-oauth2-application-tokens-for-the-authenticated-user
// @Security CoderSessionToken
// @Tags Enterprise
// @Param app path string true "Application ID"
// @Success 204
// @Router /oauth2-provider/apps/{app}/revoke [post]
func (api *API) revokeOAuth2ProviderApp() http.HandlerFunc {
return oauth2provider.RevokeAppTokens(api.Database)
}

// @Summary OAuth2 authorization request (GET - show authorization page).
// @ID oauth2-authorization-request-get
// @Security CoderSessionToken
Expand Down
160 changes: 160 additions & 0 deletionscoderd/oauth2_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,166 @@ func TestOAuth2ProviderApps(t *testing.T) {
})
}

func TestOAuth2ProviderAppBulkRevoke(t *testing.T) {
t.Parallel()

t.Run("ClientCredentialsAppRevocation", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := t.Context()

// Create an OAuth2 app with client credentials grant type
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: fmt.Sprintf("test-revoke-app-%d", time.Now().UnixNano()),
RedirectURIs: []string{"http://localhost:3000"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
})
require.NoError(t, err)

// Create a client secret for the app
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err)

// Request a token using client credentials flow with plain HTTP client
httpClient := &http.Client{}
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{app.ID.String()},
"client_secret": []string{secret.ClientSecretFull},
}.Encode()))
require.NoError(t, err)
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tokenResp, err := httpClient.Do(tokenReq)
require.NoError(t, err)
defer tokenResp.Body.Close()
require.Equal(t, http.StatusOK, tokenResp.StatusCode)

var tokenData struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
err = json.NewDecoder(tokenResp.Body).Decode(&tokenData)
require.NoError(t, err)
require.NotEmpty(t, tokenData.AccessToken)
require.Equal(t, "Bearer", tokenData.TokenType)

// Verify the token works by making an authenticated request
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
require.NoError(t, err)
authReq.Header.Set("Authorization", "Bearer "+tokenData.AccessToken)
authResp, err := httpClient.Do(authReq)
require.NoError(t, err)
defer authResp.Body.Close()
require.Equal(t, http.StatusOK, authResp.StatusCode) // Token should work

// Now revoke all tokens for this app using the new bulk revoke endpoint
revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", app.ID), nil)
require.NoError(t, err)
defer revokeResp.Body.Close()
require.Equal(t, http.StatusNoContent, revokeResp.StatusCode)

// Verify the token no longer works
authReq2, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
require.NoError(t, err)
authReq2.Header.Set("Authorization", "Bearer "+tokenData.AccessToken)

authResp2, err := httpClient.Do(authReq2)
require.NoError(t, err)
defer authResp2.Body.Close()
require.Equal(t, http.StatusUnauthorized, authResp2.StatusCode) // Token should be revoked
})

t.Run("MultipleTokensRevocation", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := t.Context()

// Create an OAuth2 app
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: fmt.Sprintf("test-multi-revoke-app-%d", time.Now().UnixNano()),
RedirectURIs: []string{"http://localhost:3000"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
})
require.NoError(t, err)

// Create multiple secrets for the app
secret1, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err)
secret2, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err)

// Request multiple tokens using different secrets with plain HTTP client
httpClient := &http.Client{}
var tokens []string
for _, secret := range []codersdk.OAuth2ProviderAppSecretFull{secret1, secret2} {
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{app.ID.String()},
"client_secret": []string{secret.ClientSecretFull},
}.Encode()))
require.NoError(t, err)
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tokenResp, err := httpClient.Do(tokenReq)
require.NoError(t, err)
defer tokenResp.Body.Close()
require.Equal(t, http.StatusOK, tokenResp.StatusCode)

var tokenData struct {
AccessToken string `json:"access_token"`
}
err = json.NewDecoder(tokenResp.Body).Decode(&tokenData)
require.NoError(t, err)
tokens = append(tokens, tokenData.AccessToken)
}

// Verify all tokens work
for _, token := range tokens {
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
require.NoError(t, err)
authReq.Header.Set("Authorization", "Bearer "+token)

authResp, err := httpClient.Do(authReq)
require.NoError(t, err)
defer authResp.Body.Close()
require.Equal(t, http.StatusOK, authResp.StatusCode)
}

// Revoke all tokens for this app using bulk revoke
revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", app.ID), nil)
require.NoError(t, err)
defer revokeResp.Body.Close()
require.Equal(t, http.StatusNoContent, revokeResp.StatusCode)

// Verify all tokens are now revoked
for _, token := range tokens {
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
require.NoError(t, err)
authReq.Header.Set("Authorization", "Bearer "+token)

authResp, err := httpClient.Do(authReq)
require.NoError(t, err)
defer authResp.Body.Close()
require.Equal(t, http.StatusUnauthorized, authResp.StatusCode)
}
})

t.Run("AppNotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
ctx := t.Context()

// Try to revoke tokens for non-existent app
fakeAppID := uuid.New()
revokeResp, err := client.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/revoke", fakeAppID), nil)
require.NoError(t, err)
defer revokeResp.Body.Close()
require.Equal(t, http.StatusNotFound, revokeResp.StatusCode)
})
}

func TestOAuth2ProviderAppSecrets(t *testing.T) {
t.Parallel()

Expand Down
50 changes: 50 additions & 0 deletionscoderd/oauth2provider/provider_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -929,3 +929,53 @@ func TestOAuth2ProviderAppOwnershipAuthorization(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
}

// TestOAuth2ClientSecretUsageTracking tests that OAuth2 client secrets properly track their last usage
func TestOAuth2ClientSecretUsageTracking(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

// Create an OAuth2 app
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: fmt.Sprintf("test-usage-tracking-%d", time.Now().UnixNano()),
RedirectURIs: []string{"http://localhost:3000"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
})
require.NoError(t, err)

// Create a client secret
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err)

// Check initial state - should be "Never" (null)
secrets, err := client.OAuth2ProviderAppSecrets(ctx, app.ID)
require.NoError(t, err)
require.Len(t, secrets, 1)
require.False(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be null initially")

// Use the client secret in a token request
httpClient := &http.Client{}
tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/token", strings.NewReader(url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{app.ID.String()},
"client_secret": []string{secret.ClientSecretFull},
}.Encode()))
require.NoError(t, err)
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")

tokenResp, err := httpClient.Do(tokenReq)
require.NoError(t, err)
defer tokenResp.Body.Close()
require.Equal(t, http.StatusOK, tokenResp.StatusCode)

// Check if LastUsedAt is now updated
secrets, err = client.OAuth2ProviderAppSecrets(ctx, app.ID)
require.NoError(t, err)
require.True(t, secrets[0].LastUsedAt.Valid, "Expected LastUsedAt to be set after usage")

// Check that the timestamp is recent (within last minute)
timeSinceUsage := time.Since(secrets[0].LastUsedAt.Time)
require.True(t, timeSinceUsage < time.Minute, "LastUsedAt timestamp should be recent, but was %v ago", timeSinceUsage)
}
51 changes: 51 additions & 0 deletionscoderd/oauth2provider/revoke.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,3 +183,54 @@ func revokeAPIKey(ctx context.Context, db database.Store, token string, appID uu

return nil
}

// RevokeAppTokens implements bulk revocation of all OAuth2 tokens and codes for a specific app and user
func RevokeAppTokens(db database.Store) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
app := httpmw.OAuth2ProviderApp(r)

err := db.InTx(func(tx database.Store) error {
// Delete all authorization codes for this app and user
err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
AppID: app.ID,
UserID: apiKey.UserID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}

// Delete all tokens for this app and user (handles authorization code flow)
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
AppID: app.ID,
UserID: apiKey.UserID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}

// For client credentials flow: if the app has an owner, also delete tokens for the app owner
// Client credentials tokens are created with UserID = app.UserID.UUID (the app owner)
if app.UserID.Valid && app.UserID.UUID != apiKey.UserID {
// Delete client credentials tokens that belong to the app owner
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
AppID: app.ID,
UserID: app.UserID.UUID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
}

return nil
}, nil)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error")
return
}

// Successful revocation returns HTTP 204 No Content
rw.WriteHeader(http.StatusNoContent)
}
}
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp