feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s

MindSift becomes the OAuth authorization server for its own MCP
endpoint: discovery (RFC 9728/8414), dynamic client registration,
authorization code with PKCE (S256), rotating refresh tokens with reuse
detection. The consent page lives in the app at /oauth/authorize. A
grant is an api_keys entry (kind 'oauth'), so scopes, origin badges,
renaming and revoking work as for keys; tokens are valid for /mcp only.
Unused connections expire after OAUTH_IDLE_DAYS (default 90). Requires
BASE_URL; off in demo mode. Release 0.51.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 20:01:32 +01:00
1 parent 994f6dc554
commit 75aea4a7d4
31 files changed
+1581 -49

No files matched your search

+29
View File
@@ -150,6 +150,14 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/events", s.handleSSE)
// OAuth for the MCP endpoint (oauth.go); the consent page /oauth/authorize is part of the app
r.Get("/.well-known/oauth-protected-resource", s.oauthOnly(s.handleOAuthProtectedResource))
r.Get("/.well-known/oauth-protected-resource/*", s.oauthOnly(s.handleOAuthProtectedResource))
r.Get("/.well-known/oauth-authorization-server", s.oauthOnly(s.handleOAuthServerMetadata))
r.Post("/oauth/register", s.oauthOnly(s.handleOAuthRegister))
r.Post("/oauth/token", s.oauthOnly(s.handleOAuthToken))
r.Post("/oauth/revoke", s.oauthOnly(s.handleOAuthRevoke))
// Authenticated API Routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
@@ -304,6 +312,8 @@ func (s *Server) Router() http.Handler {
r.Post(mcpPath, s.handleMCP)
r.Get(mcpPath, handleMCPNoStream)
r.Delete(mcpPath, handleMCPNoStream)
r.Get("/api/v1/oauth/authorize", s.oauthOnly(s.handleOAuthAuthorizeInfo))
r.Post("/api/v1/oauth/authorize", s.oauthOnly(s.handleOAuthAuthorize))
// API Keys Management
r.Get("/api/v1/keys", s.handleGetAPIKeys)
@@ -489,6 +499,16 @@ func (s *Server) authenticateToken(token string) (user *domain.User, key *apiKey
}
return u, &apiKeyAuth{ID: keyID, Scopes: scopes}
}
if strings.HasPrefix(token, storage.OAuthAccessPrefix) {
if !s.oauthEnabled() {
return nil, nil
}
u, scopes, keyID, err := s.store.ValidateOAuthAccessToken(token, time.Now())
if err != nil || u == nil || !u.IsActive {
return nil, nil
}
return u, &apiKeyAuth{ID: keyID, Scopes: scopes, OAuth: true}
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
if err != nil || claims == nil {
return nil, nil
@@ -517,9 +537,18 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
user, key := s.authenticateToken(token)
if user == nil {
// Tells MCP clients where to start the OAuth flow (RFC 9728)
if r.URL.Path == mcpPath && s.oauthEnabled() {
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+s.oauthResourceMetadataURL()+`"`)
}
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
// OAuth tokens were issued for the MCP endpoint only
if key != nil && key.OAuth && r.URL.Path != mcpPath {
writeError(w, http.StatusForbidden, "OAuth-Zugänge gelten nur für den MCP-Endpunkt")
return
}
ctx := auth.WithUserID(r.Context(), user.ID)
if key == nil {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {