feat(security): implement OAuth2 authorization server with database support

- Add OAuthServer for handling OAuth2 flows including authorization, token exchange, and client registration.
- Introduce DatabaseAuthenticator for persisting clients and authorization codes.
- Implement SQL procedures for client registration, code saving, and token introspection.
- Support for external OAuth2 providers and PKCE (Proof Key for Code Exchange).
This commit is contained in:
2026-04-07 22:56:05 +02:00
parent aa095d6bfd
commit 6502b55797
9 changed files with 1874 additions and 9 deletions

View File

@@ -142,9 +142,137 @@ e.Any("/mcp", echo.WrapHandler(h)) // Echo
---
### Authentication
## OAuth2 Authentication
Add middleware before the MCP routes. The handler itself has no auth layer.
`resolvemcp` ships a full **MCP-standard OAuth2 authorization server** (`pkg/security.OAuthServer`) that MCP clients (Claude Desktop, Cursor, etc.) can discover and use automatically.
It can operate as:
- **Its own identity provider** — shows a login form, validates via `DatabaseAuthenticator.Login()`
- **An OAuth2 federation layer** — delegates to external providers (Google, GitHub, Microsoft, etc.)
- **Both simultaneously**
### Standard endpoints served
| Path | Spec | Purpose |
|---|---|---|
| `GET /.well-known/oauth-authorization-server` | RFC 8414 | MCP client auto-discovery |
| `POST /oauth/register` | RFC 7591 | Dynamic client registration |
| `GET /oauth/authorize` | OAuth 2.1 + PKCE | Start login (form or provider redirect) |
| `POST /oauth/authorize` | — | Login form submission |
| `POST /oauth/token` | OAuth 2.1 | Auth code → Bearer token exchange |
| `POST /oauth/token` (refresh) | OAuth 2.1 | Refresh token rotation |
| `GET /oauth/provider/callback` | Internal | External provider redirect target |
MCP clients send `Authorization: Bearer <token>` on all subsequent requests.
---
### Mode 1 — Direct login (server as identity provider)
```go
import "github.com/bitechdev/ResolveSpec/pkg/security"
db, _ := sql.Open("postgres", dsn)
auth := security.NewDatabaseAuthenticator(db)
handler := resolvemcp.NewHandlerWithGORM(gormDB, resolvemcp.Config{
BaseURL: "https://api.example.com",
BasePath: "/mcp",
})
// Enable the OAuth2 server — auth enables the login form
handler.EnableOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
}, auth)
provider, _ := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
securityList, _ := security.NewSecurityList(provider)
security.RegisterSecurityHooks(handler, securityList)
http.ListenAndServe(":8080", handler.HTTPHandler(securityList))
```
MCP client flow:
1. Discovers server at `/.well-known/oauth-authorization-server`
2. Registers itself at `/oauth/register`
3. Redirects user to `/oauth/authorize` → login form appears
4. On submit, exchanges code at `/oauth/token` → receives `Authorization: Bearer` token
5. Uses token on all MCP tool calls
---
### Mode 2 — External provider (Google, GitHub, etc.)
The `RedirectURL` in the provider config must point to `/oauth/provider/callback` on this server.
```go
auth := security.NewDatabaseAuthenticator(db).WithOAuth2(security.OAuth2Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "https://api.example.com/oauth/provider/callback",
Scopes: []string{"openid", "profile", "email"},
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
ProviderName: "google",
})
// nil = no password login; Google handles auth
handler.EnableOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
}, nil)
handler.RegisterOAuth2Provider(auth, "google")
```
---
### Mode 3 — Both (login form + external providers)
```go
handler.EnableOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
LoginTitle: "My App Login",
}, auth) // auth enables the username/password form
handler.RegisterOAuth2Provider(googleAuth, "google")
handler.RegisterOAuth2Provider(githubAuth, "github")
```
When external providers are registered they take priority; the login form is used as fallback when no providers are configured.
---
### Using `security.OAuthServer` standalone
The authorization server lives in `pkg/security` and can be used with any HTTP framework independently of `resolvemcp`:
```go
oauthSrv := security.NewOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
}, auth)
oauthSrv.RegisterExternalProvider(googleAuth, "google")
mux := http.NewServeMux()
mux.Handle("/", oauthSrv.HTTPHandler()) // mounts all OAuth2 routes
mux.Handle("/mcp/", myMCPHandler)
http.ListenAndServe(":8080", mux)
```
---
### Cookie-based flow (legacy)
For simple setups without full MCP OAuth2 compliance, use the legacy helpers that set a session cookie after external provider login:
```go
resolvemcp.SetupMuxOAuth2Routes(r, auth, resolvemcp.OAuth2RouteConfig{
ProviderName: "google",
LoginPath: "/auth/google/login",
CallbackPath: "/auth/google/callback",
AfterLoginRedirect: "/",
})
resolvemcp.SetupMuxRoutesWithAuth(r, handler, securityList)
```
---

