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.
135 lines
4.3 KiB
Go
135 lines
4.3 KiB
Go
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)
|
|
}
|