Skip to content

Commit 4bd7c7b

Browse files
authored
feat: implement oauth2 RFC 7009 token revocation endpoint (#20362)
Adds RFC 7009 token revocation endpoint
1 parent 5f97ad0 commit 4bd7c7b

File tree

17 files changed

+552
-57
lines changed

17 files changed

+552
-57
lines changed

coderd/apidoc/docs.go

Lines changed: 42 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: 38 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,16 @@ func New(options *Options) *API {
985985
r.Post("/", api.postOAuth2ProviderAppToken())
986986
})
987987

988+
// RFC 7009 Token Revocation Endpoint
989+
r.Route("/revoke", func(r chi.Router) {
990+
r.Use(
991+
// RFC 7009 endpoint uses OAuth2 client authentication, not API key
992+
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
993+
)
994+
// POST /revoke is the standard OAuth2 token revocation endpoint per RFC 7009
995+
r.Post("/", api.revokeOAuth2Token())
996+
})
997+
988998
// RFC 7591 Dynamic Client Registration - Public endpoint
989999
r.Post("/register", api.postOAuth2ClientRegistration())
9901000

coderd/database/db2sdk/db2sdk.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,9 @@ func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) cod
383383
}).String(),
384384
// We do not currently support DeviceAuth.
385385
DeviceAuth: "",
386+
TokenRevoke: accessURL.ResolveReference(&url.URL{
387+
Path: "/oauth2/revoke",
388+
}).String(),
386389
},
387390
}
388391
}

coderd/database/dbauthz/dbauthz.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,34 @@ var (
446446
Scope: rbac.ScopeAll,
447447
}.WithCachedASTValue()
448448

449+
subjectSystemOAuth2 = rbac.Subject{
450+
Type: rbac.SubjectTypeSystemOAuth,
451+
FriendlyName: "System OAuth2",
452+
ID: uuid.Nil.String(),
453+
Roles: rbac.Roles([]rbac.Role{
454+
{
455+
Identifier: rbac.RoleIdentifier{Name: "system-oauth2"},
456+
DisplayName: "System OAuth2",
457+
Site: rbac.Permissions(map[string][]policy.Action{
458+
// OAuth2 resources - full CRUD permissions
459+
rbac.ResourceOauth2App.Type: rbac.ResourceOauth2App.AvailableActions(),
460+
rbac.ResourceOauth2AppSecret.Type: rbac.ResourceOauth2AppSecret.AvailableActions(),
461+
rbac.ResourceOauth2AppCodeToken.Type: rbac.ResourceOauth2AppCodeToken.AvailableActions(),
462+
463+
// API key permissions needed for OAuth2 token revocation
464+
rbac.ResourceApiKey.Type: {policy.ActionRead, policy.ActionDelete},
465+
466+
// Minimal read permissions that might be needed for OAuth2 operations
467+
rbac.ResourceUser.Type: {policy.ActionRead},
468+
rbac.ResourceOrganization.Type: {policy.ActionRead},
469+
}),
470+
User: []rbac.Permission{},
471+
ByOrgID: map[string]rbac.OrgPermissions{},
472+
},
473+
}),
474+
Scope: rbac.ScopeAll,
475+
}.WithCachedASTValue()
476+
449477
subjectSystemReadProvisionerDaemons = rbac.Subject{
450478
Type: rbac.SubjectTypeSystemReadProvisionerDaemons,
451479
FriendlyName: "Provisioner Daemons Reader",
@@ -643,6 +671,12 @@ func AsSystemRestricted(ctx context.Context) context.Context {
643671
return As(ctx, subjectSystemRestricted)
644672
}
645673

674+
// AsSystemOAuth2 returns a context with an actor that has permissions
675+
// required for OAuth2 provider operations (token revocation, device codes, registration).
676+
func AsSystemOAuth2(ctx context.Context) context.Context {
677+
return As(ctx, subjectSystemOAuth2)
678+
}
679+
646680
// AsSystemReadProvisionerDaemons returns a context with an actor that has permissions
647681
// to read provisioner daemons.
648682
func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context {

coderd/oauth2.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,19 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
160160
return oauth2provider.RevokeApp(api.Database)
161161
}
162162

163+
// @Summary Revoke OAuth2 tokens (RFC 7009).
164+
// @ID oauth2-token-revocation
165+
// @Accept x-www-form-urlencoded
166+
// @Tags Enterprise
167+
// @Param client_id formData string true "Client ID for authentication"
168+
// @Param token formData string true "The token to revoke"
169+
// @Param token_type_hint formData string false "Hint about token type (access_token or refresh_token)"
170+
// @Success 200 "Token successfully revoked"
171+
// @Router /oauth2/revoke [post]
172+
func (api *API) revokeOAuth2Token() http.HandlerFunc {
173+
return oauth2provider.RevokeToken(api.Database, api.Logger)
174+
}
175+
163176
// @Summary OAuth2 authorization server metadata.
164177
// @ID oauth2-authorization-server-metadata
165178
// @Produce json

coderd/oauth2_test.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ func TestOAuth2ProviderRevoke(t *testing.T) {
720720
},
721721
},
722722
{
723-
name: "DeleteToken",
723+
name: "DeleteApp",
724724
fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
725725
err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID)
726726
require.NoError(t, err)
@@ -1603,5 +1603,80 @@ func TestOAuth2RegistrationAccessToken(t *testing.T) {
16031603
})
16041604
}
16051605

