Skip to content

Commit ad19458

Browse files
grokifyclaude
andcommitted
feat(mcp/oauth2): add token revocation endpoint (RFC 7009)
Harden OAuth 2.1 implementation with token revocation support: - RevocationHandler: POST /oauth/revoke endpoint - Supports both access_token and refresh_token revocation - token_type_hint parameter for optimization - Client authentication (optional) with ownership verification - Returns 200 OK per RFC 7009 to prevent token enumeration - Updated metadata endpoint to advertise revocation support Refs: RMI-OMNISKILL-014 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 84826e9 commit ad19458

2 files changed

Lines changed: 121 additions & 8 deletions

File tree

mcp/oauth2/handlers.go

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,106 @@ func (s *Server) handleRefreshTokenGrant(w http.ResponseWriter, req *TokenReques
647647
_ = json.NewEncoder(w).Encode(resp) //nolint:gosec // G117: OAuth token response contains access_token by spec
648648
}
649649

650+
// revocationHandler handles token revocation (RFC 7009).
651+
func (s *Server) revocationHandler() http.Handler {
652+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
653+
w.Header().Set("Access-Control-Allow-Origin", "*")
654+
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
655+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
656+
657+
if r.Method == http.MethodOptions {
658+
w.WriteHeader(http.StatusNoContent)
659+
return
660+
}
661+
662+
if r.Method != http.MethodPost {
663+
writeOAuthError(w, http.StatusMethodNotAllowed, ErrorInvalidRequest, "Method not allowed")
664+
return
665+
}
666+
667+
// Parse form data
668+
if err := r.ParseForm(); err != nil {
669+
writeOAuthError(w, http.StatusBadRequest, ErrorInvalidRequest, "Failed to parse request")
670+
return
671+
}
672+
673+
token := r.Form.Get("token")
674+
if token == "" {
675+
writeOAuthError(w, http.StatusBadRequest, ErrorInvalidRequest, "token parameter is required")
676+
return
677+
}
678+
679+
tokenTypeHint := r.Form.Get("token_type_hint")
680+
681+
// Authenticate the client (optional for public clients)
682+
clientID, clientSecret, ok := r.BasicAuth()
683+
if !ok {
684+
clientID = r.Form.Get("client_id")
685+
clientSecret = r.Form.Get("client_secret")
686+
}
687+
688+
// If client credentials provided, validate them
689+
if clientID != "" {
690+
client, err := s.storage.GetClient(clientID)
691+
if err != nil || client.ClientSecret != clientSecret {
692+
s.logDebugCtx(r.Context(), "revocation: invalid client credentials",
693+
"client_id", clientID)
694+
writeOAuthError(w, http.StatusUnauthorized, ErrorInvalidClient, "Invalid client credentials")
695+
return
696+
}
697+
}
698+
699+
// Try to revoke the token
700+
revoked := false
701+
702+
// Try as access token first (or if hinted)
703+
if tokenTypeHint == "" || tokenTypeHint == "access_token" {
704+
tokenInfo, err := s.storage.GetToken(token)
705+
if err == nil {
706+
// Verify client owns this token (if client authenticated)
707+
if clientID != "" && tokenInfo.ClientID != clientID {
708+
s.logDebugCtx(r.Context(), "revocation: token belongs to different client",
709+
"client_id", clientID,
710+
"token_client_id", tokenInfo.ClientID)
711+
// Per RFC 7009, we return success even if we don't revoke
712+
w.WriteHeader(http.StatusOK)
713+
return
714+
}
715+
if err := s.storage.DeleteToken(token); err == nil {
716+
revoked = true
717+
s.logDebugCtx(r.Context(), "revocation: access token revoked",
718+
"client_id", clientID)
719+
}
720+
}
721+
}
722+
723+
// Try as refresh token if not yet revoked
724+
if !revoked && (tokenTypeHint == "" || tokenTypeHint == "refresh_token") {
725+
tokenInfo, err := s.storage.GetTokenByRefresh(token)
726+
if err == nil {
727+
// Verify client owns this token (if client authenticated)
728+
if clientID != "" && tokenInfo.ClientID != clientID {
729+
s.logDebugCtx(r.Context(), "revocation: token belongs to different client",
730+
"client_id", clientID,
731+
"token_client_id", tokenInfo.ClientID)
732+
// Per RFC 7009, we return success even if we don't revoke
733+
w.WriteHeader(http.StatusOK)
734+
return
735+
}
736+
if err := s.storage.DeleteToken(tokenInfo.AccessToken); err == nil {
737+
s.logDebugCtx(r.Context(), "revocation: refresh token revoked",
738+
"client_id", clientID)
739+
}
740+
}
741+
}
742+
_ = revoked // Silence unused variable lint
743+
744+
// Per RFC 7009, always return 200 OK (even if token wasn't found)
745+
// This prevents token enumeration attacks
746+
w.WriteHeader(http.StatusOK)
747+
})
748+
}
749+
650750
// metadataHandler returns the authorization server metadata (RFC 8414).
651751
func (s *Server) metadataHandler() http.Handler {
652752
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -671,14 +771,16 @@ func (s *Server) metadataHandler() http.Handler {
671771
}
672772

673773
metadata := map[string]interface{}{
674-
"issuer": baseURL,
675-
"authorization_endpoint": baseURL + s.paths.Authorization,
676-
"token_endpoint": baseURL + s.paths.Token,
677-
"registration_endpoint": baseURL + s.paths.Registration,
678-
"response_types_supported": []string{"code"},
679-
"grant_types_supported": []string{"authorization_code", "refresh_token"},
680-
"token_endpoint_auth_methods_supported": []string{"none", "client_secret_basic", "client_secret_post"},
681-
"code_challenge_methods_supported": []string{"S256"},
774+
"issuer": baseURL,
775+
"authorization_endpoint": baseURL + s.paths.Authorization,
776+
"token_endpoint": baseURL + s.paths.Token,
777+
"registration_endpoint": baseURL + s.paths.Registration,
778+
"revocation_endpoint": baseURL + s.paths.Revocation,
779+
"response_types_supported": []string{"code"},
780+
"grant_types_supported": []string{"authorization_code", "refresh_token"},
781+
"token_endpoint_auth_methods_supported": []string{"none", "client_secret_basic", "client_secret_post"},
782+
"revocation_endpoint_auth_methods_supported": []string{"none", "client_secret_basic", "client_secret_post"},
783+
"code_challenge_methods_supported": []string{"S256"},
682784
}
683785

684786
if len(s.config.AllowedScopes) > 0 {

mcp/oauth2/server.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ type PathConfig struct {
9898
// Registration is the dynamic client registration path. Defaults to "/oauth/register".
9999
Registration string
100100

101+
// Revocation is the token revocation path (RFC 7009). Defaults to "/oauth/revoke".
102+
Revocation string
103+
101104
// Metadata is the authorization server metadata path.
102105
// Defaults to "/.well-known/oauth-authorization-server".
103106
Metadata string
@@ -110,6 +113,7 @@ func DefaultPaths() *PathConfig {
110113
Authorization: "/oauth/authorize",
111114
Token: "/oauth/token",
112115
Registration: "/oauth/register",
116+
Revocation: "/oauth/revoke",
113117
Metadata: "/.well-known/oauth-authorization-server",
114118
}
115119
}
@@ -265,12 +269,19 @@ func (s *Server) TokenVerifier() func(token string) (*TokenInfo, error) {
265269
}
266270
}
267271

272+
// RevocationHandler returns the HTTP handler for token revocation (RFC 7009).
273+
// This endpoint allows clients to revoke access or refresh tokens.
274+
func (s *Server) RevocationHandler() http.Handler {
275+
return s.revocationHandler()
276+
}
277+
268278
// RegisterHandlers registers all OAuth handlers on the given mux using
269279
// the configured paths. This is a convenience method for simple setups.
270280
func (s *Server) RegisterHandlers(mux *http.ServeMux) {
271281
mux.Handle(s.paths.Authorization, s.AuthorizationHandler())
272282
mux.Handle(s.paths.Token, s.TokenHandler())
273283
mux.Handle(s.paths.Registration, s.RegistrationHandler())
284+
mux.Handle(s.paths.Revocation, s.RevocationHandler())
274285
mux.Handle(s.paths.Metadata, s.MetadataHandler())
275286
}
276287

0 commit comments

Comments
 (0)