Skip to content

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 our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: thomask33/07-14-feat_oauth2_add_frontend_ui_for_client_credentials_applications
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 deletions coderd/apidoc/docs.go

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

26 changes: 26 additions & 0 deletions coderd/apidoc/swagger.json

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

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff 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 deletions coderd/oauth2.go
Original file line number Diff line number Diff 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 deletions coderd/oauth2_test.go
Original file line number Diff line number Diff 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 deletions coderd/oauth2provider/provider_test.go
Original file line number Diff line number Diff 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 deletions coderd/oauth2provider/revoke.go
Original file line number Diff line number Diff 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