View File

@@ -16,17 +16,20 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// Handler exposes registered database models as MCP tools and resources.
type Handler struct {
db common.Database
registry common.ModelRegistry
hooks *HookRegistry
mcpServer *server.MCPServer
config Config
name string
version string
db common.Database
registry common.ModelRegistry
hooks *HookRegistry
mcpServer *server.MCPServer
config Config
name string
version string
oauth2Regs []oauth2Registration
oauthSrv *security.OAuthServer
}
// NewHandler creates a Handler with the given database, model registry, and config.

264
pkg/resolvemcp/oauth2.go Normal file
View File

@@ -0,0 +1,264 @@
package resolvemcp
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// --------------------------------------------------------------------------
// OAuth2 registration on the Handler
// --------------------------------------------------------------------------
// oauth2Registration stores a configured auth provider and its route config.
type oauth2Registration struct {
auth *security.DatabaseAuthenticator
cfg OAuth2RouteConfig
}
// RegisterOAuth2 attaches an OAuth2 provider to the Handler.
// The login and callback HTTP routes are served by HTTPHandler / StreamableHTTPMux.
// Call this once per provider before serving requests.
//
// Example:
//
// auth := security.NewGoogleAuthenticator(clientID, secret, redirectURL, db)
// handler.RegisterOAuth2(auth, resolvemcp.OAuth2RouteConfig{
// ProviderName: "google",
// LoginPath: "/auth/google/login",
// CallbackPath: "/auth/google/callback",
// AfterLoginRedirect: "/",
// })
func (h *Handler) RegisterOAuth2(auth *security.DatabaseAuthenticator, cfg OAuth2RouteConfig) {
h.oauth2Regs = append(h.oauth2Regs, oauth2Registration{auth: auth, cfg: cfg})
}
// HTTPHandler returns a single http.Handler that serves:
// - MCP OAuth2 authorization server endpoints (when EnableOAuthServer has been called)
// - OAuth2 login + callback routes for every registered provider (legacy cookie flow)
// - The MCP SSE transport wrapped with required authentication middleware
//
// Example:
//
// auth := security.NewGoogleAuthenticator(...)
// handler.RegisterOAuth2(auth, cfg)
// handler.EnableOAuthServer(resolvemcp.OAuthServerConfig{Issuer: "https://api.example.com"})
// security.RegisterSecurityHooks(handler, securityList)
// http.ListenAndServe(":8080", handler.HTTPHandler(securityList))
func (h *Handler) HTTPHandler(securityList *security.SecurityList) http.Handler {
mux := http.NewServeMux()
if h.oauthSrv != nil {
h.mountOAuthServerRoutes(mux)
}
h.mountOAuth2Routes(mux)
mcpHandler := h.AuthedSSEServer(securityList)
basePath := h.config.BasePath
if basePath == "" {
basePath = "/mcp"
}
mux.Handle(basePath+"/sse", mcpHandler)
mux.Handle(basePath+"/message", mcpHandler)
mux.Handle(basePath+"/", http.StripPrefix(basePath, mcpHandler))
return mux
}
// StreamableHTTPMux returns a single http.Handler that serves:
// - MCP OAuth2 authorization server endpoints (when EnableOAuthServer has been called)
// - OAuth2 login + callback routes for every registered provider (legacy cookie flow)
// - The MCP streamable HTTP transport wrapped with required authentication middleware
//
// Example:
//
// http.ListenAndServe(":8080", handler.StreamableHTTPMux(securityList))
func (h *Handler) StreamableHTTPMux(securityList *security.SecurityList) http.Handler {
mux := http.NewServeMux()
if h.oauthSrv != nil {
h.mountOAuthServerRoutes(mux)
}
h.mountOAuth2Routes(mux)
mcpHandler := h.AuthedStreamableHTTPServer(securityList)
basePath := h.config.BasePath
if basePath == "" {
basePath = "/mcp"
}
mux.Handle(basePath+"/", http.StripPrefix(basePath, mcpHandler))
mux.Handle(basePath, mcpHandler)
return mux
}
// mountOAuth2Routes registers all stored OAuth2 login+callback routes onto mux.
func (h *Handler) mountOAuth2Routes(mux *http.ServeMux) {
for _, reg := range h.oauth2Regs {
var cookieOpts []security.SessionCookieOptions
if reg.cfg.CookieOptions != nil {
cookieOpts = append(cookieOpts, *reg.cfg.CookieOptions)
}
mux.Handle(reg.cfg.LoginPath, OAuth2LoginHandler(reg.auth, reg.cfg.ProviderName))
mux.Handle(reg.cfg.CallbackPath, OAuth2CallbackHandler(reg.auth, reg.cfg.ProviderName, reg.cfg.AfterLoginRedirect, cookieOpts...))
}
}
// --------------------------------------------------------------------------
// Auth-wrapped transports
// --------------------------------------------------------------------------
// AuthedSSEServer wraps SSEServer with required authentication middleware from pkg/security.
// The middleware reads the session cookie / Authorization header and populates the user
// context into the request context, making it available to BeforeHandle security hooks.
// Unauthenticated requests receive 401 before reaching any MCP tool.
func (h *Handler) AuthedSSEServer(securityList *security.SecurityList) http.Handler {
return security.NewAuthMiddleware(securityList)(h.SSEServer())
}
// OptionalAuthSSEServer wraps SSEServer with optional authentication middleware.
// Unauthenticated requests continue as guest rather than returning 401.
// Use together with RegisterSecurityHooks and per-model CanPublicRead/Write rules
// to allow mixed public/private access.
func (h *Handler) OptionalAuthSSEServer(securityList *security.SecurityList) http.Handler {
return security.NewOptionalAuthMiddleware(securityList)(h.SSEServer())
}
// AuthedStreamableHTTPServer wraps StreamableHTTPServer with required authentication middleware.
func (h *Handler) AuthedStreamableHTTPServer(securityList *security.SecurityList) http.Handler {
return security.NewAuthMiddleware(securityList)(h.StreamableHTTPServer())
}
// OptionalAuthStreamableHTTPServer wraps StreamableHTTPServer with optional authentication middleware.
func (h *Handler) OptionalAuthStreamableHTTPServer(securityList *security.SecurityList) http.Handler {
return security.NewOptionalAuthMiddleware(securityList)(h.StreamableHTTPServer())
}
// --------------------------------------------------------------------------
// OAuth2 route config and standalone handlers
// --------------------------------------------------------------------------
// OAuth2RouteConfig configures the OAuth2 HTTP endpoints for a single provider.
type OAuth2RouteConfig struct {
// ProviderName is the OAuth2 provider name as registered with WithOAuth2()
// (e.g. "google", "github", "microsoft").
ProviderName string
// LoginPath is the HTTP path that redirects the browser to the OAuth2 provider
// (e.g. "/auth/google/login").
LoginPath string
// CallbackPath is the HTTP path that the OAuth2 provider redirects back to
// (e.g. "/auth/google/callback"). Must match the RedirectURL in OAuth2Config.
CallbackPath string
// AfterLoginRedirect is the URL to redirect the browser to after a successful
// login. When empty the LoginResponse JSON is written directly to the response.
AfterLoginRedirect string
// CookieOptions customises the session cookie written on successful login.
// Defaults to HttpOnly, Secure, SameSite=Lax when nil.
CookieOptions *security.SessionCookieOptions
}
// OAuth2LoginHandler returns an http.HandlerFunc that redirects the browser to
// the OAuth2 provider's authorization URL.
//
// Register it on any router:
//
// mux.Handle("/auth/google/login", resolvemcp.OAuth2LoginHandler(auth, "google"))
func OAuth2LoginHandler(auth *security.DatabaseAuthenticator, providerName string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state, err := auth.OAuth2GenerateState()
if err != nil {
http.Error(w, "failed to generate state", http.StatusInternalServerError)
return
}
authURL, err := auth.OAuth2GetAuthURL(providerName, state)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
}
// OAuth2CallbackHandler returns an http.HandlerFunc that handles the OAuth2 provider
// callback: exchanges the authorization code for a session token, writes the session
// cookie, then either redirects to afterLoginRedirect or writes the LoginResponse as JSON.
//
// Register it on any router:
//
// mux.Handle("/auth/google/callback", resolvemcp.OAuth2CallbackHandler(auth, "google", "/dashboard"))
func OAuth2CallbackHandler(auth *security.DatabaseAuthenticator, providerName, afterLoginRedirect string, cookieOpts ...security.SessionCookieOptions) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" {
http.Error(w, "missing code parameter", http.StatusBadRequest)
return
}
loginResp, err := auth.OAuth2HandleCallback(r.Context(), providerName, code, state)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
security.SetSessionCookie(w, loginResp, cookieOpts...)
if afterLoginRedirect != "" {
http.Redirect(w, r, afterLoginRedirect, http.StatusTemporaryRedirect)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(loginResp) //nolint:errcheck
}
}
// --------------------------------------------------------------------------
// Gorilla Mux convenience helpers
// --------------------------------------------------------------------------
// SetupMuxOAuth2Routes registers the OAuth2 login and callback routes on a Gorilla Mux router.
//
// Example:
//
// resolvemcp.SetupMuxOAuth2Routes(r, auth, resolvemcp.OAuth2RouteConfig{
// ProviderName: "google", LoginPath: "/auth/google/login",
// CallbackPath: "/auth/google/callback", AfterLoginRedirect: "/",
// })
func SetupMuxOAuth2Routes(muxRouter *mux.Router, auth *security.DatabaseAuthenticator, cfg OAuth2RouteConfig) {
var cookieOpts []security.SessionCookieOptions
if cfg.CookieOptions != nil {
cookieOpts = append(cookieOpts, *cfg.CookieOptions)
}
muxRouter.Handle(cfg.LoginPath,
OAuth2LoginHandler(auth, cfg.ProviderName),
).Methods(http.MethodGet)
muxRouter.Handle(cfg.CallbackPath,
OAuth2CallbackHandler(auth, cfg.ProviderName, cfg.AfterLoginRedirect, cookieOpts...),
).Methods(http.MethodGet)
}
// SetupMuxRoutesWithAuth mounts the MCP SSE endpoints on a Gorilla Mux router
// with required authentication middleware applied.
func SetupMuxRoutesWithAuth(muxRouter *mux.Router, handler *Handler, securityList *security.SecurityList) {
basePath := handler.config.BasePath
h := handler.AuthedSSEServer(securityList)
muxRouter.Handle(basePath+"/sse", h).Methods(http.MethodGet, http.MethodOptions)
muxRouter.Handle(basePath+"/message", h).Methods(http.MethodPost, http.MethodOptions)
muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, h))
}
// SetupMuxStreamableHTTPRoutesWithAuth mounts the MCP streamable HTTP endpoint on a
// Gorilla Mux router with required authentication middleware applied.
func SetupMuxStreamableHTTPRoutesWithAuth(muxRouter *mux.Router, handler *Handler, securityList *security.SecurityList) {
basePath := handler.config.BasePath
h := handler.AuthedStreamableHTTPServer(securityList)
muxRouter.PathPrefix(basePath).Handler(http.StripPrefix(basePath, h))
}

