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

Commit5cb397f

Browse files
committed
feat: oauth2 RFC 7009 token revocation endpoint
1 parentf3d950d commit5cb397f

File tree

7 files changed

+317
-50
lines changed

7 files changed

+317
-50
lines changed

‎coderd/coderd.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,16 @@ func New(options *Options) *API {
983983
r.Post("/",api.postOAuth2ProviderAppToken())
984984
})
985985

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

‎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.SubjectTypeSystemRestricted,
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
returnAs(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+
funcAsSystemOAuth2(ctx context.Context) context.Context {
677+
returnAs(ctx,subjectSystemOAuth2)
678+
}
679+
646680
// AsSystemReadProvisionerDaemons returns a context with an actor that has permissions
647681
// to read provisioner daemons.
648682
funcAsSystemReadProvisionerDaemons(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
returnoauth2provider.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+
returnoauth2provider.RevokeToken(api.Database,api.Logger)
174+
}
175+
163176
// @Summary OAuth2 authorization server metadata.
164177
// @ID oauth2-authorization-server-metadata
165178
// @Produce json

‎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
funcCreateDynamicClientRegistration(db database.Store,accessURL*url.URL,auditor*audit.Auditor,logger slog.Logger) http.HandlerFunc {
3731
returnfunc(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
iferr!=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-
typeparsedSecretstruct {
556-
prefixstring
557-
secretstring
558-
}
559-
560-
// parseFormattedSecret parses a formatted secret like "coder_prefix_secret"
561-
funcparseFormattedSecret(secretstring) (parsedSecret,error) {
562-
parts:=strings.Split(secret,"_")
563-
iflen(parts)!=3 {
564-
returnparsedSecret{},xerrors.Errorf("incorrect number of parts: %d",len(parts))
565-
}
566-
ifparts[0]!="coder" {
567-
returnparsedSecret{},xerrors.Errorf("incorrect scheme: %s",parts[0])
568-
}
569-
returnparsedSecret{
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
funccreateDisplaySecret(secretstring)string {
577550
iflen(secret)<=displaySecretLength {

‎coderd/oauth2provider/revoke.go‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,213 @@
11
package oauth2provider
22

33
import (
4+
"context"
5+
"crypto/sha256"
6+
"crypto/subtle"
47
"database/sql"
58
"errors"
69
"net/http"
10+
"strings"
711

12+
"golang.org/x/xerrors"
13+
14+
"github.com/google/uuid"
15+
16+
"cdr.dev/slog"
817
"github.com/coder/coder/v2/coderd/database"
18+
"github.com/coder/coder/v2/coderd/database/dbauthz"
919
"github.com/coder/coder/v2/coderd/httpapi"
1020
"github.com/coder/coder/v2/coderd/httpmw"
1121
)
1222

23+
var (
24+
// ErrTokenNotBelongsToClient is returned when a token does not belong to the requesting client
25+
ErrTokenNotBelongsToClient=xerrors.New("token does not belong to requesting client")
26+
// ErrInvalidTokenFormat is returned when a token has an invalid format
27+
ErrInvalidTokenFormat=xerrors.New("invalid token format")
28+
)
29+
30+
// RevokeToken implements RFC 7009 OAuth2 Token Revocation
31+
funcRevokeToken(db database.Store,logger slog.Logger) http.HandlerFunc {
32+
returnfunc(rw http.ResponseWriter,r*http.Request) {
33+
ctx:=r.Context()
34+
app:=httpmw.OAuth2ProviderApp(r)
35+
36+
// RFC 7009 requires POST method with application/x-www-form-urlencoded
37+
ifr.Method!=http.MethodPost {
38+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusMethodNotAllowed,"invalid_request","Method not allowed")
39+
return
40+
}
41+
42+
iferr:=r.ParseForm();err!=nil {
43+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Invalid form data")
44+
return
45+
}
46+
47+
// RFC 7009 requires 'token' parameter
48+
token:=r.Form.Get("token")
49+
iftoken=="" {
50+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Missing token parameter")
51+
return
52+
}
53+
54+
// Extract client_id parameter - required for ownership verification
55+
clientID:=r.Form.Get("client_id")
56+
ifclientID=="" {
57+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Missing client_id parameter")
58+
return
59+
}
60+
61+
// Verify the extracted app matches the client_id parameter
62+
ifapp.ID.String()!=clientID {
63+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_client","Invalid client_id")
64+
return
65+
}
66+
67+
// Determine if this is a refresh token (starts with "coder_") or API key
68+
// APIKeys do not have the SecretIdentifier prefix.
69+
constcoderPrefix=SecretIdentifier+"_"
70+
isRefreshToken:=strings.HasPrefix(token,coderPrefix)
71+
72+
// Revoke the token with ownership verification
73+
err:=db.InTx(func(tx database.Store)error {
74+
ifisRefreshToken {
75+
// Handle refresh token revocation
76+
returnrevokeRefreshTokenInTx(ctx,tx,token,app.ID)
77+
}
78+
// Handle API key revocation
79+
returnrevokeAPIKeyInTx(ctx,tx,token,app.ID)
80+
},nil)
81+
iferr!=nil {
82+
iferrors.Is(err,ErrTokenNotBelongsToClient) {
83+
// RFC 7009: Return success even if token doesn't belong to client (don't reveal token existence)
84+
logger.Debug(ctx,"token revocation failed: token does not belong to requesting client",
85+
slog.F("client_id",app.ID.String()),
86+
slog.F("app_name",app.Name))
87+
rw.WriteHeader(http.StatusOK)
88+
return
89+
}
90+
iferrors.Is(err,ErrInvalidTokenFormat) {
91+
// Invalid token format should return 400 bad request
92+
logger.Debug(ctx,"token revocation failed: invalid token format",
93+
slog.F("client_id",app.ID.String()),
94+
slog.F("app_name",app.Name))
95+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Invalid token format")
96+
return
97+
}
98+
logger.Error(ctx,"token revocation failed with internal server error",
99+
slog.Error(err),
100+
slog.F("client_id",app.ID.String()),
101+
slog.F("app_name",app.Name))
102+
httpapi.WriteOAuth2Error(ctx,rw,http.StatusInternalServerError,"server_error","Internal server error")
103+
return
104+
}
105+
106+
// RFC 7009: successful revocation returns HTTP 200
107+
rw.WriteHeader(http.StatusOK)
108+
}
109+
}
110+
111+
funcrevokeRefreshTokenInTx(ctx context.Context,db database.Store,tokenstring,appID uuid.UUID)error {
112+
// Parse the refresh token using the existing function
113+
parsedToken,err:=ParseFormattedSecret(token)
114+
iferr!=nil {
115+
returnErrInvalidTokenFormat
116+
}
117+
118+
// Try to find refresh token by prefix
119+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
120+
dbToken,err:=db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(parsedToken.Prefix))
121+
iferr!=nil {
122+
iferrors.Is(err,sql.ErrNoRows) {
123+
// Token not found - return success per RFC 7009 (don't reveal token existence)
124+
returnnil
125+
}
126+
returnxerrors.Errorf("get oauth2 provider app token by prefix: %w",err)
127+
}
128+
129+
// Verify ownership
130+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
131+
appSecret,err:=db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx),dbToken.AppSecretID)
132+
iferr!=nil {
133+
returnxerrors.Errorf("get oauth2 provider app secret: %w",err)
134+
}
135+
ifappSecret.AppID!=appID {
136+
returnErrTokenNotBelongsToClient
137+
}
138+
139+
// Delete the associated API key, which should cascade to remove the refresh token
140+
// According to RFC 7009, when a refresh token is revoked, associated access tokens should be invalidated
141+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
142+
err=db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx),dbToken.APIKeyID)
143+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
144+
returnxerrors.Errorf("delete api key: %w",err)
145+
}
146+
147+
returnnil
148+
}
149+
150+
funcrevokeAPIKeyInTx(ctx context.Context,db database.Store,tokenstring,appID uuid.UUID)error {
151+
keyID,secret,err:=httpmw.SplitAPIToken(token)
152+
iferr!=nil {
153+
returnErrInvalidTokenFormat
154+
}
155+
156+
// Get the API key
157+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
158+
apiKey,err:=db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx),keyID)
159+
iferr!=nil {
160+
iferrors.Is(err,sql.ErrNoRows) {
161+
// API key not found - return success per RFC 7009 (don't reveal token existence)
162+
// Note: This covers both non-existent keys and invalid key ID formats
163+
returnnil
164+
}
165+
returnxerrors.Errorf("get api key by id: %w",err)
166+
}
167+
168+
// Checking to see if the provided secret matches the stored hashed secret
169+
hashedSecret:=sha256.Sum256([]byte(secret))
170+
ifsubtle.ConstantTimeCompare(apiKey.HashedSecret,hashedSecret[:])!=1 {
171+
returnxerrors.Errorf("invalid api key")
172+
}
173+
174+
// Verify the API key was created by OAuth2
175+
ifapiKey.LoginType!=database.LoginTypeOAuth2ProviderApp {
176+
returnxerrors.New("API key is not an OAuth2 token")
177+
}
178+
179+
// Find the associated OAuth2 token to verify ownership
180+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
181+
dbToken,err:=db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx),apiKey.ID)
182+
iferr!=nil {
183+
iferrors.Is(err,sql.ErrNoRows) {
184+
// No associated OAuth2 token - return success per RFC 7009
185+
returnnil
186+
}
187+
returnxerrors.Errorf("get oauth2 provider app token by api key id: %w",err)
188+
}
189+
190+
// Verify the token belongs to the requesting app
191+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
192+
appSecret,err:=db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx),dbToken.AppSecretID)
193+
iferr!=nil {
194+
returnxerrors.Errorf("get oauth2 provider app secret for api key verification: %w",err)
195+
}
196+
197+
ifappSecret.AppID!=appID {
198+
returnErrTokenNotBelongsToClient
199+
}
200+
201+
// Delete the API key
202+
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
203+
err=db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx),apiKey.ID)
204+
iferr!=nil&&!errors.Is(err,sql.ErrNoRows) {
205+
returnxerrors.Errorf("delete api key for revocation: %w",err)
206+
}
207+
208+
returnnil
209+
}
210+
13211
funcRevokeApp(db database.Store) http.HandlerFunc {
14212
returnfunc(rw http.ResponseWriter,r*http.Request) {
15213
ctx:=r.Context()

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp