feat(brain-mcp): OAuth 2.0 client_credentials flow for claude.ai
Adds a minimal RFC 8414 + RFC 6749 client_credentials flow so claude.ai's custom-MCP integration (no static-Bearer field in the UI) can exchange a client_id + client_secret pair for the existing BRAIN_MCP_TOKEN and use it as a Bearer on /mcp. No JWTs, no refresh, no expiry — the rest of the auth middleware is unchanged. New package ingestion/internal/oauth: - MetadataHandler(issuer): serves /.well-known/oauth-authorization-server with grant_types=[client_credentials] and both token_endpoint_auth_methods (post + basic). - TokenHandler(cfg): serves /oauth/token. Validates client_id and client_secret via constant-time compare; returns BRAIN_MCP_TOKEN as access_token. RFC 6749 §5.2 error JSON on bad grant / bad creds. Wiring in cmd/server/main.go: opt-in by setting both OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET. Setting only one is misconfiguration → exit 1. Mounts both endpoints with no auth; MCP_RESOURCE_URL supplies the issuer. Also pivots issue #8's vector backend from Qdrant to pgvector (see DECISIONS.md 2026-05-18) — Qdrant was never deployed and postgres18 with pgvector already runs as the project default; supersedes 2026-04-08 for this use case. Tests cover post-auth, basic-auth, wrong secret, bad grant, GET rejection, malformed Basic header, and Basic without colon. Closes hyperguild#5.
This commit is contained in:
87
ingestion/internal/oauth/token.go
Normal file
87
ingestion/internal/oauth/token.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// TokenConfig is the static configuration for the token endpoint. All
|
||||
// three fields are required.
|
||||
type TokenConfig struct {
|
||||
// ClientID and ClientSecret are the single accepted credentials.
|
||||
// claude.ai's custom-MCP UI persists these on its side.
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// AccessToken is the bearer value handed back on a successful
|
||||
// exchange. In this deployment it is BRAIN_MCP_TOKEN — the same
|
||||
// static token the rest of the auth middleware already accepts —
|
||||
// so no JWT machinery is needed downstream.
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// TokenHandler serves POST /oauth/token. Implements the
|
||||
// client_credentials grant only, with client_secret_post and
|
||||
// client_secret_basic auth methods (both advertised by MetadataHandler).
|
||||
// Errors follow RFC 6749 §5.2 — JSON body with an "error" field.
|
||||
//
|
||||
// Mount with no auth — credentials live in the request body / header.
|
||||
func TokenHandler(cfg TokenConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
writeOAuthError(w, http.StatusMethodNotAllowed, "invalid_request", "POST required")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "form parse")
|
||||
return
|
||||
}
|
||||
if r.PostForm.Get("grant_type") != "client_credentials" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||
"only client_credentials is supported")
|
||||
return
|
||||
}
|
||||
|
||||
clientID, clientSecret := extractClientCreds(r)
|
||||
if !constantTimeEqual(clientID, cfg.ClientID) ||
|
||||
!constantTimeEqual(clientSecret, cfg.ClientSecret) {
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "bad credentials")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}{cfg.AccessToken, "bearer"})
|
||||
}
|
||||
}
|
||||
|
||||
// extractClientCreds returns the client_id and client_secret pair from
|
||||
// either client_secret_basic (HTTP Basic) or client_secret_post (form
|
||||
// fields). When both are present, Basic wins per RFC 6749 §2.3.1.
|
||||
func extractClientCreds(r *http.Request) (string, string) {
|
||||
if id, secret, ok := r.BasicAuth(); ok {
|
||||
return id, secret
|
||||
}
|
||||
return r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func writeOAuthError(w http.ResponseWriter, status int, code, desc string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}{code, desc})
|
||||
}
|
||||
Reference in New Issue
Block a user