View File

@@ -0,0 +1,51 @@
package resolvemcp
import (
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// EnableOAuthServer activates the MCP-standard OAuth2 authorization server on this Handler.
//
// Pass a DatabaseAuthenticator to enable direct username/password login — the server acts as
// its own identity provider and renders a login form at /oauth/authorize. Pass nil to use
// only external providers registered via RegisterOAuth2Provider.
//
// After calling this, HTTPHandler and StreamableHTTPMux serve the full set of RFC-compliant
// endpoints required by MCP clients alongside the MCP transport:
//
// GET /.well-known/oauth-authorization-server RFC 8414 — auto-discovery
// POST /oauth/register RFC 7591 — dynamic client registration
// GET /oauth/authorize OAuth 2.1 + PKCE — start login
// POST /oauth/authorize Login form submission (password flow)
// POST /oauth/token Bearer token exchange + refresh
// GET /oauth/provider/callback External provider redirect target
func (h *Handler) EnableOAuthServer(cfg security.OAuthServerConfig, auth *security.DatabaseAuthenticator) {
h.oauthSrv = security.NewOAuthServer(cfg, auth)
// Wire any external providers already registered via RegisterOAuth2
for _, reg := range h.oauth2Regs {
h.oauthSrv.RegisterExternalProvider(reg.auth, reg.cfg.ProviderName)
}
}
// RegisterOAuth2Provider adds an external OAuth2 provider to the MCP OAuth2 authorization server.
// EnableOAuthServer must be called before this. The auth must have been configured with
// WithOAuth2(providerName, ...) for the given provider name.
func (h *Handler) RegisterOAuth2Provider(auth *security.DatabaseAuthenticator, providerName string) {
if h.oauthSrv != nil {
h.oauthSrv.RegisterExternalProvider(auth, providerName)
}
}
// mountOAuthServerRoutes mounts the security.OAuthServer's HTTP handler onto mux.
func (h *Handler) mountOAuthServerRoutes(mux *http.ServeMux) {
oauthHandler := h.oauthSrv.HTTPHandler()
// Delegate all /oauth/ and /.well-known/ paths to the OAuth server
mux.Handle("/.well-known/", oauthHandler)
mux.Handle("/oauth/", oauthHandler)
if h.oauthSrv != nil {
// Also mount the external provider callback path if it differs from /oauth/
mux.Handle(h.oauthSrv.ProviderCallbackPath(), oauthHandler)
}
}