1606+
// TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client.
1607+
func TestOAuth2CoderClient(t *testing.T) {
1608+
t.Parallel()
1609+
1610+
owner := coderdtest.New(t, nil)
1611+
first := coderdtest.CreateFirstUser(t, owner)
1612+
1613+
// Setup an oauth app
1614+
ctx := testutil.Context(t, testutil.WaitLong)
1615+
app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
1616+
Name: "new-app",
1617+
CallbackURL: "http://localhost",
1618+
})
1619+
require.NoError(t, err)
1620+
1621+
appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID)
1622+
require.NoError(t, err)
1623+
1624+
cfg := &oauth2.Config{
1625+
ClientID: app.ID.String(),
1626+
ClientSecret: appsecret.ClientSecretFull,
1627+
Endpoint: oauth2.Endpoint{
1628+
AuthURL: app.Endpoints.Authorization,
1629+
DeviceAuthURL: app.Endpoints.DeviceAuth,
1630+
TokenURL: app.Endpoints.Token,
1631+
AuthStyle: oauth2.AuthStyleInParams,
1632+
},
1633+
RedirectURL: app.CallbackURL,
1634+
Scopes: []string{},
1635+
}
1636+
1637+
// Make a new user
1638+
client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
1639+
1640+
// Do an OAuth2 token exchange and get a new client with an oauth token
1641+
state := uuid.NewString()
1642+
1643+
// Get an OAuth2 code for a token exchange
1644+
code, err := oidctest.OAuth2GetCode(
1645+
cfg.AuthCodeURL(state),
1646+
func(req *http.Request) (*http.Response, error) {
1647+
// Change to POST to simulate the form submission
1648+
req.Method = http.MethodPost
1649+
1650+
// Prevent automatic redirect following
1651+
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
1652+
return http.ErrUseLastResponse
1653+
}
1654+
return client.Request(ctx, req.Method, req.URL.String(), nil)
1655+
},
1656+
)
1657+
require.NoError(t, err)
1658+
1659+
token, err := cfg.Exchange(ctx, code)
1660+
require.NoError(t, err)
1661+
1662+
// Use the oauth client's authentication
1663+
// TODO: The SDK could probably support this with a better syntax/api.
1664+
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
1665+
usingOauth := codersdk.New(owner.URL)
1666+
usingOauth.HTTPClient = oauthClient
1667+
1668+
me, err := usingOauth.User(ctx, codersdk.Me)
1669+
require.NoError(t, err)
1670+
require.Equal(t, user.ID, me.ID)
1671+
1672+
// Revoking the refresh token should prevent further access
1673+
// Revoking the refresh also invalidates the associated access token.
1674+
err = usingOauth.RevokeOAuth2Token(ctx, app.ID, token.RefreshToken)
1675+
require.NoError(t, err)
1676+
1677+
_, err = usingOauth.User(ctx, codersdk.Me)
1678+
require.Error(t, err)
1679+
}
1680+
16061681
// NOTE: OAuth2 client registration validation tests have been migrated to
16071682
// oauth2provider/validation_test.go for better separation of concerns

coderd/oauth2provider/registration.go

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,6 @@ import (
2626
"github.com/coder/coder/v2/cryptorand"
2727
)
2828

29-
// Constants for OAuth2 secret generation (RFC 7591)
30-
const (
31-
secretLength = 40 // Length of the actual secret part
32-
displaySecretLength = 6 // Length of visible part in UI (last 6 characters)
33-
)
34-
3529
// CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register
3630
func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
3731
return func(rw http.ResponseWriter, r *http.Request) {
@@ -121,7 +115,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
121115
}
122116

123117
// Create client secret - parse the formatted secret to get components
124-
parsedSecret, err := parseFormattedSecret(clientSecret)
118+
parsedSecret, err := ParseFormattedSecret(clientSecret)
125119
if err != nil {
126120
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
127121
"server_error", "Failed to parse generated secret")
@@ -132,7 +126,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
132126
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{
133127
ID: uuid.New(),
134128
CreatedAt: now,
135-
SecretPrefix: []byte(parsedSecret.prefix),
129+
SecretPrefix: []byte(parsedSecret.Prefix),
136130
HashedSecret: []byte(hashedSecret),
137131
DisplaySecret: createDisplaySecret(clientSecret),
138132
AppID: clientID,
@@ -551,27 +545,6 @@ func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, sta
551545
_ = json.NewEncoder(rw).Encode(errorResponse)
552546
}
553547

554-
// parsedSecret represents the components of a formatted OAuth2 secret
555-
type parsedSecret struct {
556-
prefix string
557-
secret string
558-
}
559-
560-
// parseFormattedSecret parses a formatted secret like "coder_prefix_secret"
561-
func parseFormattedSecret(secret string) (parsedSecret, error) {
562-
parts := strings.Split(secret, "_")
563-
if len(parts) != 3 {
564-
return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts))
565-
}
566-
if parts[0] != "coder" {
567-
return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0])
568-
}
569-
return parsedSecret{
570-
prefix: parts[1],
571-
secret: parts[2],
572-
}, nil
573-
}
574-
575548
// createDisplaySecret creates a display version of the secret showing only the last few characters
576549
func createDisplaySecret(secret string) string {
577550
if len(secret) <= displaySecretLength {

0 commit comments

Comments
 (0)