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:
28
DECISIONS.md
28
DECISIONS.md
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
38
ingestion/internal/oauth/metadata.go
Normal file
38
ingestion/internal/oauth/metadata.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
ingestion/internal/oauth/metadata_test.go
Normal file
41
ingestion/internal/oauth/metadata_test.go
Normal 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"])
|
||||
}
|
||||
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})
|
||||
}
|
||||
134
ingestion/internal/oauth/token_test.go
Normal file
134
ingestion/internal/oauth/token_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user