feat(brain-mcp): OAuth 2.0 client_credentials flow for claude.ai
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s

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:
Mathias
2026-05-18 22:21:54 +02:00
parent ddd07ae7eb
commit 58c57412a9
6 changed files with 357 additions and 0 deletions

View File

@@ -139,3 +139,31 @@ manifest is a fresh namespace under `k3s/staging/<name>/` — isolated, low
blast-radius, and Flux will simply recreate it if the file is bad. Manual 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 review gating was friction for no compensating safety gain on experiment
namespaces. 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.

View File

@@ -15,6 +15,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/auth" "github.com/mathiasbq/hyperguild/ingestion/internal/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/llm" "github.com/mathiasbq/hyperguild/ingestion/internal/llm"
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp" "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/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher" "github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
) )
@@ -128,6 +129,34 @@ func main() {
mux.Handle("/mcp", mcp.BearerAuth(mcpToken, jwtValidator, resourceMetadataURL, mcpSrv)) 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 addr := ":" + port
watchIntervalLog := "disabled" watchIntervalLog := "disabled"
if watchInterval > 0 { if watchInterval > 0 {

View File

@@ -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)
}
}

View File

@@ -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"])
}

View 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})
}

View File

@@ -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)
}