From 58c57412a96053ec8393faa88c26807c06f79f30 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 18 May 2026 22:21:54 +0200 Subject: [PATCH] feat(brain-mcp): OAuth 2.0 client_credentials flow for claude.ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DECISIONS.md | 28 +++++ ingestion/cmd/server/main.go | 29 +++++ ingestion/internal/oauth/metadata.go | 38 ++++++ ingestion/internal/oauth/metadata_test.go | 41 +++++++ ingestion/internal/oauth/token.go | 87 ++++++++++++++ ingestion/internal/oauth/token_test.go | 134 ++++++++++++++++++++++ 6 files changed, 357 insertions(+) create mode 100644 ingestion/internal/oauth/metadata.go create mode 100644 ingestion/internal/oauth/metadata_test.go create mode 100644 ingestion/internal/oauth/token.go create mode 100644 ingestion/internal/oauth/token_test.go diff --git a/DECISIONS.md b/DECISIONS.md index 3290543..8f35e41 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -139,3 +139,31 @@ manifest is a fresh namespace under `k3s/staging//` — isolated, low blast-radius, and Flux will simply recreate it if the file is bad. Manual review gating was friction for no compensating safety gain on experiment namespaces. + +--- + +## 2026-05-18 — pgvector over Qdrant for brain hybrid retrieval (supersedes 2026-04-08) + +**Context:** The 2026-04-08 ADR chose Qdrant for vector store. Since then, +postgres18 with pgvector has been deployed in the `databases` namespace on +koala and is already the shared default for the rest of the project +(CLAUDE.md lists `pgvector (vector), BM25` as the primary search layer and +Qdrant only as a fallback "when >1M vectors or hybrid retrieval"). Qdrant +itself has never been deployed — `kubectl get` finds no pod, service, or +manifest. Standing up a new vector engine for a single consumer is friction +that the original ADR did not weigh. + +**Decision:** Use pgvector for brain hybrid retrieval. Issue #8 — and any +follow-on embedding work — targets the existing `postgres18` instance: + +- one table `brain_embeddings(path TEXT PRIMARY KEY, embedding VECTOR(768), updated_at TIMESTAMPTZ)`, + IVFFlat or HNSW index by feel once volume warrants +- BM25 stays as today (file walk + token frequency); cosine via pgvector +- hybrid scoring done in SQL or Go; pick once we measure +- nomic-embed-text on iguana ollama provides 768-dim vectors + +**Consequences:** One database engine instead of two. Backups, monitoring, +and connection pooling already solved. Trade-off: pgvector at >1M vectors +or under hybrid-search load may underperform Qdrant — revisit only when +benchmarks hurt. The 2026-04-08 ADR is superseded for the brain use case; +Qdrant remains the noted fallback path in CLAUDE.md if scale demands it. diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 9ce241e..0e2f886 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "github.com/mathiasbq/hyperguild/ingestion/internal/auth" "github.com/mathiasbq/hyperguild/ingestion/internal/llm" "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/mathiasbq/hyperguild/ingestion/internal/oauth" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/watcher" ) @@ -128,6 +129,34 @@ func main() { mux.Handle("/mcp", mcp.BearerAuth(mcpToken, jwtValidator, resourceMetadataURL, mcpSrv)) + // Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP + // integration UI, which has no static-Bearer field. Setting both + // OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange; + // setting only one is misconfiguration → fail fast. + oauthID := os.Getenv("OAUTH_CLIENT_ID") + oauthSecret := os.Getenv("OAUTH_CLIENT_SECRET") + switch { + case oauthID != "" && oauthSecret != "": + issuer := os.Getenv("MCP_RESOURCE_URL") + if issuer == "" { + logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer") + os.Exit(1) + } + mux.HandleFunc("GET /.well-known/oauth-authorization-server", + oauth.MetadataHandler(issuer)) + mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{ + ClientID: oauthID, + ClientSecret: oauthSecret, + AccessToken: mcpToken, + })) + logger.Info("oauth client_credentials enabled", "issuer", strings.TrimRight(issuer, "/")) + case oauthID == "" && oauthSecret == "": + // disabled — that's fine + default: + logger.Error("OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET must be set together") + os.Exit(1) + } + addr := ":" + port watchIntervalLog := "disabled" if watchInterval > 0 { diff --git a/ingestion/internal/oauth/metadata.go b/ingestion/internal/oauth/metadata.go new file mode 100644 index 0000000..202bd77 --- /dev/null +++ b/ingestion/internal/oauth/metadata.go @@ -0,0 +1,38 @@ +// Package oauth implements a minimal OAuth 2.0 client_credentials flow +// for the brain MCP server. Designed for claude.ai's custom MCP integration +// UI, which only supports OAuth (no static-Bearer field). The flow trades +// a registered client_id + client_secret for the existing BRAIN_MCP_TOKEN — +// no JWTs, no expiry, no refresh — so the rest of the auth middleware is +// unchanged. +package oauth + +import ( + "encoding/json" + "net/http" + "strings" +) + +// MetadataHandler serves RFC 8414 authorization-server metadata at +// GET /.well-known/oauth-authorization-server. issuer must be the public +// origin of the brain MCP (e.g. https://brain-mcp.d-ma.be); the handler +// derives the token endpoint from it. +// +// Mount with no auth — discovery must be reachable to anonymous callers. +func MetadataHandler(issuer string) http.HandlerFunc { + issuer = strings.TrimRight(issuer, "/") + body, _ := json.Marshal(struct { + Issuer string `json:"issuer"` + TokenEndpoint string `json:"token_endpoint"` + GrantTypes []string `json:"grant_types_supported"` + TokenEndpointAuthMeth []string `json:"token_endpoint_auth_methods_supported"` + }{ + Issuer: issuer, + TokenEndpoint: issuer + "/oauth/token", + GrantTypes: []string{"client_credentials"}, + TokenEndpointAuthMeth: []string{"client_secret_post", "client_secret_basic"}, + }) + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } +} diff --git a/ingestion/internal/oauth/metadata_test.go b/ingestion/internal/oauth/metadata_test.go new file mode 100644 index 0000000..74e70c0 --- /dev/null +++ b/ingestion/internal/oauth/metadata_test.go @@ -0,0 +1,41 @@ +package oauth_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/oauth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetadataHandler_ReturnsJSON(t *testing.T) { + h := oauth.MetadataHandler("https://brain-mcp.d-ma.be") + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + var body map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) + assert.Equal(t, "https://brain-mcp.d-ma.be", body["issuer"]) + assert.Equal(t, "https://brain-mcp.d-ma.be/oauth/token", body["token_endpoint"]) + assert.ElementsMatch(t, []any{"client_credentials"}, body["grant_types_supported"]) + assert.ElementsMatch(t, + []any{"client_secret_post", "client_secret_basic"}, + body["token_endpoint_auth_methods_supported"]) +} + +func TestMetadataHandler_StripsTrailingSlashFromIssuer(t *testing.T) { + h := oauth.MetadataHandler("https://brain-mcp.d-ma.be/") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)) + var body map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) + assert.Equal(t, "https://brain-mcp.d-ma.be", body["issuer"]) + assert.Equal(t, "https://brain-mcp.d-ma.be/oauth/token", body["token_endpoint"]) +} diff --git a/ingestion/internal/oauth/token.go b/ingestion/internal/oauth/token.go new file mode 100644 index 0000000..59d488e --- /dev/null +++ b/ingestion/internal/oauth/token.go @@ -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}) +} diff --git a/ingestion/internal/oauth/token_test.go b/ingestion/internal/oauth/token_test.go new file mode 100644 index 0000000..770645b --- /dev/null +++ b/ingestion/internal/oauth/token_test.go @@ -0,0 +1,134 @@ +package oauth_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/oauth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTokenServer() *httptest.Server { + return httptest.NewServer(oauth.TokenHandler(oauth.TokenConfig{ + ClientID: "the-client", + ClientSecret: "the-secret", + AccessToken: "BRAIN_TOKEN_VALUE", + })) +} + +func postForm(t *testing.T, srv *httptest.Server, vals url.Values, basic [2]string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", strings.NewReader(vals.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if basic[0] != "" { + req.SetBasicAuth(basic[0], basic[1]) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func TestTokenHandler_ClientSecretPost_Success(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + resp := postForm(t, srv, url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"the-client"}, + "client_secret": {"the-secret"}, + }, [2]string{}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "BRAIN_TOKEN_VALUE", body["access_token"]) + assert.Equal(t, "bearer", body["token_type"]) +} + +func TestTokenHandler_ClientSecretBasic_Success(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + resp := postForm(t, srv, + url.Values{"grant_type": {"client_credentials"}}, + [2]string{"the-client", "the-secret"}, + ) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestTokenHandler_WrongSecret(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + resp := postForm(t, srv, url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"the-client"}, + "client_secret": {"wrong"}, + }, [2]string{}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "invalid_client", body["error"]) +} + +func TestTokenHandler_BadGrantType(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + resp := postForm(t, srv, url.Values{ + "grant_type": {"password"}, + "client_id": {"the-client"}, + "client_secret": {"the-secret"}, + }, [2]string{}) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "unsupported_grant_type", body["error"]) +} + +func TestTokenHandler_RejectsGet(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + resp, err := http.Get(srv.URL + "/oauth/token") + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) +} + +func TestTokenHandler_BasicMalformed_FallsThrough(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + // Malformed (non-base64) Authorization header — handler should treat + // the request as missing creds, not crash. + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", + strings.NewReader("grant_type=client_credentials")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic ###not-base64###") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestTokenHandler_BasicNoColon(t *testing.T) { + srv := newTokenServer() + defer srv.Close() + // "client-only" base64 — missing the `:secret` half. + enc := base64.StdEncoding.EncodeToString([]byte("the-client")) + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", + strings.NewReader("grant_type=client_credentials")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+enc) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +}