diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index 7d3a8a4e..9a94a079 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -70,6 +70,21 @@ jobs: sleep 5 done + echo "Waiting for the MCP server to be healthy..." + for i in $(seq 1 40); do + if curl -sf http://localhost:8082/health >/dev/null 2>&1; then + echo "MCP server is healthy!" + break + fi + if [ $i -eq 40 ]; then + echo "MCP server failed to become healthy" + docker compose logs mcp + exit 1 + fi + echo "MCP attempt $i/40 - waiting 5s..." + sleep 5 + done + - name: Seed Database working-directory: ./tests run: | @@ -77,6 +92,14 @@ jobs: docker compose wait seed || true sleep 2 + - name: Run MCP Unit Tests + working-directory: ./mcp + run: go test -race -count=1 ./... + + - name: Build MCP Server + working-directory: ./mcp + run: go build ./cmd/server + - name: Run Handler Integration Tests working-directory: ./api env: @@ -85,7 +108,7 @@ jobs: - name: Run Integration Tests working-directory: ./tests - run: go test -v -timeout 300s ./... + run: go test -v -timeout 900s ./... - name: Collect Logs on Failure if: failure() diff --git a/README.md b/README.md index 42a6b0d3..c4d535dc 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ bash generate-firebase-credentials.sh export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) docker compose up -d --build --wait docker compose wait seed && sleep 2 -go test -v -timeout 120s ./... +go test -v -timeout 900s ./... docker compose down -v ``` diff --git a/api/docs/docs.go b/api/docs/docs.go index eb72ac76..35893f97 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1571,6 +1571,107 @@ const docTemplate = `{ } } }, + "/messages/incoming": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This returns the list of mobile-originated messages received by the user's phones. This route is scoped to messages:read and never returns other message types", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Search incoming messages of a user", + "parameters": [ + { + "type": "string", + "default": "+18005550199,+18005550100", + "description": "the owner's phone numbers", + "name": "owners", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "filter by message status", + "name": "statuses", + "in": "query" + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "field used to sort the messages", + "name": "sort_by", + "in": "query" + }, + { + "type": "boolean", + "description": "sort messages in descending order", + "name": "sort_descending", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/messages/outstanding": { "get": { "security": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 59c5637b..2521f90f 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1568,6 +1568,107 @@ } } }, + "/messages/incoming": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This returns the list of mobile-originated messages received by the user's phones. This route is scoped to messages:read and never returns other message types", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Search incoming messages of a user", + "parameters": [ + { + "type": "string", + "default": "+18005550199,+18005550100", + "description": "the owner's phone numbers", + "name": "owners", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "filter by message status", + "name": "statuses", + "in": "query" + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "field used to sort the messages", + "name": "sort_by", + "in": "query" + }, + { + "type": "boolean", + "description": "sort messages in descending order", + "name": "sort_descending", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/messages/outstanding": { "get": { "security": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 6dc24021..5f655798 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -2920,6 +2920,75 @@ paths: summary: Register a missed call event on the mobile phone tags: - Messages + /messages/incoming: + get: + consumes: + - application/json + description: This returns the list of mobile-originated messages received by + the user's phones. This route is scoped to messages:read and never returns + other message types + parameters: + - default: +18005550199,+18005550100 + description: the owner's phone numbers + in: query + name: owners + required: true + type: string + - description: filter by message status + in: query + name: statuses + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter messages containing query + in: query + name: query + type: string + - description: field used to sort the messages + in: query + name: sort_by + type: string + - description: sort messages in descending order + in: query + name: sort_descending + type: boolean + - description: number of messages to return + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.MessagesResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/responses.BadRequest' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/responses.Unauthorized' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/responses.UnprocessableEntity' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/responses.InternalServerError' + security: + - ApiKeyAuth: [] + summary: Search incoming messages of a user + tags: + - Messages /messages/outstanding: get: consumes: diff --git a/api/pkg/auth/mcp_claims.go b/api/pkg/auth/mcp_claims.go new file mode 100644 index 00000000..cd105844 --- /dev/null +++ b/api/pkg/auth/mcp_claims.go @@ -0,0 +1,21 @@ +package auth + +import "github.com/golang-jwt/jwt/v5" + +// MCPClaims are the claims embedded in a delegated MCP API JWT minted by the +// hosted MCP service on behalf of an authenticated user. The token is scoped +// to a single API operation: it is only valid for the exact HTTP method and +// path it was minted for, and only when it carries the scope that operation +// requires. +type MCPClaims struct { + // Scopes are the downstream API scopes granted to this delegated token. + Scopes []string `json:"scopes"` + + // Method is the HTTP method this delegated token is bound to. + Method string `json:"http_method"` + + // Path is the HTTP request path this delegated token is bound to. + Path string `json:"http_path"` + + jwt.RegisteredClaims +} diff --git a/api/pkg/auth/mcp_jwks.go b/api/pkg/auth/mcp_jwks.go new file mode 100644 index 00000000..ae38801a --- /dev/null +++ b/api/pkg/auth/mcp_jwks.go @@ -0,0 +1,266 @@ +package auth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" + + "github.com/NdoleStudio/stacktrace" +) + +const ( + // mcpJWKSDefaultCacheTTL is used when MCPTokenVerifierConfig.CacheTTL is not set. + mcpJWKSDefaultCacheTTL = 15 * time.Minute + + // mcpJWKSHTTPTimeout bounds every HTTP call made to fetch the JWKS document. + mcpJWKSHTTPTimeout = 2 * time.Second + + // mcpJWKSMaxResponseBytes bounds the size of the JWKS document read from the network. + mcpJWKSMaxResponseBytes = 1 << 20 // 1 MiB + + // mcpJWKSDefaultMinRefreshInterval is the default minimum delay between two outbound + // fetches of the JWKS endpoint. It bounds refresh amplification: without it, a flood of + // tokens carrying random unknown "kid" headers would cause one outbound fetch per + // request. The MCP service publishes a rotated signing key before it starts signing with + // it, so a legitimate rotation is still picked up -- at worst one interval late. + mcpJWKSDefaultMinRefreshInterval = time.Minute +) + +// errMCPJWKSRefreshThrottled reports that a JWKS refresh was skipped because the minimum +// refresh interval has not elapsed yet. +var errMCPJWKSRefreshThrottled = errors.New("MCP JWKS refresh is rate limited") + +// mcpJWK is a single JSON Web Key as published by a JWKS endpoint. Only the +// fields required to build an RSA public key are decoded. +type mcpJWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +// mcpJWKSet is the JSON Web Key Set document shape. +type mcpJWKSet struct { + Keys []mcpJWK `json:"keys"` +} + +// mcpJWKSCache fetches and caches the RSA public keys published by a JWKS +// endpoint, keyed by "kid". +// +// Two bounds keep an attacker from turning a stream of tokens carrying random unknown "kid" +// headers into a stream of outbound fetches: +// +// - concurrent refreshes are collapsed into a single in-flight fetch that every waiting +// caller shares, and +// - a new fetch is never started until minRefreshInterval has elapsed since the previous +// attempt (successful or not); until then, callers either reuse an already cached key or +// fail closed. +// +// A legitimate key rotation is still picked up: the MCP service publishes a rotated key before +// signing with it, and a missing "kid" triggers a real refresh as soon as the interval has +// elapsed. +type mcpJWKSCache struct { + url string + httpClient *http.Client + cacheTTL time.Duration + minRefreshInterval time.Duration + + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time + lastAttemptAt time.Time + inflight *mcpJWKSRefresh +} + +// mcpJWKSRefresh is a single in-flight JWKS refresh shared by every caller that arrives while +// it is running. err is written before done is closed, so a waiter that observes done may +// safely read it. +type mcpJWKSRefresh struct { + done chan struct{} + err error +} + +// newMCPJWKSCache creates a new mcpJWKSCache for the given JWKS URL. minRefreshInterval may be +// <= 0, in which case mcpJWKSDefaultMinRefreshInterval is used. +func newMCPJWKSCache(url string, httpClient *http.Client, cacheTTL time.Duration, minRefreshInterval time.Duration) *mcpJWKSCache { + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Reuse the caller's transport (important for tests using httptest + // servers) but always enforce our own bounded timeout. + client := &http.Client{ + Transport: httpClient.Transport, + Timeout: mcpJWKSHTTPTimeout, + } + + if cacheTTL <= 0 { + cacheTTL = mcpJWKSDefaultCacheTTL + } + if minRefreshInterval <= 0 { + minRefreshInterval = mcpJWKSDefaultMinRefreshInterval + } + + return &mcpJWKSCache{ + url: url, + httpClient: client, + cacheTTL: cacheTTL, + minRefreshInterval: minRefreshInterval, + keys: map[string]*rsa.PublicKey{}, + } +} + +// key returns the cached RSA public key for kid, refreshing the JWKS document when the cache is +// stale or the key is not yet known -- subject to the collapsing and rate limiting described on +// mcpJWKSCache. +func (cache *mcpJWKSCache) key(ctx context.Context, kid string) (*rsa.PublicKey, error) { + cache.mu.Lock() + key, ok := cache.keys[kid] + expired := time.Since(cache.fetchedAt) >= cache.cacheTTL + cache.mu.Unlock() + + if ok && !expired { + return key, nil + } + + if err := cache.refreshOnce(ctx); err != nil { + // A rate-limited refresh must not invalidate a key we already hold: serving the + // (stale but still published) cached key is strictly better than failing a + // legitimate request because the cache TTL elapsed moments after the last fetch + // attempt. + if errors.Is(err, errMCPJWKSRefreshThrottled) && ok { + return key, nil + } + return nil, stacktrace.Propagatef(err, "cannot refresh MCP JWKS from [%s]", cache.url) + } + + cache.mu.Lock() + key, ok = cache.keys[kid] + cache.mu.Unlock() + if !ok { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "MCP JWKS has no key with kid [%s]", kid) + } + + return key, nil +} + +// refreshOnce performs at most one outbound JWKS fetch on behalf of every caller that needs one +// at the same time, and refuses to start a new fetch until minRefreshInterval has elapsed since +// the previous attempt. +func (cache *mcpJWKSCache) refreshOnce(ctx context.Context) error { + cache.mu.Lock() + + if inflight := cache.inflight; inflight != nil { + cache.mu.Unlock() + select { + case <-inflight.done: + return inflight.err + case <-ctx.Done(): + return ctx.Err() + } + } + + if !cache.lastAttemptAt.IsZero() && time.Since(cache.lastAttemptAt) < cache.minRefreshInterval { + cache.mu.Unlock() + return errMCPJWKSRefreshThrottled + } + + inflight := &mcpJWKSRefresh{done: make(chan struct{})} + cache.inflight = inflight + cache.lastAttemptAt = time.Now() + cache.mu.Unlock() + + err := cache.refresh(ctx) + inflight.err = err + + cache.mu.Lock() + cache.inflight = nil + cache.mu.Unlock() + close(inflight.done) + + return err +} + +// refresh fetches and replaces the cached JWKS key set. +func (cache *mcpJWKSCache) refresh(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cache.url, nil) + if err != nil { + return stacktrace.Propagatef(err, "cannot create request for MCP JWKS URL [%s]", cache.url) + } + + resp, err := cache.httpClient.Do(req) + if err != nil { + return stacktrace.Propagatef(err, "cannot fetch MCP JWKS from [%s]", cache.url) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return stacktrace.NewErrorf("MCP JWKS endpoint [%s] returned status code [%d]", cache.url, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, mcpJWKSMaxResponseBytes+1)) + if err != nil { + return stacktrace.Propagatef(err, "cannot read response body from MCP JWKS URL [%s]", cache.url) + } + if len(body) > mcpJWKSMaxResponseBytes { + return stacktrace.NewErrorf("MCP JWKS response from [%s] exceeds the [%d] byte limit", cache.url, mcpJWKSMaxResponseBytes) + } + + var set mcpJWKSet + if err = json.Unmarshal(body, &set); err != nil { + return stacktrace.Propagatef(err, "cannot decode MCP JWKS response from [%s]", cache.url) + } + + keys := map[string]*rsa.PublicKey{} + for _, jwk := range set.Keys { + if jwk.Kty != "RSA" || jwk.Kid == "" { + continue + } + + publicKey, err := rsaPublicKeyFromJWK(jwk) + if err != nil { + continue + } + + keys[jwk.Kid] = publicKey + } + + cache.mu.Lock() + cache.keys = keys + cache.fetchedAt = time.Now() + cache.mu.Unlock() + + return nil +} + +// rsaPublicKeyFromJWK constructs an *rsa.PublicKey from the modulus and +// exponent of a JSON Web Key. +func rsaPublicKeyFromJWK(jwk mcpJWK) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, fmt.Errorf("cannot decode modulus for kid [%s]: %w", jwk.Kid, err) + } + + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("cannot decode exponent for kid [%s]: %w", jwk.Kid, err) + } + + e := new(big.Int).SetBytes(eBytes) + if !e.IsInt64() { + return nil, fmt.Errorf("exponent for kid [%s] is out of range", jwk.Kid) + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(e.Int64()), + }, nil +} diff --git a/api/pkg/auth/mcp_jwks_test.go b/api/pkg/auth/mcp_jwks_test.go new file mode 100644 index 00000000..cbacb709 --- /dev/null +++ b/api/pkg/auth/mcp_jwks_test.go @@ -0,0 +1,264 @@ +package auth + +import ( + "context" + "crypto/rsa" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// countingJWKSServer serves the JWKS document for kid/publicKey and counts every request it +// receives, so a test can assert exactly how many outbound fetches a verifier performed. +func countingJWKSServer(t *testing.T, kid string, publicKey *rsa.PublicKey, count *atomic.Int64) *httptest.Server { + t.Helper() + + handler := testJWKSHandler(t, kid, publicKey) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count.Add(1) + handler(w, r) + })) + t.Cleanup(server.Close) + + return server +} + +// newBoundedTestVerifier builds a verifier whose JWKS refreshes are throttled to +// minRefreshInterval. +func newBoundedTestVerifier(t *testing.T, jwksURL string, jwksClient *http.Client, minRefreshInterval time.Duration) *MCPTokenVerifier { + t.Helper() + + verifier, err := NewMCPTokenVerifier(MCPTokenVerifierConfig{ + Issuer: "https://mcp.httpsms.com", + Audience: "https://api.httpsms.com", + JWKSURL: jwksURL, + HTTPClient: jwksClient, + MinRefreshInterval: minRefreshInterval, + }) + require.NoError(t, err) + + return verifier +} + +// TestMCPTokenVerifierVerifyRequest_NonMCPBearerTokensPerformNoJWKSRequests proves the cheap +// unverified-issuer prefilter: the delegated MCP middleware runs before Firebase bearer +// authentication, so Firebase ID tokens (and any other non-MCP bearer value) reach this +// verifier first. None of them may cause a single outbound JWKS fetch. +func TestMCPTokenVerifierVerifyRequest_NonMCPBearerTokensPerformNoJWKSRequests(t *testing.T) { + privateKey, publicKey := testRSAKey(t) + + var requestCount atomic.Int64 + jwks := countingJWKSServer(t, "test-key", publicKey, &requestCount) + + verifier := newBoundedTestVerifier(t, jwks.URL, jwks.Client(), time.Nanosecond) + + firebaseClaims := testMCPClaims("firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages") + firebaseClaims.Issuer = "https://securetoken.google.com/httpsms-test" + firebaseToken := signDelegatedToken(t, privateKey, "firebase-kid", firebaseClaims) + + noIssuerClaims := testMCPClaims("firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages") + noIssuerClaims.Issuer = "" + noIssuerToken := signDelegatedToken(t, privateKey, "unknown-kid", noIssuerClaims) + + tests := []struct { + name string + raw string + }{ + {name: "firebase id token", raw: firebaseToken}, + {name: "token without an issuer", raw: noIssuerToken}, + {name: "opaque api key", raw: "not-a-json-web-token"}, + {name: "empty token", raw: ""}, + {name: "malformed jwt", raw: "aaa.bbb.ccc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims, err := verifier.VerifyRequest(context.Background(), tt.raw, http.MethodGet, "/v1/messages") + + require.Error(t, err) + require.Nil(t, claims) + }) + } + + assert.Equal(t, int64(0), requestCount.Load(), "non-MCP bearer tokens must never reach the JWKS endpoint") +} + +// TestMCPTokenVerifierVerifyRequest_BoundsRefreshesAcrossManyUnknownKids proves that a flood of +// otherwise well-formed MCP-issuer tokens carrying random unknown "kid" headers cannot amplify +// into one outbound JWKS fetch per request. +func TestMCPTokenVerifierVerifyRequest_BoundsRefreshesAcrossManyUnknownKids(t *testing.T) { + privateKey, publicKey := testRSAKey(t) + + var requestCount atomic.Int64 + jwks := countingJWKSServer(t, "test-key", publicKey, &requestCount) + + verifier := newBoundedTestVerifier(t, jwks.URL, jwks.Client(), time.Minute) + + for i := 0; i < 100; i++ { + raw := signDelegatedToken(t, privateKey, fmt.Sprintf("unknown-kid-%d", i), testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )) + + claims, err := verifier.VerifyRequest(context.Background(), raw, http.MethodGet, "/v1/messages") + require.Error(t, err) + require.Nil(t, claims) + } + + assert.Equal(t, int64(1), requestCount.Load(), "unknown kids must cause at most one JWKS fetch per refresh interval") +} + +// TestMCPJWKSCacheCollapsesConcurrentRefreshes proves that callers arriving while a refresh is +// already in flight share it instead of each starting their own fetch. Throttling is disabled +// here so the single fetch is attributable to collapsing alone. +func TestMCPJWKSCacheCollapsesConcurrentRefreshes(t *testing.T) { + _, publicKey := testRSAKey(t) + + var requestCount atomic.Int64 + handler := testJWKSHandler(t, "test-key", publicKey) + jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + // Hold the response open long enough for every concurrent caller to find the + // refresh already in flight. + time.Sleep(100 * time.Millisecond) + handler(w, r) + })) + defer jwks.Close() + + cache := newMCPJWKSCache(jwks.URL, jwks.Client(), time.Minute, time.Nanosecond) + + const callers = 25 + start := make(chan struct{}) + var wg sync.WaitGroup + keys := make([]*rsa.PublicKey, callers) + errs := make([]error, callers) + + for i := 0; i < callers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + <-start + keys[index], errs[index] = cache.key(context.Background(), "test-key") + }(i) + } + + close(start) + wg.Wait() + + for i := 0; i < callers; i++ { + require.NoError(t, errs[i]) + require.NotNil(t, keys[i]) + } + assert.Equal(t, int64(1), requestCount.Load(), "concurrent JWKS cache misses must collapse into one fetch") +} + +// TestMCPJWKSCacheServesKnownKeyWhileRefreshIsThrottled proves that rate limiting never +// invalidates a key the cache already holds: an expired cache TTL combined with a throttled +// refresh still serves the previously published key. +func TestMCPJWKSCacheServesKnownKeyWhileRefreshIsThrottled(t *testing.T) { + _, publicKey := testRSAKey(t) + + var requestCount atomic.Int64 + jwks := countingJWKSServer(t, "test-key", publicKey, &requestCount) + + // A one-nanosecond cache TTL makes every lookup consider the cache stale, so only the + // refresh interval bounds the outbound fetches. + cache := newMCPJWKSCache(jwks.URL, jwks.Client(), time.Nanosecond, time.Minute) + + for i := 0; i < 10; i++ { + key, err := cache.key(context.Background(), "test-key") + require.NoError(t, err) + require.NotNil(t, key) + } + + assert.Equal(t, int64(1), requestCount.Load()) +} + +// TestMCPTokenVerifierVerifyRequest_RefreshesRotatedKeyAfterMinRefreshInterval proves a real +// MCP signing-key rotation is still picked up: the refresh is throttled immediately after the +// previous attempt, and succeeds once the interval has elapsed. +func TestMCPTokenVerifierVerifyRequest_RefreshesRotatedKeyAfterMinRefreshInterval(t *testing.T) { + firstPrivateKey, firstPublicKey := testRSAKey(t) + secondPrivateKey, secondPublicKey := testRSAKey(t) + + var requestCount atomic.Int64 + jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requestCount.Add(1) == 1 { + testJWKSHandler(t, "key-1", firstPublicKey)(w, r) + return + } + testJWKSHandler(t, "key-2", secondPublicKey)(w, r) + })) + defer jwks.Close() + + minRefreshInterval := 150 * time.Millisecond + verifier := newBoundedTestVerifier(t, jwks.URL, jwks.Client(), minRefreshInterval) + + firstToken := signDelegatedToken(t, firstPrivateKey, "key-1", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )) + _, err := verifier.VerifyRequest(context.Background(), firstToken, http.MethodGet, "/v1/messages") + require.NoError(t, err) + require.Equal(t, int64(1), requestCount.Load()) + + secondToken := signDelegatedToken(t, secondPrivateKey, "key-2", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )) + + // Immediately after the first fetch the rotated key cannot be picked up yet: the + // refresh is throttled rather than amplified into another fetch. + _, err = verifier.VerifyRequest(context.Background(), secondToken, http.MethodGet, "/v1/messages") + require.Error(t, err) + require.Equal(t, int64(1), requestCount.Load()) + + time.Sleep(minRefreshInterval + 50*time.Millisecond) + + claims, err := verifier.VerifyRequest(context.Background(), secondToken, http.MethodGet, "/v1/messages") + require.NoError(t, err) + assert.Equal(t, "firebase-user-id", claims.Subject) + assert.Equal(t, int64(2), requestCount.Load()) +} + +// TestHasUnverifiedIssuer covers the prefilter in isolation, including that a matching +// unverified issuer is only ever a "maybe ours" signal. +func TestHasUnverifiedIssuer(t *testing.T) { + privateKey, _ := testRSAKey(t) + + mcpToken := signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )) + + firebaseClaims := testMCPClaims("firebase-user-id", nil, http.MethodGet, "/v1/messages") + firebaseClaims.Issuer = "https://securetoken.google.com/httpsms-test" + firebaseToken := signDelegatedToken(t, privateKey, "test-key", firebaseClaims) + + unsignedToken, err := jwt.NewWithClaims(jwt.SigningMethodNone, testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )).SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + tests := []struct { + name string + raw string + expected bool + }{ + {name: "mcp token", raw: mcpToken, expected: true}, + {name: "firebase token", raw: firebaseToken, expected: false}, + {name: "opaque value", raw: "not-a-json-web-token", expected: false}, + {name: "empty value", raw: "", expected: false}, + {name: "unsigned token with the mcp issuer", raw: unsignedToken, expected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, hasUnverifiedIssuer(tt.raw, "https://mcp.httpsms.com")) + }) + } +} diff --git a/api/pkg/auth/mcp_token_verifier.go b/api/pkg/auth/mcp_token_verifier.go new file mode 100644 index 00000000..e4699708 --- /dev/null +++ b/api/pkg/auth/mcp_token_verifier.go @@ -0,0 +1,221 @@ +package auth + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/NdoleStudio/stacktrace" + "github.com/golang-jwt/jwt/v5" +) + +const ( + // ErrCodeInvalidToken is thrown when a delegated MCP token cannot be verified. + ErrCodeInvalidToken = stacktrace.ErrorCode(3000) + + // ErrCodeInsufficientScope is thrown when a delegated MCP token is valid but does not carry the required scope. + ErrCodeInsufficientScope = stacktrace.ErrorCode(3001) + + // ErrCodeOperationDenied is thrown when a delegated MCP token is valid but is not bound to the requested operation. + ErrCodeOperationDenied = stacktrace.ErrorCode(3002) +) + +// mcpDelegatedRoute is an API operation that can be authorized with a delegated MCP token. +type mcpDelegatedRoute struct { + method string + segments []string + scope string +} + +// mcpDelegatedRoutes are the only API operations a delegated MCP token may authorize. Every +// entry corresponds to a tool in the MCP tool catalog. "*" matches any single path segment, +// which is required for the primary-API-key rotation route which is bound to the authenticated +// user's ID. +var mcpDelegatedRoutes = []mcpDelegatedRoute{ + {method: http.MethodGet, segments: []string{"v1", "phones"}, scope: "phones:read"}, + {method: http.MethodPost, segments: []string{"v1", "messages", "send"}, scope: "messages:send"}, + {method: http.MethodGet, segments: []string{"v1", "message-threads"}, scope: "messages:read"}, + {method: http.MethodGet, segments: []string{"v1", "messages"}, scope: "messages:read"}, + {method: http.MethodGet, segments: []string{"v1", "messages", "incoming"}, scope: "messages:read"}, + {method: http.MethodPost, segments: []string{"v1", "phone-api-keys"}, scope: "phone-api-keys:write"}, + {method: http.MethodDelete, segments: []string{"v1", "users", "*", "api-keys"}, scope: "user-api-key:rotate"}, +} + +// requiredMCPDelegatedScope returns the downstream API scope required to authorize method/path +// with a delegated MCP token, and whether method/path is an approved MCP API operation at all. +func requiredMCPDelegatedScope(method string, path string) (string, bool) { + requestSegments := splitMCPPath(path) + for _, route := range mcpDelegatedRoutes { + if route.method != method { + continue + } + if matchMCPPathSegments(route.segments, requestSegments) { + return route.scope, true + } + } + return "", false +} + +func splitMCPPath(path string) []string { + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "/") +} + +func matchMCPPathSegments(pattern []string, actual []string) bool { + if len(pattern) != len(actual) { + return false + } + for i, segment := range pattern { + if segment == "*" { + continue + } + if segment != actual[i] { + return false + } + } + return true +} + +// containsAllScopes returns true if every scope in required is present in granted. +func containsAllScopes(granted []string, required []string) bool { + grantedSet := make(map[string]struct{}, len(granted)) + for _, scope := range granted { + grantedSet[scope] = struct{}{} + } + for _, scope := range required { + if _, ok := grantedSet[scope]; !ok { + return false + } + } + return true +} + +// MCPTokenVerifierConfig configures a MCPTokenVerifier. +type MCPTokenVerifierConfig struct { + // Issuer is the only issuer trusted for delegated MCP tokens. + Issuer string + + // Audience is the audience delegated MCP tokens must carry. + Audience string + + // JWKSURL is the JWKS endpoint used to verify delegated MCP token signatures. + JWKSURL string + + // HTTPClient is used to fetch the JWKS document. http.DefaultClient is used when nil. + HTTPClient *http.Client + + // CacheTTL is how long a fetched JWKS document is cached. Defaults to 15 minutes. + CacheTTL time.Duration + + // MinRefreshInterval bounds how often an unknown "kid" (or an expired cache) may trigger + // an outbound JWKS fetch. Defaults to one minute. + MinRefreshInterval time.Duration +} + +// MCPTokenVerifier validates delegated MCP API JWTs minted by the hosted MCP service. +type MCPTokenVerifier struct { + issuer string + audience string + jwks *mcpJWKSCache +} + +// NewMCPTokenVerifier creates a new MCPTokenVerifier. It returns an error if config is missing +// any of the required Issuer, Audience, or JWKSURL values. +func NewMCPTokenVerifier(config MCPTokenVerifierConfig) (*MCPTokenVerifier, error) { + if config.Issuer == "" || config.Audience == "" || config.JWKSURL == "" { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "MCP token verifier requires an issuer, audience, and JWKS URL") + } + + return &MCPTokenVerifier{ + issuer: config.Issuer, + audience: config.Audience, + jwks: newMCPJWKSCache(config.JWKSURL, config.HTTPClient, config.CacheTTL, config.MinRefreshInterval), + }, nil +} + +// VerifyRequest verifies that raw is a delegated MCP token that is valid, unexpired, issued by +// the configured issuer for the configured audience, and bound to the exact method and path of +// the current request with the scope that operation requires. +func (verifier *MCPTokenVerifier) VerifyRequest(ctx context.Context, raw string, method string, path string) (*MCPClaims, error) { + // Cheap prefilter: this middleware runs before Firebase bearer authentication, so almost + // every token it sees is a Firebase ID token or another non-MCP credential. Reading the + // unverified "iss" claim -- which is never trusted for authentication, only to decide + // that this token is definitely not ours -- keeps those tokens from reaching the JWKS + // cache and turning unknown "kid" headers into outbound fetches. + if !hasUnverifiedIssuer(raw, verifier.issuer) { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "bearer token is not issued by the MCP issuer [%s]", verifier.issuer) + } + + claims := new(MCPClaims) + token, err := jwt.ParseWithClaims( + raw, + claims, + verifier.keyfunc(ctx), + jwt.WithIssuer(verifier.issuer), + jwt.WithAudience(verifier.audience), + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + ) + if err != nil { + return nil, stacktrace.PropagateWithCodef(err, ErrCodeInvalidToken, "invalid MCP delegated token") + } + + if !token.Valid || claims.Subject == "" { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "invalid MCP delegated token") + } + + requiredScope, ok := requiredMCPDelegatedScope(method, path) + if !ok || claims.Method != method || claims.Path != path { + return nil, stacktrace.NewErrorWithCodef(ErrCodeOperationDenied, "MCP delegated token is not valid for this API operation") + } + + if !containsAllScopes(claims.Scopes, []string{requiredScope}) { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInsufficientScope, "MCP delegated token has insufficient scope") + } + + return claims, nil +} + +// mcpUnverifiedParser decodes a JWT without verifying its signature. It is only ever used by +// hasUnverifiedIssuer to discard tokens that cannot be ours, never to authenticate a request. +var mcpUnverifiedParser = jwt.NewParser() + +// hasUnverifiedIssuer reports whether raw is a well-formed JWT whose *unverified* "iss" claim +// equals issuer. A false result means the token is definitely not a delegated MCP token, so it +// can be rejected before any network I/O. A true result carries no authentication weight at +// all: the issuer is still verified against the token signature by VerifyRequest. Neither the +// token nor any of its claims is ever logged or returned. +func hasUnverifiedIssuer(raw string, issuer string) bool { + claims := jwt.MapClaims{} + if _, _, err := mcpUnverifiedParser.ParseUnverified(raw, claims); err != nil { + return false + } + + tokenIssuer, err := claims.GetIssuer() + if err != nil { + return false + } + + return tokenIssuer == issuer +} + +// keyfunc returns a jwt.Keyfunc that resolves the RSA public key matching the token's "kid" +// header from the cached JWKS document. +func (verifier *MCPTokenVerifier) keyfunc(ctx context.Context) jwt.Keyfunc { + return func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "unexpected MCP delegated token signing method [%v]", token.Header["alg"]) + } + + kid, ok := token.Header["kid"].(string) + if !ok || kid == "" { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "MCP delegated token has no [kid] header") + } + + return verifier.jwks.key(ctx, kid) + } +} diff --git a/api/pkg/auth/mcp_token_verifier_test.go b/api/pkg/auth/mcp_token_verifier_test.go new file mode 100644 index 00000000..7e71783e --- /dev/null +++ b/api/pkg/auth/mcp_token_verifier_test.go @@ -0,0 +1,312 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/stacktrace" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testRSAKey(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return privateKey, &privateKey.PublicKey +} + +func testJWKSHandler(_ *testing.T, kid string, publicKey *rsa.PublicKey) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + set := mcpJWKSet{ + Keys: []mcpJWK{ + { + Kty: "RSA", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()), + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(set) + } +} + +func signDelegatedToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, claims MCPClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + + raw, err := token.SignedString(privateKey) + require.NoError(t, err) + + return raw +} + +func testMCPClaims(subject string, scopes []string, method string, path string) MCPClaims { + now := time.Now() + return MCPClaims{ + Scopes: scopes, + Method: method, + Path: path, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://mcp.httpsms.com", + Subject: subject, + Audience: jwt.ClaimStrings{"https://api.httpsms.com"}, + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } +} + +func newTestVerifier(t *testing.T, jwksURL string, jwksClient *http.Client) *MCPTokenVerifier { + t.Helper() + + verifier, err := NewMCPTokenVerifier(MCPTokenVerifierConfig{ + Issuer: "https://mcp.httpsms.com", + Audience: "https://api.httpsms.com", + JWKSURL: jwksURL, + HTTPClient: jwksClient, + // Refresh throttling is exercised on its own in mcp_jwks_test.go; the tests using + // this helper assert unrelated verification behavior, so every unknown "kid" here + // is allowed to refresh immediately. + MinRefreshInterval: time.Nanosecond, + }) + require.NoError(t, err) + + return verifier +} + +func TestNewMCPTokenVerifier_RequiresIssuerAudienceAndJWKSURL(t *testing.T) { + tests := []struct { + name string + config MCPTokenVerifierConfig + }{ + {name: "missing issuer", config: MCPTokenVerifierConfig{Audience: "aud", JWKSURL: "https://example.com"}}, + {name: "missing audience", config: MCPTokenVerifierConfig{Issuer: "iss", JWKSURL: "https://example.com"}}, + {name: "missing jwks url", config: MCPTokenVerifierConfig{Issuer: "iss", Audience: "aud"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewMCPTokenVerifier(tt.config) + require.Error(t, err) + assert.Equal(t, ErrCodeInvalidToken, stacktrace.GetCode(err)) + }) + } +} + +func TestMCPTokenVerifierVerifyRequest(t *testing.T) { + privateKey, publicKey := testRSAKey(t) + jwks := httptest.NewServer(testJWKSHandler(t, "test-key", publicKey)) + defer jwks.Close() + + verifier := newTestVerifier(t, jwks.URL, jwks.Client()) + + raw := signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", + []string{"messages:read"}, + http.MethodGet, + "/v1/messages", + )) + + claims, err := verifier.VerifyRequest(context.Background(), raw, http.MethodGet, "/v1/messages") + + require.NoError(t, err) + assert.Equal(t, "firebase-user-id", claims.Subject) + assert.Equal(t, []string{"messages:read"}, claims.Scopes) +} + +func TestMCPTokenVerifierVerifyRequest_RotateAPIKeyRouteMatchesUserIDWildcard(t *testing.T) { + privateKey, publicKey := testRSAKey(t) + jwks := httptest.NewServer(testJWKSHandler(t, "test-key", publicKey)) + defer jwks.Close() + + verifier := newTestVerifier(t, jwks.URL, jwks.Client()) + + raw := signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", + []string{"user-api-key:rotate"}, + http.MethodDelete, + "/v1/users/firebase-user-id/api-keys", + )) + + claims, err := verifier.VerifyRequest(context.Background(), raw, http.MethodDelete, "/v1/users/firebase-user-id/api-keys") + + require.NoError(t, err) + assert.Equal(t, "firebase-user-id", claims.Subject) +} + +func TestMCPTokenVerifierVerifyRequest_Failures(t *testing.T) { + privateKey, publicKey := testRSAKey(t) + otherPrivateKey, _ := testRSAKey(t) + jwks := httptest.NewServer(testJWKSHandler(t, "test-key", publicKey)) + defer jwks.Close() + + verifier := newTestVerifier(t, jwks.URL, jwks.Client()) + + tests := []struct { + name string + raw string + method string + path string + expectedCode stacktrace.ErrorCode + }{ + { + name: "wrong issuer", + raw: func() string { + claims := testMCPClaims("firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages") + claims.Issuer = "https://not-mcp.httpsms.com" + return signDelegatedToken(t, privateKey, "test-key", claims) + }(), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInvalidToken, + }, + { + name: "wrong audience", + raw: func() string { + claims := testMCPClaims("firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages") + claims.Audience = jwt.ClaimStrings{"https://not-api.httpsms.com"} + return signDelegatedToken(t, privateKey, "test-key", claims) + }(), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInvalidToken, + }, + { + name: "expired token", + raw: func() string { + claims := testMCPClaims("firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages") + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute)) + return signDelegatedToken(t, privateKey, "test-key", claims) + }(), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInvalidToken, + }, + { + name: "unknown kid", + raw: signDelegatedToken(t, privateKey, "unknown-key", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInvalidToken, + }, + { + name: "signed by wrong key", + raw: signDelegatedToken(t, otherPrivateKey, "test-key", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInvalidToken, + }, + { + name: "missing subject", + raw: func() string { + claims := testMCPClaims("", []string{"messages:read"}, http.MethodGet, "/v1/messages") + return signDelegatedToken(t, privateKey, "test-key", claims) + }(), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInvalidToken, + }, + { + name: "missing required scope", + raw: signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", []string{"phones:read"}, http.MethodGet, "/v1/messages", + )), + method: http.MethodGet, + path: "/v1/messages", + expectedCode: ErrCodeInsufficientScope, + }, + { + name: "path does not match token binding", + raw: signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )), + method: http.MethodGet, + path: "/v1/message-threads", + expectedCode: ErrCodeOperationDenied, + }, + { + name: "method does not match token binding", + raw: signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", []string{"messages:send"}, http.MethodPost, "/v1/messages/send", + )), + method: http.MethodDelete, + path: "/v1/messages/send", + expectedCode: ErrCodeOperationDenied, + }, + { + name: "route is not an approved MCP operation", + raw: signDelegatedToken(t, privateKey, "test-key", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodDelete, "/v1/users/firebase-user-id", + )), + method: http.MethodDelete, + path: "/v1/users/firebase-user-id", + expectedCode: ErrCodeOperationDenied, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims, err := verifier.VerifyRequest(context.Background(), tt.raw, tt.method, tt.path) + + require.Error(t, err) + require.Nil(t, claims) + assert.Equal(t, tt.expectedCode, stacktrace.GetCode(err)) + }) + } +} + +func TestMCPTokenVerifierVerifyRequest_RefreshesJWKSOnceWhenKeyIsRotated(t *testing.T) { + firstPrivateKey, firstPublicKey := testRSAKey(t) + secondPrivateKey, secondPublicKey := testRSAKey(t) + + requestCount := 0 + jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if requestCount == 1 { + testJWKSHandler(t, "key-1", firstPublicKey)(w, r) + return + } + testJWKSHandler(t, "key-2", secondPublicKey)(w, r) + })) + defer jwks.Close() + + verifier := newTestVerifier(t, jwks.URL, jwks.Client()) + + firstToken := signDelegatedToken(t, firstPrivateKey, "key-1", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )) + _, err := verifier.VerifyRequest(context.Background(), firstToken, http.MethodGet, "/v1/messages") + require.NoError(t, err) + assert.Equal(t, 1, requestCount) + + // The verifier's cache only has "key-1"; a token signed with the newly rotated "key-2" + // forces exactly one additional JWKS refresh before it can be verified. + secondToken := signDelegatedToken(t, secondPrivateKey, "key-2", testMCPClaims( + "firebase-user-id", []string{"messages:read"}, http.MethodGet, "/v1/messages", + )) + claims, err := verifier.VerifyRequest(context.Background(), secondToken, http.MethodGet, "/v1/messages") + require.NoError(t, err) + assert.Equal(t, "firebase-user-id", claims.Subject) + assert.Equal(t, 2, requestCount) +} diff --git a/api/pkg/di/config.go b/api/pkg/di/config.go index 0c6b8680..2aefb7bb 100644 --- a/api/pkg/di/config.go +++ b/api/pkg/di/config.go @@ -5,6 +5,8 @@ import ( "os" "strings" + "github.com/NdoleStudio/httpsms/pkg/auth" + "github.com/NdoleStudio/stacktrace" "github.com/joho/godotenv" ) @@ -36,3 +38,34 @@ func splitCommaEnv(key, defaultValue string) []string { } return result } + +// mcpTokenVerifierConfigFromEnv resolves auth.MCPTokenVerifierConfig from MCP_AUTH_ISSUER, +// MCP_AUTH_AUDIENCE, and MCP_AUTH_JWKS_URL using getenv (os.Getenv in production). +// +// enabled is false, with a nil error, when all three variables are empty: delegated MCP +// authentication is optional and stays disabled until it is fully configured. +// +// An error is returned when only some of the three variables are set, since a partially +// configured delegated MCP issuer must never silently run with a missing issuer, audience, or +// JWKS URL. +func mcpTokenVerifierConfigFromEnv(getenv func(string) string) (config auth.MCPTokenVerifierConfig, enabled bool, err error) { + issuer := getenv("MCP_AUTH_ISSUER") + audience := getenv("MCP_AUTH_AUDIENCE") + jwksURL := getenv("MCP_AUTH_JWKS_URL") + + if issuer == "" && audience == "" && jwksURL == "" { + return auth.MCPTokenVerifierConfig{}, false, nil + } + + if issuer == "" || audience == "" || jwksURL == "" { + return auth.MCPTokenVerifierConfig{}, false, stacktrace.NewError( + "MCP_AUTH_ISSUER, MCP_AUTH_AUDIENCE, and MCP_AUTH_JWKS_URL must all be set together to enable delegated MCP authentication", + ) + } + + return auth.MCPTokenVerifierConfig{ + Issuer: issuer, + Audience: audience, + JWKSURL: jwksURL, + }, true, nil +} diff --git a/api/pkg/di/config_test.go b/api/pkg/di/config_test.go new file mode 100644 index 00000000..ebb86fb0 --- /dev/null +++ b/api/pkg/di/config_test.go @@ -0,0 +1,73 @@ +package di + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func envMap(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} + +func TestMCPTokenVerifierConfigFromEnv_DisabledWhenAllEmpty(t *testing.T) { + _, enabled, err := mcpTokenVerifierConfigFromEnv(envMap(map[string]string{})) + + require.NoError(t, err) + assert.False(t, enabled) +} + +func TestMCPTokenVerifierConfigFromEnv_EnabledWhenAllSet(t *testing.T) { + config, enabled, err := mcpTokenVerifierConfigFromEnv(envMap(map[string]string{ + "MCP_AUTH_ISSUER": "https://mcp.httpsms.com", + "MCP_AUTH_AUDIENCE": "https://api.httpsms.com", + "MCP_AUTH_JWKS_URL": "https://mcp.httpsms.com/.well-known/jwks.json", + })) + + require.NoError(t, err) + require.True(t, enabled) + assert.Equal(t, "https://mcp.httpsms.com", config.Issuer) + assert.Equal(t, "https://api.httpsms.com", config.Audience) + assert.Equal(t, "https://mcp.httpsms.com/.well-known/jwks.json", config.JWKSURL) +} + +func TestMCPTokenVerifierConfigFromEnv_RejectsPartialConfiguration(t *testing.T) { + tests := []struct { + name string + env map[string]string + }{ + { + name: "missing issuer", + env: map[string]string{ + "MCP_AUTH_AUDIENCE": "https://api.httpsms.com", + "MCP_AUTH_JWKS_URL": "https://mcp.httpsms.com/.well-known/jwks.json", + }, + }, + { + name: "missing audience", + env: map[string]string{ + "MCP_AUTH_ISSUER": "https://mcp.httpsms.com", + "MCP_AUTH_JWKS_URL": "https://mcp.httpsms.com/.well-known/jwks.json", + }, + }, + { + name: "missing jwks url", + env: map[string]string{ + "MCP_AUTH_ISSUER": "https://mcp.httpsms.com", + "MCP_AUTH_AUDIENCE": "https://api.httpsms.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, enabled, err := mcpTokenVerifierConfigFromEnv(envMap(tt.env)) + + require.Error(t, err) + assert.False(t, enabled) + }) + } +} diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 8d9e778d..d0159cfe 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -23,6 +23,7 @@ import ( otelfiber "github.com/gofiber/contrib/v3/otel" "gorm.io/plugin/opentelemetry/tracing" + mcpauth "github.com/NdoleStudio/httpsms/pkg/auth" "github.com/NdoleStudio/httpsms/pkg/discord" "cloud.google.com/go/storage" @@ -206,6 +207,9 @@ func (container *Container) App() (app *fiber.App) { ), ) app.Use(middlewares.HTTPRequestLogger(container.Tracer(), container.Logger())) + if verifier := container.MCPTokenVerifier(); verifier != nil { + app.Use(middlewares.MCPDelegationAuth(container.Logger(), container.Tracer(), verifier, container.UserRepository())) + } app.Use(middlewares.BearerAuth(container.Logger(), container.Tracer(), container.FirebaseAuthClient())) app.Use(middlewares.APIKeyAuth(container.Logger(), container.Tracer(), container.UserRepository())) @@ -486,6 +490,31 @@ func (container *Container) FirebaseAuthClient() (client *auth.Client) { return authClient } +// MCPTokenVerifier creates a new instance of *auth.MCPTokenVerifier used to validate delegated +// MCP API JWTs, configured from MCP_AUTH_ISSUER, MCP_AUTH_AUDIENCE, and MCP_AUTH_JWKS_URL. +// +// It returns nil when all three environment variables are empty, which disables delegated MCP +// authentication entirely. A partially configured issuer, audience, or JWKS URL is treated as a +// misconfiguration and stops container construction. +func (container *Container) MCPTokenVerifier() *mcpauth.MCPTokenVerifier { + config, enabled, err := mcpTokenVerifierConfigFromEnv(os.Getenv) + if err != nil { + container.logger.Fatal(stacktrace.Propagate(err, "invalid MCP delegated authentication configuration")) + return nil + } + if !enabled { + return nil + } + + verifier, err := mcpauth.NewMCPTokenVerifier(config) + if err != nil { + container.logger.Fatal(stacktrace.Propagate(err, "cannot create MCP token verifier")) + return nil + } + + return verifier +} + // CloudTasksClient creates a new instance of cloudtasks.Client func (container *Container) CloudTasksClient() (client *cloudtasks.Client) { container.logger.Debug(fmt.Sprintf("creating %T", client)) diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index 8c7a1420..6dfb955d 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -54,6 +54,7 @@ func (h *MessageHandler) RegisterRoutes(router fiber.Router, middlewares ...fibe h.register(router, fiber.MethodPost, "/v1/messages/bulk-send", middlewares, h.BulkSend) h.register(router, fiber.MethodGet, "/v1/messages", middlewares, h.Index) h.register(router, fiber.MethodGet, "/v1/messages/search", middlewares, h.Search) + h.register(router, fiber.MethodGet, "/v1/messages/incoming", middlewares, h.Incoming) h.register(router, fiber.MethodGet, "/v1/messages/:messageID", middlewares, h.Get) h.register(router, fiber.MethodDelete, "/v1/messages/:messageID", middlewares, h.Delete) } @@ -548,3 +549,47 @@ func (h *MessageHandler) Search(c fiber.Ctx) error { return h.responseOK(c, fmt.Sprintf("found %d %s", len(messages), h.pluralize("message", len(messages))), messages) } + +// Incoming returns a filtered list of mobile-originated messages of a user +// @Summary Search incoming messages of a user +// @Description This returns the list of mobile-originated messages received by the user's phones. This route is scoped to messages:read and never returns other message types +// @Security ApiKeyAuth +// @Tags Messages +// @Accept json +// @Produce json +// @Param owners query string true "the owner's phone numbers" default(+18005550199,+18005550100) +// @Param statuses query string false "filter by message status" +// @Param skip query int false "number of messages to skip" minimum(0) +// @Param query query string false "filter messages containing query" +// @Param sort_by query string false "field used to sort the messages" +// @Param sort_descending query bool false "sort messages in descending order" +// @Param limit query int false "number of messages to return" minimum(1) maximum(200) +// @Success 200 {object} responses.MessagesResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /messages/incoming [get] +func (h *MessageHandler) Incoming(c fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + var request requests.MessageIncoming + if err := c.Bind().Query(&request); err != nil { + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params in [%s] into [%T]", c.OriginalURL(), request)) + return h.responseBadRequest(c, err) + } + + if errors := h.validator.ValidateMessageIncoming(ctx, request.Sanitize()); len(errors) != 0 { + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while fetching incoming messages [%+#v]", spew.Sdump(errors), request)) + return h.responseUnprocessableEntity(c, errors, "validation errors while fetching incoming messages") + } + + messages, err := h.service.SearchMessages(ctx, request.ToSearchParams(h.userIDFomContext(c))) + if err != nil { + ctxLogger.Error(stacktrace.Propagatef(err, "cannot fetch incoming messages with params [%+#v]", request)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, fmt.Sprintf("found %d %s", len(messages), h.pluralize("message", len(messages))), messages) +} diff --git a/api/pkg/handlers/message_handler_incoming_test.go b/api/pkg/handlers/message_handler_incoming_test.go new file mode 100644 index 00000000..11bb8625 --- /dev/null +++ b/api/pkg/handlers/message_handler_incoming_test.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/middlewares" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type messageIncomingRepositoryStub struct { + searchUserID entities.UserID + searchOwners []string + searchTypes []entities.MessageType + searchStatus []entities.MessageStatus + searchParams repositories.IndexParams +} + +func (stub *messageIncomingRepositoryStub) Store(context.Context, *entities.Message) error { + return nil +} + +func (stub *messageIncomingRepositoryStub) Update(context.Context, *entities.Message) error { + return nil +} + +func (stub *messageIncomingRepositoryStub) Load(context.Context, entities.UserID, uuid.UUID) (*entities.Message, error) { + return nil, nil +} + +func (stub *messageIncomingRepositoryStub) Index(context.Context, entities.UserID, string, string, repositories.IndexParams) (*[]entities.Message, error) { + return nil, nil +} + +func (stub *messageIncomingRepositoryStub) LastMessage(context.Context, entities.UserID, string, string) (*entities.Message, error) { + return nil, nil +} + +func (stub *messageIncomingRepositoryStub) Search(_ context.Context, userID entities.UserID, owners []string, types []entities.MessageType, statuses []entities.MessageStatus, params repositories.IndexParams) ([]*entities.Message, error) { + stub.searchUserID = userID + stub.searchOwners = owners + stub.searchTypes = types + stub.searchStatus = statuses + stub.searchParams = params + return []*entities.Message{}, nil +} + +func (stub *messageIncomingRepositoryStub) GetBulkMessages(context.Context, entities.UserID, int) ([]*entities.BulkMessage, error) { + return nil, nil +} + +func (stub *messageIncomingRepositoryStub) GetOutstanding(context.Context, entities.UserID, uuid.UUID, []string) (*entities.Message, error) { + return nil, nil +} + +func (stub *messageIncomingRepositoryStub) Delete(context.Context, entities.UserID, uuid.UUID) error { + return nil +} + +func (stub *messageIncomingRepositoryStub) DeleteByOwnerAndContact(context.Context, entities.UserID, string, string) error { + return nil +} + +func (stub *messageIncomingRepositoryStub) DeleteAllForUser(context.Context, entities.UserID) error { + return nil +} + +func TestMessageHandlerIncoming_ForcesMobileOriginatedType(t *testing.T) { + logger := &messageIncomingNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + repository := &messageIncomingRepositoryStub{} + service := services.NewMessageService(logger, tracer, repository, nil, nil, nil, "http://localhost") + validator := validators.NewMessageHandlerValidator(logger, tracer, nil, nil) + handler := NewMessageHandler(logger, tracer, validator, nil, service) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages/incoming?owners=%2B18005550199&limit=25&skip=0", nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, []entities.MessageType{entities.MessageTypeMobileOriginated}, repository.searchTypes) + require.Equal(t, entities.UserID("user-id"), repository.searchUserID) + require.Equal(t, []string{"+18005550199"}, repository.searchOwners) +} + +func TestMessageHandlerIncoming_ReturnsUnprocessableEntityForInvalidOwner(t *testing.T) { + logger := &messageIncomingNoopLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + repository := &messageIncomingRepositoryStub{} + service := services.NewMessageService(logger, tracer, repository, nil, nil, nil, "http://localhost") + validator := validators.NewMessageHandlerValidator(logger, tracer, nil, nil) + handler := NewMessageHandler(logger, tracer, validator, nil, service) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(middlewares.ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("user-id"), Email: "user@example.com"}) + return c.Next() + }) + handler.RegisterRoutes(app) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages/incoming?owners=not-a-phone-number&limit=25&skip=0", nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) +} + +type messageIncomingNoopLogger struct{} + +var _ telemetry.Logger = (*messageIncomingNoopLogger)(nil) + +func (logger *messageIncomingNoopLogger) Error(_ error) {} +func (logger *messageIncomingNoopLogger) WithService(_ string) telemetry.Logger { return logger } + +func (logger *messageIncomingNoopLogger) WithString(_, _ string) telemetry.Logger { return logger } + +func (logger *messageIncomingNoopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { + return logger +} +func (logger *messageIncomingNoopLogger) Trace(_ string) {} +func (logger *messageIncomingNoopLogger) Info(_ string) {} +func (logger *messageIncomingNoopLogger) Warn(_ error) {} +func (logger *messageIncomingNoopLogger) Debug(_ string) {} +func (logger *messageIncomingNoopLogger) Fatal(_ error) {} +func (logger *messageIncomingNoopLogger) Printf(_ string, _ ...interface{}) {} diff --git a/api/pkg/middlewares/bearer_auth_middleware.go b/api/pkg/middlewares/bearer_auth_middleware.go index 3391e875..6f939570 100644 --- a/api/pkg/middlewares/bearer_auth_middleware.go +++ b/api/pkg/middlewares/bearer_auth_middleware.go @@ -19,6 +19,13 @@ func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *au _, span := tracer.StartFromFiberCtx(c, "middlewares.BearerAuth") defer span.End() + // A delegated MCP token has already authenticated this request; skip Firebase + // verification so a valid but non-Firebase MCP JWT is not rejected here. + if authUser, ok := c.Locals(ContextKeyAuthUserID).(entities.AuthContext); ok && !authUser.IsNoop() { + span.AddEvent("the request is already authenticated") + return c.Next() + } + authToken := c.Get(authHeaderBearer) if !strings.HasPrefix(authToken, bearerScheme) { span.AddEvent(fmt.Sprintf("The request header has no [%s] token", bearerScheme)) @@ -33,7 +40,7 @@ func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *au token, err := authClient.VerifyIDToken(context.Background(), authToken) if err != nil { - ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "invalid firebase id token [%s]", authToken))) + ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "invalid firebase id token"))) return c.Next() } diff --git a/api/pkg/middlewares/bearer_auth_middleware_test.go b/api/pkg/middlewares/bearer_auth_middleware_test.go new file mode 100644 index 00000000..03cf6c2e --- /dev/null +++ b/api/pkg/middlewares/bearer_auth_middleware_test.go @@ -0,0 +1,65 @@ +package middlewares + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBearerAuth_SkipsFirebaseVerificationWhenAlreadyAuthenticated proves BearerAuth short +// circuits as soon as a prior middleware (MCPDelegationAuth) has already populated +// ContextKeyAuthUserID. authClient is nil: if BearerAuth attempted Firebase verification here it +// would panic on the nil pointer, so reaching the downstream handler proves the short-circuit +// fired instead. +func TestBearerAuth_SkipsFirebaseVerificationWhenAlreadyAuthenticated(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(ContextKeyAuthUserID, entities.AuthContext{ID: entities.UserID("mcp-user"), Email: "mcp-user@example.com"}) + return c.Next() + }) + app.Use(BearerAuth(logger, tracer, nil)) + app.Get("/v1/messages", func(c fiber.Ctx) error { + authUser, _ := c.Locals(ContextKeyAuthUserID).(entities.AuthContext) + return c.JSON(fiber.Map{"id": authUser.ID}) + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + req.Header.Set("Authorization", "Bearer some-mcp-delegated-jwt") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestBearerAuth_ContinuesWhenNoBearerTokenIsPresent proves BearerAuth still passes requests +// through to c.Next() unchanged when there is no authentication context yet and no Authorization +// header, preserving existing behavior for normal callers. +func TestBearerAuth_ContinuesWhenNoBearerTokenIsPresent(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + + app := fiber.New() + app.Use(BearerAuth(logger, tracer, nil)) + app.Get("/v1/messages", func(c fiber.Ctx) error { + _, ok := c.Locals(ContextKeyAuthUserID).(entities.AuthContext) + return c.JSON(fiber.Map{"authenticated": ok}) + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/api/pkg/middlewares/mcp_delegation_auth_middleware.go b/api/pkg/middlewares/mcp_delegation_auth_middleware.go new file mode 100644 index 00000000..12f1508b --- /dev/null +++ b/api/pkg/middlewares/mcp_delegation_auth_middleware.go @@ -0,0 +1,75 @@ +package middlewares + +import ( + "context" + "strings" + + "github.com/NdoleStudio/httpsms/pkg/auth" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/gofiber/fiber/v3" +) + +// MCPTokenVerifier verifies a delegated MCP API JWT is unexpired, issued by the trusted MCP +// issuer for the expected audience, and bound to the exact method, path, and scope of the +// current request. It is satisfied by *auth.MCPTokenVerifier. +type MCPTokenVerifier interface { + VerifyRequest(ctx context.Context, raw string, method string, path string) (*auth.MCPClaims, error) +} + +// MCPDelegationAuth authenticates a user from a delegated MCP API JWT minted by the hosted MCP +// service. It must be registered before BearerAuth: a cryptographically valid MCP token that is +// not bound to the requested operation is rejected with 403 directly, instead of falling through +// to Firebase ID token verification. Malformed or non-MCP bearer tokens continue to the next +// authentication middleware unchanged. +func MCPDelegationAuth(logger telemetry.Logger, tracer telemetry.Tracer, verifier MCPTokenVerifier, users repositories.UserRepository) fiber.Handler { + logger = logger.WithService("middlewares.MCPDelegationAuth") + + return func(c fiber.Ctx) error { + ctx, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger) + defer span.End() + + raw := bearerToken(c.Get(authHeaderBearer)) + if raw == "" { + span.AddEvent("the request header has no MCP delegated bearer token") + return c.Next() + } + + claims, err := verifier.VerifyRequest(ctx, raw, c.Method(), c.Path()) + if err != nil { + code := stacktrace.GetCode(err) + if code == auth.ErrCodeInsufficientScope || code == auth.ErrCodeOperationDenied { + ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "MCP delegated token cannot access this API operation"))) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "status": "error", + "message": "MCP token cannot access this API operation", + }) + } + span.AddEvent("MCP delegated token is not valid; continuing to the next authentication middleware") + return c.Next() + } + + user, err := users.Load(ctx, entities.UserID(claims.Subject)) + if err != nil { + ctxLogger.Warn(tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load user for MCP delegated token subject"))) + return c.Next() + } + + c.Locals(ContextKeyAuthUserID, entities.AuthContext{ID: user.ID, Email: user.Email}) + return c.Next() + } +} + +// bearerToken extracts the raw token from an Authorization header value using the Bearer scheme. +// It returns an empty string when the header is missing or does not use the Bearer scheme. +func bearerToken(header string) string { + if !strings.HasPrefix(header, bearerScheme) { + return "" + } + if len(header) <= len(bearerScheme)+1 { + return "" + } + return header[len(bearerScheme)+1:] +} diff --git a/api/pkg/middlewares/mcp_delegation_auth_middleware_test.go b/api/pkg/middlewares/mcp_delegation_auth_middleware_test.go new file mode 100644 index 00000000..79d66955 --- /dev/null +++ b/api/pkg/middlewares/mcp_delegation_auth_middleware_test.go @@ -0,0 +1,226 @@ +package middlewares + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/auth" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/gofiber/fiber/v3" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +// mcpDelegationAuthTestLogger is a minimal telemetry.Logger test double that records every +// message passed to Warn/Error so tests can assert no raw bearer token is ever logged. +type mcpDelegationAuthTestLogger struct { + messages []string +} + +func (logger *mcpDelegationAuthTestLogger) Error(err error) { + logger.messages = append(logger.messages, err.Error()) +} +func (logger *mcpDelegationAuthTestLogger) WithService(string) telemetry.Logger { return logger } +func (logger *mcpDelegationAuthTestLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *mcpDelegationAuthTestLogger) WithSpan(trace.SpanContext) telemetry.Logger { + return logger +} +func (logger *mcpDelegationAuthTestLogger) Trace(string) {} +func (logger *mcpDelegationAuthTestLogger) Info(string) {} +func (logger *mcpDelegationAuthTestLogger) Warn(err error) { + logger.messages = append(logger.messages, err.Error()) +} +func (logger *mcpDelegationAuthTestLogger) Debug(string) {} +func (logger *mcpDelegationAuthTestLogger) Fatal(error) {} +func (logger *mcpDelegationAuthTestLogger) Printf(string, ...interface{}) {} + +type mcpDelegationAuthVerifierStub struct { + claims *auth.MCPClaims + err error +} + +func (stub *mcpDelegationAuthVerifierStub) VerifyRequest(context.Context, string, string, string) (*auth.MCPClaims, error) { + return stub.claims, stub.err +} + +// mcpDelegationAuthUserRepositoryStub implements repositories.UserRepository with only Load +// wired up, which is all MCPDelegationAuth depends on. +type mcpDelegationAuthUserRepositoryStub struct { + user *entities.User + err error +} + +func (stub *mcpDelegationAuthUserRepositoryStub) Store(context.Context, *entities.User) error { + return nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) Update(context.Context, *entities.User) error { + return nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) LoadAuthContext(context.Context, string) (entities.AuthContext, error) { + return entities.AuthContext{}, nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) Load(context.Context, entities.UserID) (*entities.User, error) { + if stub.err != nil { + return nil, stub.err + } + return stub.user, nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) RotateAPIKey(context.Context, entities.UserID) (*entities.User, error) { + return nil, nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) LoadOrStore(context.Context, entities.AuthContext) (*entities.User, bool, error) { + return nil, false, nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) LoadBySubscriptionID(context.Context, string) (*entities.User, error) { + return nil, nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) LoadByEmail(context.Context, string) (*entities.User, error) { + return nil, nil +} + +func (stub *mcpDelegationAuthUserRepositoryStub) Delete(context.Context, *entities.User) error { + return nil +} + +func newMCPDelegationAuthTestApp(t *testing.T, logger *mcpDelegationAuthTestLogger, verifier MCPTokenVerifier, users *mcpDelegationAuthUserRepositoryStub) *fiber.App { + t.Helper() + + tracer := telemetry.NewOtelLogger("test", logger) + + app := fiber.New() + app.Use(MCPDelegationAuth(logger, tracer, verifier, users)) + app.Get("/v1/messages", func(c fiber.Ctx) error { + authUser, ok := c.Locals(ContextKeyAuthUserID).(entities.AuthContext) + if !ok || authUser.IsNoop() { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error"}) + } + return c.JSON(fiber.Map{"id": authUser.ID, "email": authUser.Email}) + }) + + return app +} + +func TestMCPDelegationAuthSetsAuthContext(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + verifier := &mcpDelegationAuthVerifierStub{ + claims: &auth.MCPClaims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-id"}}, + } + users := &mcpDelegationAuthUserRepositoryStub{ + user: &entities.User{ID: "user-id", Email: "user@example.com"}, + } + app := newMCPDelegationAuthTestApp(t, logger, verifier, users) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + req.Header.Set("Authorization", "Bearer super-secret-delegated-token") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestMCPDelegationAuth_NonBearerRequestPassesThrough(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + verifier := &mcpDelegationAuthVerifierStub{err: stacktrace.NewErrorWithCodef(auth.ErrCodeInvalidToken, "should never be called")} + users := &mcpDelegationAuthUserRepositoryStub{} + app := newMCPDelegationAuthTestApp(t, logger, verifier, users) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + // No Authorization header at all. + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestMCPDelegationAuth_InvalidDelegatedTokenPassesThroughForBearerAuth(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + verifier := &mcpDelegationAuthVerifierStub{err: stacktrace.NewErrorWithCodef(auth.ErrCodeInvalidToken, "invalid MCP delegated token")} + users := &mcpDelegationAuthUserRepositoryStub{} + app := newMCPDelegationAuthTestApp(t, logger, verifier, users) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + req.Header.Set("Authorization", "Bearer not-an-mcp-token-super-secret") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + for _, message := range logger.messages { + assert.NotContains(t, message, "not-an-mcp-token-super-secret") + } +} + +func TestMCPDelegationAuth_InsufficientScopeReturnsForbidden(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + verifier := &mcpDelegationAuthVerifierStub{err: stacktrace.NewErrorWithCodef(auth.ErrCodeInsufficientScope, "MCP delegated token has insufficient scope")} + users := &mcpDelegationAuthUserRepositoryStub{} + app := newMCPDelegationAuthTestApp(t, logger, verifier, users) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + req.Header.Set("Authorization", "Bearer scoped-secret-token") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + + for _, message := range logger.messages { + assert.NotContains(t, message, "scoped-secret-token") + } +} + +func TestMCPDelegationAuth_OperationDeniedReturnsForbidden(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + verifier := &mcpDelegationAuthVerifierStub{err: stacktrace.NewErrorWithCodef(auth.ErrCodeOperationDenied, "MCP delegated token is not valid for this API operation")} + users := &mcpDelegationAuthUserRepositoryStub{} + app := newMCPDelegationAuthTestApp(t, logger, verifier, users) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + req.Header.Set("Authorization", "Bearer denied-secret-token") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestMCPDelegationAuth_UnknownUserPassesThrough(t *testing.T) { + logger := &mcpDelegationAuthTestLogger{} + verifier := &mcpDelegationAuthVerifierStub{ + claims: &auth.MCPClaims{RegisteredClaims: jwt.RegisteredClaims{Subject: "missing-user-id"}}, + } + users := &mcpDelegationAuthUserRepositoryStub{err: stacktrace.NewErrorWithCodef(auth.ErrCodeInvalidToken, "user not found")} + app := newMCPDelegationAuthTestApp(t, logger, verifier, users) + + req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil) + req.Header.Set("Authorization", "Bearer valid-but-unknown-user-secret") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: time.Second}) + + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + for _, message := range logger.messages { + assert.NotContains(t, message, "valid-but-unknown-user-secret") + } +} diff --git a/api/pkg/requests/message_incoming_request.go b/api/pkg/requests/message_incoming_request.go new file mode 100644 index 00000000..8ccfdbac --- /dev/null +++ b/api/pkg/requests/message_incoming_request.go @@ -0,0 +1,67 @@ +package requests + +import ( + "strings" + + "github.com/NdoleStudio/httpsms/pkg/entities" + + "github.com/NdoleStudio/httpsms/pkg/repositories" + + "github.com/NdoleStudio/httpsms/pkg/services" +) + +// MessageIncoming is the payload for fetching mobile-originated entities.Message +type MessageIncoming struct { + request + Skip string `json:"skip" query:"skip"` + Owners []string `json:"owners" query:"owners"` + Statuses []string `json:"statuses" query:"statuses"` + Query string `json:"query" query:"query"` + SortBy string `json:"sort_by" query:"sort_by"` + SortDescending bool `json:"sort_descending" query:"sort_descending"` + Limit string `json:"limit" query:"limit"` +} + +// Sanitize sets defaults to MessageIncoming +func (input *MessageIncoming) Sanitize() MessageIncoming { + if strings.TrimSpace(input.Limit) == "" { + input.Limit = "100" + } + + input.Query = strings.TrimSpace(input.Query) + + input.Skip = strings.TrimSpace(input.Skip) + if input.Skip == "" { + input.Skip = "0" + } + + input.SortBy = strings.TrimSpace(input.SortBy) + if input.SortBy == "" { + input.SortBy = "created_at" + input.SortDescending = true + } + + return *input +} + +// ToSearchParams converts request to services.MessageSearchParams, forcing mobile-originated messages +func (input MessageIncoming) ToSearchParams(userID entities.UserID) *services.MessageSearchParams { + statuses := make([]entities.MessageStatus, 0, len(input.Statuses)) + for _, status := range input.Statuses { + statuses = append(statuses, entities.MessageStatus(status)) + } + + return &services.MessageSearchParams{ + IndexParams: repositories.IndexParams{ + Skip: input.getInt(input.Skip), + Query: input.Query, + SortBy: input.SortBy, + SortDescending: input.SortDescending, + Limit: input.getInt(input.Limit), + }, + UserID: userID, + Owners: input.Owners, + Types: []entities.MessageType{entities.MessageTypeMobileOriginated}, + Statuses: statuses, + } +} diff --git a/api/pkg/requests/message_incoming_request_test.go b/api/pkg/requests/message_incoming_request_test.go new file mode 100644 index 00000000..ce6f0ae0 --- /dev/null +++ b/api/pkg/requests/message_incoming_request_test.go @@ -0,0 +1,47 @@ +package requests + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestMessageIncomingToSearchParamsForcesMobileOriginated(t *testing.T) { + request := MessageIncoming{ + Owners: []string{"+18005550199"}, + Statuses: []string{"received"}, + SortBy: "created_at", + SortDescending: true, + Limit: "25", + } + + params := request.Sanitize().ToSearchParams(entities.UserID("user-id")) + + assert.Equal(t, []entities.MessageType{entities.MessageTypeMobileOriginated}, params.Types) + assert.Equal(t, []entities.MessageStatus{entities.MessageStatusReceived}, params.Statuses) + assert.Equal(t, 25, params.Limit) +} + +func TestMessageIncomingSanitizeSetsDefaults(t *testing.T) { + request := MessageIncoming{} + + sanitized := request.Sanitize() + + assert.Equal(t, "0", sanitized.Skip) + assert.Equal(t, "100", sanitized.Limit) + assert.Equal(t, "created_at", sanitized.SortBy) + assert.True(t, sanitized.SortDescending) +} + +func TestMessageIncomingToSearchParamsSetsUserIDAndOwners(t *testing.T) { + request := MessageIncoming{ + Owners: []string{"+18005550199"}, + Limit: "25", + } + + params := request.Sanitize().ToSearchParams(entities.UserID("user-id")) + + assert.Equal(t, entities.UserID("user-id"), params.UserID) + assert.Equal(t, []string{"+18005550199"}, params.Owners) +} diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index f14575fa..e267e53f 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -346,6 +346,44 @@ func (validator MessageHandlerValidator) ValidateMessageSearch(ctx context.Conte return errors } +// ValidateMessageIncoming validates the requests.MessageIncoming request +func (validator MessageHandlerValidator) ValidateMessageIncoming(_ context.Context, request requests.MessageIncoming) url.Values { + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: govalidator.MapData{ + "owners": []string{ + multipleContactPhoneNumberRule, + }, + "statuses": []string{ + multipleInRule + ":" + entities.MessageStatusReceived, + }, + "sort_by": []string{ + "in:" + strings.Join([]string{ + "created_at", + "owner", + "contact", + "status", + }, ","), + }, + "limit": []string{ + "required", + "numeric", + "min:1", + "max:200", + }, + "skip": []string{ + "required", + "numeric", + "min:0", + }, + "query": []string{ + "max:50", + }, + }, + }) + return v.ValidateStruct() +} + // ValidateMessageEvent validates the requests.MessageEvent request func (validator MessageHandlerValidator) ValidateMessageEvent(_ context.Context, request requests.MessageEvent) url.Values { v := govalidator.New(govalidator.Options{ diff --git a/api/pkg/validators/message_handler_validator_test.go b/api/pkg/validators/message_handler_validator_test.go new file mode 100644 index 00000000..caef494a --- /dev/null +++ b/api/pkg/validators/message_handler_validator_test.go @@ -0,0 +1,49 @@ +package validators + +import ( + "context" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/stretchr/testify/assert" +) + +func TestValidateMessageIncomingDoesNotRequireTurnstileToken(t *testing.T) { + validator := &MessageHandlerValidator{} + request := requests.MessageIncoming{ + Owners: []string{"+18005550199"}, + Limit: "25", + Skip: "0", + } + + errors := validator.ValidateMessageIncoming(context.Background(), request.Sanitize()) + + assert.Empty(t, errors) +} + +func TestValidateMessageIncomingRejectsInvalidOwner(t *testing.T) { + validator := &MessageHandlerValidator{} + request := requests.MessageIncoming{ + Owners: []string{"not-a-phone-number"}, + Limit: "25", + Skip: "0", + } + + errors := validator.ValidateMessageIncoming(context.Background(), request.Sanitize()) + + assert.NotEmpty(t, errors.Get("owners")) +} + +func TestValidateMessageIncomingRejectsStatusOtherThanReceived(t *testing.T) { + validator := &MessageHandlerValidator{} + request := requests.MessageIncoming{ + Owners: []string{"+18005550199"}, + Statuses: []string{"pending"}, + Limit: "25", + Skip: "0", + } + + errors := validator.ValidateMessageIncoming(context.Background(), request.Sanitize()) + + assert.NotEmpty(t, errors.Get("statuses")) +} diff --git a/docs/superpowers/plans/2026-09-03-httpsms-mcp-server.md b/docs/superpowers/plans/2026-09-03-httpsms-mcp-server.md new file mode 100644 index 00000000..b0f7dc55 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-httpsms-mcp-server.md @@ -0,0 +1,1747 @@ +# httpSMS MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build and deploy a standards-compliant remote MCP server that authenticates existing httpSMS users with Firebase, calls the httpSMS API, and exposes the approved SMS, message-history, and API-key tools. + +**Architecture:** Add an independent `mcp/` Go module using `github.com/modelcontextprotocol/go-sdk` v1.7.0 and stateless Streamable HTTP. The service implements an OAuth 2.1 authorization facade backed by Firebase identity and Redis, issues audience-bound MCP JWTs, mints short-lived delegated API JWTs, and calls only `api.httpsms.com`; the API gains delegated-JWT authentication and a CAPTCHA-free, narrowly scoped incoming-message endpoint. + +**Tech Stack:** Go 1.25, official MCP Go SDK v1.7.0, Fiber v3, Firebase Authentication, Redis, `golang-jwt/jwt/v5`, OpenTelemetry, Cloud Build, Cloud Run, Docker Compose, Testify. + +**Spec:** `docs/superpowers/specs/2026-09-03-httpsms-mcp-server-design.md` + +## Global Constraints + +- Develop only in `.worktrees/mcp-server` on branch `feat/mcp-server`. +- Serve MCP at `https://mcp.httpsms.com/mcp`. +- Implement MCP `2026-07-28` and retain `2025-11-25` compatibility. +- Use `github.com/modelcontextprotocol/go-sdk` v1.7.0; do not use `mark3labs/mcp-go`. +- Keep MCP protocol handling stateless; store OAuth grants and confirmation state in Redis. +- Use Firebase ID tokens only as browser-login identity proof, never as MCP access tokens. +- Issue separate audience-bound JWTs for MCP access and downstream API delegation. +- Do not store user Firebase refresh tokens or primary httpSMS API keys. +- Call all product operations through the httpSMS HTTP API; do not access its database from `mcp/`. +- Keep `/v1/messages/search` CAPTCHA-protected. +- Never log SMS content, bearer tokens, authorization codes, refresh tokens, PKCE verifiers, or API-key values. +- Use `stacktrace.Propagate` or `stacktrace.PropagateWithCode` for API errors; never return bare API errors. +- Run `go-fumpt` and `go-imports` through the repository's existing formatting workflow. +- Regenerate Swagger after API annotation changes. + +--- + +## File Map + +### Existing API + +- `api/pkg/auth/mcp_claims.go`: delegated token claims and scope parsing. +- `api/pkg/auth/mcp_jwks.go`: bounded JWKS fetch/cache and RSA key selection. +- `api/pkg/auth/mcp_token_verifier.go`: issuer, audience, signature, time, and scope validation. +- `api/pkg/middlewares/mcp_delegation_auth_middleware.go`: load the delegated Firebase UID into `AuthContext`. +- `api/pkg/middlewares/bearer_auth_middleware.go`: skip Firebase verification when an earlier middleware authenticated the request and stop logging raw tokens. +- `api/pkg/requests/message_incoming_request.go`: request model and conversion to fixed-type search params. +- `api/pkg/validators/message_handler_validator.go`: incoming-message filter validation without Turnstile. +- `api/pkg/handlers/message_handler.go`: register and implement `GET /v1/messages/incoming`. +- `api/pkg/di/container.go`: construct and order delegated authentication middleware. +- `api/docs/docs.go`, `api/docs/swagger.json`, `api/docs/swagger.yaml`: regenerated API documentation. + +### MCP Module + +- `mcp/go.mod`, `mcp/go.sum`: isolated Go module and pinned dependencies. +- `mcp/cmd/server/main.go`: load config, construct dependencies, serve HTTP, and shut down. +- `mcp/internal/config/config.go`: validated environment configuration. +- `mcp/internal/observability/observability.go`: structured logging and OpenTelemetry setup. +- `mcp/internal/auth/claims.go`: MCP and API JWT claims, principals, scopes, and context helpers. +- `mcp/internal/auth/keys.go`: RSA private-key loading, signing, and JWKS publication. +- `mcp/internal/auth/firebase.go`: Firebase identity-token verification against the configured certificate endpoint. +- `mcp/internal/auth/middleware.go`: official SDK bearer middleware adapter and per-tool scope checks. +- `mcp/internal/oauth/store.go`: Redis records for transactions, codes, refresh tokens, DCR clients, and confirmations. +- `mcp/internal/oauth/metadata.go`: protected-resource, authorization-server, and JWKS HTTP handlers. +- `mcp/internal/oauth/clients.go`: CIMD retrieval/validation and DCR compatibility. +- `mcp/internal/oauth/authorize.go`: authorization request, Firebase completion, consent, and code issuance. +- `mcp/internal/oauth/token.go`: authorization-code and refresh-token grants. +- `mcp/internal/oauth/templates/authorize.html`: Firebase login and scope-consent page. +- `mcp/internal/httpsms/client.go`: typed downstream HTTP client and standard error decoding. +- `mcp/internal/httpsms/models.go`: request/response models used by approved tools. +- `mcp/internal/tools/phones.go`: `list_phones`. +- `mcp/internal/tools/messages.go`: `send_sms`, `list_message_threads`, `list_thread_messages`, and `list_incoming_messages`. +- `mcp/internal/tools/api_keys.go`: `create_phone_api_key` and `rotate_user_api_key`. +- `mcp/internal/tools/register.go`: deterministic tool registration. +- `mcp/internal/server/rate_limit.go`: Redis-backed per-user/per-tool limits. +- `mcp/internal/server/server.go`: route assembly, MCP handler, health endpoint, and middleware chain. +- `mcp/Dockerfile`, `mcp/cloudbuild.yaml`, `mcp/README.md`: build, deployment, and operations. + +### Integration Suite + +- `tests/mcp_helpers_test.go`: OAuth, MCP client, PKCE, and test-token helpers. +- `tests/mcp_integration_test.go`: metadata, protocol, tool, scope, and confirmation tests. +- `tests/docker-compose.yml`: MCP container and test identity/certificate configuration. +- `tests/.env.test`: delegated-auth and MCP test configuration. +- `tests/generate-firebase-credentials.sh`: also generate the throwaway MCP/Firebase test key and WireMock certificate mapping. +- `tests/.gitignore`: exclude generated test keys, certificates, and mappings. +- `.github/workflows/api.yml`: wait for MCP and run the expanded integration suite before deployment. + +--- + +### Task 1: Add delegated MCP JWT authentication to the API + +**Files:** +- Create: `api/pkg/auth/mcp_claims.go` +- Create: `api/pkg/auth/mcp_jwks.go` +- Create: `api/pkg/auth/mcp_token_verifier.go` +- Create: `api/pkg/auth/mcp_token_verifier_test.go` +- Create: `api/pkg/middlewares/mcp_delegation_auth_middleware.go` +- Create: `api/pkg/middlewares/mcp_delegation_auth_middleware_test.go` +- Modify: `api/pkg/middlewares/bearer_auth_middleware.go:15-49` +- Modify: `api/pkg/di/container.go:100-225` +- Modify: `api/go.mod` +- Modify: `api/go.sum` + +**Interfaces:** +- Produces: `auth.NewMCPTokenVerifier(config MCPTokenVerifierConfig) (*MCPTokenVerifier, error)`. +- Produces: `(*MCPTokenVerifier).VerifyRequest(ctx context.Context, raw, method, path string) (*MCPClaims, error)`. +- Produces: `middlewares.MCPDelegationAuth(logger telemetry.Logger, tracer telemetry.Tracer, verifier MCPTokenVerifier, users repositories.UserRepository) fiber.Handler`. +- Consumes later: API requests with `Authorization: Bearer `. + +- [ ] **Step 1: Write failing verifier tests** + +```go +func TestMCPTokenVerifierVerify(t *testing.T) { + privateKey, publicKey := testRSAKey(t) + jwks := httptest.NewServer(testJWKSHandler(t, "test-key", publicKey)) + defer jwks.Close() + + verifier, err := NewMCPTokenVerifier(MCPTokenVerifierConfig{ + Issuer: "https://mcp.httpsms.com", + Audience: "https://api.httpsms.com", + JWKSURL: jwks.URL, + HTTPClient: jwks.Client(), + }) + require.NoError(t, err) + + raw := signDelegatedToken(t, privateKey, "test-key", MCPClaims{ + Scopes: []string{"messages:read"}, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://mcp.httpsms.com", + Subject: "firebase-user-id", + Audience: jwt.ClaimStrings{"https://api.httpsms.com"}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + }) + + claims, err := verifier.Verify(context.Background(), raw, "messages:read") + require.NoError(t, err) + assert.Equal(t, "firebase-user-id", claims.Subject) +} +``` + +Add table cases for wrong issuer, wrong audience, expired token, unknown `kid`, +missing subject, and missing required scope. + +- [ ] **Step 2: Run the verifier test and confirm failure** + +Run: `cd api && go test ./pkg/auth -run TestMCPTokenVerifierVerify -count=1` + +Expected: FAIL because `NewMCPTokenVerifier`, `MCPClaims`, and `Verify` do not exist. + +- [ ] **Step 3: Implement claims, JWKS caching, and verification** + +```go +type MCPClaims struct { + Scopes []string `json:"scopes"` + Method string `json:"http_method"` + Path string `json:"http_path"` + jwt.RegisteredClaims +} + +type MCPTokenVerifierConfig struct { + Issuer string + Audience string + JWKSURL string + HTTPClient *http.Client + CacheTTL time.Duration +} + +func (v *MCPTokenVerifier) VerifyRequest( + ctx context.Context, + raw string, + method string, + path string, +) (*MCPClaims, error) { + claims := new(MCPClaims) + token, err := jwt.ParseWithClaims( + raw, + claims, + v.keyfunc(ctx), + jwt.WithIssuer(v.issuer), + jwt.WithAudience(v.audience), + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + ) + if err != nil { + return nil, stacktrace.PropagateWithCodef(err, ErrCodeInvalidToken, "invalid MCP delegated token") + } + if !token.Valid || claims.Subject == "" { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "invalid MCP delegated token") + } + requiredScope, ok := requiredMCPDelegatedScope(method, path) + if !ok || claims.Method != method || claims.Path != path { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInvalidToken, "MCP delegated token is not valid for this API operation") + } + if !containsAllScopes(claims.Scopes, []string{requiredScope}) { + return nil, stacktrace.NewErrorWithCodef(ErrCodeInsufficientScope, "MCP delegated token has insufficient scope") + } + return claims, nil +} +``` + +Implement `requiredMCPDelegatedScope` for only the approved method/path pairs: +phones read, SMS send, message/thread reads, incoming-message reads, phone +API-key creation, and primary API-key rotation. Implement the JWKS loader with +a 2-second HTTP timeout, 1 MiB response limit, RSA-only keys, `kid` lookup, and +a 15-minute default cache. Refresh once when a requested `kid` is absent, then +fail closed. + +- [ ] **Step 4: Run verifier tests** + +Run: `cd api && go test ./pkg/auth -count=1` + +Expected: PASS. + +- [ ] **Step 5: Write failing middleware tests** + +```go +func TestMCPDelegationAuthSetsAuthContext(t *testing.T) { + app := fiber.New() + app.Use(MCPDelegationAuth(logger, tracer, verifierStub{ + claims: &auth.MCPClaims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-id"}}, + }, userRepositoryStub{ + user: &entities.User{ID: "user-id", Email: "user@example.com"}, + })) + app.Get("/", func(c fiber.Ctx) error { + return c.JSON(c.Locals(ContextKeyAuthUserID)) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer delegated-token") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} +``` + +Add cases for non-bearer requests passing through, invalid delegated tokens +passing through for existing auth middleware, unknown users, and no raw token in +logs. + +- [ ] **Step 6: Run middleware tests and confirm failure** + +Run: `cd api && go test ./pkg/middlewares -run 'TestMCPDelegationAuth|TestBearerAuth' -count=1` + +Expected: FAIL because the middleware is not implemented and `BearerAuth` +still attempts Firebase verification after delegated authentication. + +- [ ] **Step 7: Implement middleware ordering and bearer short-circuit** + +```go +func MCPDelegationAuth( + logger telemetry.Logger, + tracer telemetry.Tracer, + verifier interface { + VerifyRequest(context.Context, string, string, string) (*auth.MCPClaims, error) + }, + users repositories.UserRepository, +) fiber.Handler { + return func(c fiber.Ctx) error { + raw := bearerToken(c.Get(authHeaderBearer)) + if raw == "" { + return c.Next() + } + claims, err := verifier.VerifyRequest(c.Context(), raw, c.Method(), c.Path()) + if err != nil { + if stacktrace.GetCode(err) == auth.ErrCodeInsufficientScope || + stacktrace.GetCode(err) == auth.ErrCodeOperationDenied { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "status": "error", "message": "MCP token cannot access this API operation", + }) + } + return c.Next() + } + user, err := users.Load(c.Context(), entities.UserID(claims.Subject)) + if err != nil { + return c.Next() + } + c.Locals(ContextKeyAuthUserID, entities.AuthContext{ID: user.ID, Email: user.Email}) + return c.Next() + } +} +``` + +At the top of `BearerAuth`, return `c.Next()` when +`ContextKeyAuthUserID` already contains a non-noop `entities.AuthContext`. +Replace the existing invalid-token log message so it never interpolates +`authToken`. A cryptographically valid MCP token with the wrong scope or +method/path binding must return 403 directly; malformed or non-MCP bearer +tokens continue to the existing Firebase middleware. + +Construct the verifier from `MCP_AUTH_ISSUER`, `MCP_AUTH_AUDIENCE`, and +`MCP_AUTH_JWKS_URL`. Register `MCPDelegationAuth` before `BearerAuth` in +`Container.App()`. Disable it only when all three values are empty; reject +partially configured values during container construction. + +- [ ] **Step 8: Run targeted API tests** + +Run: `cd api && go test ./pkg/auth ./pkg/middlewares ./pkg/di -count=1` + +Expected: PASS. + +- [ ] **Step 9: Commit delegated API authentication** + +```bash +git add api/go.mod api/go.sum api/pkg/auth api/pkg/middlewares api/pkg/di/container.go +git commit -m "feat(api): trust scoped MCP tokens" +``` + +--- + +### Task 2: Add the incoming-message API endpoint + +**Files:** +- Create: `api/pkg/requests/message_incoming_request.go` +- Create: `api/pkg/requests/message_incoming_request_test.go` +- Modify: `api/pkg/validators/message_handler_validator.go:284-340` +- Modify: `api/pkg/validators/message_handler_validator_test.go` +- Modify: `api/pkg/handlers/message_handler.go:51-58,507-550` +- Create: `api/pkg/handlers/message_handler_incoming_test.go` +- Modify: `api/docs/docs.go` +- Modify: `api/docs/swagger.json` +- Modify: `api/docs/swagger.yaml` + +**Interfaces:** +- Produces: `GET /v1/messages/incoming`. +- Produces: `requests.MessageIncoming.ToSearchParams(userID entities.UserID) *services.MessageSearchParams`. +- Consumes: existing `MessageService.SearchMessages`. + +- [ ] **Step 1: Write failing request conversion tests** + +```go +func TestMessageIncomingToSearchParamsForcesMobileOriginated(t *testing.T) { + request := MessageIncoming{ + Owners: []string{"+18005550199"}, + Statuses: []string{"received"}, + SortBy: "created_at", + SortDescending: true, + Limit: "25", + } + + params := request.Sanitize().ToSearchParams(entities.UserID("user-id")) + + assert.Equal(t, []entities.MessageType{entities.MessageTypeMobileOriginated}, params.Types) + assert.Equal(t, []entities.MessageStatus{entities.MessageStatusReceived}, params.Statuses) + assert.Equal(t, 25, params.Limit) +} +``` + +- [ ] **Step 2: Run the request test and confirm failure** + +Run: `cd api && go test ./pkg/requests -run TestMessageIncoming -count=1` + +Expected: FAIL because `MessageIncoming` does not exist. + +- [ ] **Step 3: Implement the request model** + +```go +type MessageIncoming struct { + request + Skip string `json:"skip" query:"skip"` + Owners []string `json:"owners" query:"owners"` + Statuses []string `json:"statuses" query:"statuses"` + Query string `json:"query" query:"query"` + SortBy string `json:"sort_by" query:"sort_by"` + SortDescending bool `json:"sort_descending" query:"sort_descending"` + Limit string `json:"limit" query:"limit"` +} + +func (input MessageIncoming) ToSearchParams(userID entities.UserID) *services.MessageSearchParams { + statuses := make([]entities.MessageStatus, 0, len(input.Statuses)) + for _, status := range input.Statuses { + statuses = append(statuses, entities.MessageStatus(status)) + } + return &services.MessageSearchParams{ + IndexParams: repositories.IndexParams{ + Skip: input.getInt(input.Skip), Query: input.Query, + SortBy: input.SortBy, SortDescending: input.SortDescending, + Limit: input.getInt(input.Limit), + }, + UserID: userID, + Owners: input.Owners, + Types: []entities.MessageType{entities.MessageTypeMobileOriginated}, + Statuses: statuses, + } +} +``` + +Use defaults `skip=0`, `limit=100`, `sort_by=created_at`, and +`sort_descending=true`. + +- [ ] **Step 4: Write failing validator and handler tests** + +The validator test must prove no Turnstile token is requested. The handler test +must capture repository search arguments and assert the fixed message type: + +```go +require.Equal(t, + []entities.MessageType{entities.MessageTypeMobileOriginated}, + repository.searchTypes, +) +require.Equal(t, entities.UserID("user-id"), repository.searchUserID) +``` + +- [ ] **Step 5: Run validator and handler tests and confirm failure** + +Run: `cd api && go test ./pkg/validators ./pkg/handlers -run 'MessageIncoming|Incoming' -count=1` + +Expected: FAIL because the validator, route, and handler are missing. + +- [ ] **Step 6: Implement validation and handler** + +Add `ValidateMessageIncoming` with: + +```go +"owners": {multipleContactPhoneNumberRule}, +"statuses": {multipleInRule + ":" + entities.MessageStatusReceived}, +"sort_by": {"in:created_at,owner,contact,status"}, +"limit": {"required", "numeric", "min:1", "max:200"}, +"skip": {"required", "numeric", "min:0"}, +"query": {"max:50"}, +``` + +Register the route before `/:messageID`: + +```go +h.register(router, fiber.MethodGet, "/v1/messages/incoming", middlewares, h.Incoming) +``` + +Implement `Incoming` by binding the query, sanitizing, validating with +`ValidateMessageIncoming`, calling `SearchMessages`, and returning the existing +standard response envelope. Add Swagger annotations documenting that only +mobile-originated messages are returned. + +- [ ] **Step 7: Run endpoint tests** + +Run: `cd api && go test ./pkg/requests ./pkg/validators ./pkg/handlers -run 'MessageIncoming|Incoming' -count=1` + +Expected: PASS. + +- [ ] **Step 8: Regenerate and verify Swagger** + +Run: + +```bash +cd api +swag init --requiredByDefault --parseDependency --parseInternal +grep -n '"/messages/incoming"' docs/swagger.json +``` + +Expected: Swagger generation succeeds and the route is present. + +- [ ] **Step 9: Commit the incoming-message endpoint** + +```bash +git add api/pkg/requests api/pkg/validators api/pkg/handlers api/docs +git commit -m "feat(api): add incoming message endpoint" +``` + +--- + +### Task 3: Bootstrap the MCP module, configuration, and key handling + +**Files:** +- Create: `mcp/go.mod` +- Create: `mcp/go.sum` +- Create: `mcp/internal/config/config.go` +- Create: `mcp/internal/config/config_test.go` +- Create: `mcp/internal/auth/claims.go` +- Create: `mcp/internal/auth/keys.go` +- Create: `mcp/internal/auth/keys_test.go` +- Create: `mcp/internal/observability/observability.go` + +**Interfaces:** +- Produces: `config.Load() (config.Config, error)`. +- Produces: `auth.NewKeySet(privateKeyPEM []byte, keyID string) (*auth.KeySet, error)`. +- Produces: `(*auth.KeySet).SignMCPAccessToken(principal auth.Principal, clientID string, scopes []string, ttl time.Duration) (string, error)`. +- Produces: `(*auth.KeySet).SignAPIDelegationToken(principal auth.Principal, scopes []string, method, path string, ttl time.Duration) (string, error)`. +- Produces: `(*auth.KeySet).JWKS() auth.JWKS`. + +- [ ] **Step 1: Create the module and pin dependencies** + +```go +module github.com/NdoleStudio/httpsms/mcp + +go 1.25.0 + +require ( + firebase.google.com/go v3.13.0+incompatible + github.com/modelcontextprotocol/go-sdk v1.7.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/redis/go-redis/v9 v9.21.0 + github.com/rs/zerolog v1.35.1 + github.com/stretchr/testify v1.12.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 + go.opentelemetry.io/otel v1.46.0 +) +``` + +Run: `cd mcp && go mod tidy` + +Expected: dependencies resolve and `go.sum` is created. + +- [ ] **Step 2: Write failing configuration tests** + +```go +func TestLoadRejectsPartialConfiguration(t *testing.T) { + t.Setenv("MCP_BASE_URL", "https://mcp.httpsms.com") + t.Setenv("HTTPSMS_API_URL", "") + _, err := Load() + require.ErrorContains(t, err, "HTTPSMS_API_URL") +} +``` + +Cover required URLs, Redis URL, Firebase project/config, RSA PEM, key ID, +audiences, access-token TTL, refresh-token TTL, HTTP timeout, and production +HTTPS enforcement. + +- [ ] **Step 3: Run configuration tests and confirm failure** + +Run: `cd mcp && go test ./internal/config -count=1` + +Expected: FAIL because `Load` does not exist. + +- [ ] **Step 4: Implement validated configuration** + +```go +type Config struct { + Environment string + Port string + BaseURL *url.URL + APIURL *url.URL + RedisURL string + FirebaseProjectID string + FirebaseAPIKey string + FirebaseAuthDomain string + FirebaseCertsURL *url.URL + SigningPrivateKeyPEM []byte + SigningKeyID string + MCPAudience string + APIAudience string + AccessTokenTTL time.Duration + APIDelegationTokenTTL time.Duration + AuthorizationCodeTTL time.Duration + RefreshTokenTTL time.Duration + ConfirmationTTL time.Duration + HTTPTimeout time.Duration + ReadToolsPerMinute int + SendToolsPerMinute int + KeyCreatesPerHour int + KeyRotationsPerHour int +} +``` + +Use defaults: `PORT=8080`, MCP access token `15m`, API delegation token `2m`, +authorization code `2m`, refresh token `30d`, confirmation `5m`, and HTTP +timeout `10s`. Load key material from `MCP_SIGNING_PRIVATE_KEY`; when +`MCP_SIGNING_PRIVATE_KEY_FILE` is set, read that file instead and reject +configurations that set both. Rate-limit defaults are 120 read calls/minute, +30 SMS sends/minute, 10 phone API-key creations/hour, and 3 primary API-key +rotations/hour. Production URLs must use HTTPS. + +- [ ] **Step 5: Write failing key-set tests** + +```go +func TestKeySetSignsAudienceBoundTokens(t *testing.T) { + keys := newTestKeySet(t) + raw, err := keys.SignMCPAccessToken( + Principal{UserID: "user-id", Email: "user@example.com"}, + "https://client.example/metadata.json", + []string{"messages:read"}, + 15*time.Minute, + ) + require.NoError(t, err) + claims := parseTestClaims(t, raw, keys.PublicKey()) + assert.Equal(t, "https://mcp.httpsms.com/mcp", claims.Audience[0]) + assert.Equal(t, "user-id", claims.Subject) +} +``` + +Also assert the API token uses `https://api.httpsms.com`, has only requested +scopes, includes `kid`, and never exceeds the configured TTL. + +- [ ] **Step 6: Implement claims, signing, and JWKS** + +```go +type Principal struct { + UserID string + Email string +} + +type AccessClaims struct { + ClientID string `json:"client_id"` + Email string `json:"email,omitempty"` + Scopes []string `json:"scopes"` + Method string `json:"http_method,omitempty"` + Path string `json:"http_path,omitempty"` + jwt.RegisteredClaims +} +``` + +Load only PKCS#8 or PKCS#1 RSA private keys of at least 2048 bits. Sign with +RS256. Generate JWKS `n` and `e` values from the RSA public key and publish only +the public key. + +- [ ] **Step 7: Add observability bootstrap** + +Expose: + +```go +func New(ctx context.Context, serviceName, version string) ( + logger zerolog.Logger, + shutdown func(context.Context) error, + err error, +) +``` + +Configure JSON logs, service/version fields, W3C propagation, and an +OpenTelemetry tracer provider. Provide a no-exporter local mode when no OTLP or +Google exporter configuration is present. + +- [ ] **Step 8: Run MCP foundational tests** + +Run: `cd mcp && go test ./internal/config ./internal/auth ./internal/observability -count=1` + +Expected: PASS. + +- [ ] **Step 9: Commit the MCP foundation** + +```bash +git add mcp/go.mod mcp/go.sum mcp/internal/config mcp/internal/auth mcp/internal/observability +git commit -m "feat(mcp): add service foundation" +``` + +--- + +### Task 4: Implement Redis OAuth state and client registration + +**Files:** +- Create: `mcp/internal/oauth/store.go` +- Create: `mcp/internal/oauth/store_test.go` +- Create: `mcp/internal/oauth/clients.go` +- Create: `mcp/internal/oauth/clients_test.go` +- Create: `mcp/internal/oauth/metadata.go` +- Create: `mcp/internal/oauth/metadata_test.go` + +**Interfaces:** +- Produces: `oauth.Store` for transactions, codes, refresh tokens, DCR clients, and confirmations. +- Produces: `oauth.ClientResolver.Resolve(ctx context.Context, clientID string) (oauth.Client, error)`. +- Produces: metadata handlers mounted by Task 9. + +- [ ] **Step 1: Define the store interface and failing one-time-use tests** + +```go +type Store interface { + PutAuthorizationTransaction(context.Context, AuthorizationTransaction, time.Duration) error + GetAuthorizationTransaction(context.Context, string) (AuthorizationTransaction, error) + PutAuthorizationCode(context.Context, AuthorizationCode, time.Duration) error + ConsumeAuthorizationCode(context.Context, string) (AuthorizationCode, error) + PutRefreshToken(context.Context, RefreshGrant, time.Duration) error + RotateRefreshToken(context.Context, string, RefreshGrant, time.Duration) error + PutDynamicClient(context.Context, Client, time.Duration) error + GetDynamicClient(context.Context, string) (Client, error) + PutConfirmation(context.Context, Confirmation, time.Duration) error + ConsumeConfirmation(context.Context, string) (Confirmation, error) +} +``` + +The test must call `ConsumeAuthorizationCode` twice and assert the second call +returns `ErrNotFound`. + +- [ ] **Step 2: Run store tests and confirm failure** + +Run: `cd mcp && go test ./internal/oauth -run 'Store|AuthorizationCode' -count=1` + +Expected: FAIL because the Redis store is missing. + +- [ ] **Step 3: Implement Redis records and atomic consumption** + +Use namespaced keys: + +```text +httpsms:mcp:oauth:transaction: +httpsms:mcp:oauth:code: +httpsms:mcp:oauth:refresh: +httpsms:mcp:oauth:client: +httpsms:mcp:confirmation: +``` + +Generate public values with `crypto/rand`, store only SHA-256 hashes, serialize +records as JSON, and consume codes/confirmations atomically with Redis +`GETDEL`. Rotate refresh tokens in a transaction that deletes the old hash and +creates the new hash with TTL. + +- [ ] **Step 4: Write failing CIMD and DCR tests** + +```go +func TestClientResolverRejectsPrivateMetadataTarget(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, store) + _, err := resolver.Resolve(context.Background(), "https://127.0.0.1/client.json") + require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) +} +``` + +Cover HTTPS enforcement, loopback exception only for redirect URIs, private and +link-local DNS results, response-size limit, unsafe redirects, exact +`client_id`, supported grant/response types, and `token_endpoint_auth_method=none`. + +- [ ] **Step 5: Implement client metadata resolution** + +```go +type Client struct { + ID string `json:"client_id"` + Name string `json:"client_name"` + URI string `json:"client_uri,omitempty"` + RedirectURIs []string `json:"redirect_uris"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} +``` + +Fetch CIMD documents with a dedicated transport that rejects resolved private +addresses, disables automatic redirects, limits bodies to 256 KiB, and uses a +5-second timeout. Cache validated documents for 15 minutes. Implement +`POST /oauth/register` by validating the same fields, assigning a random +`client_id`, storing the record for 24 hours, and returning HTTP 201. + +- [ ] **Step 6: Write and implement metadata handlers** + +Assert exact JSON fields: + +```json +{ + "resource": "https://mcp.httpsms.com/mcp", + "authorization_servers": ["https://mcp.httpsms.com"], + "scopes_supported": [ + "phones:read", + "messages:read", + "messages:send", + "phone-api-keys:write", + "user-api-key:rotate" + ] +} +``` + +Authorization-server metadata must include issuer, authorization endpoint, +token endpoint, registration endpoint, JWKS URI, code and refresh grant types, +`S256`, supported scopes, and +`"client_id_metadata_document_supported": true`. + +- [ ] **Step 7: Run OAuth state and metadata tests** + +Run: `cd mcp && go test ./internal/oauth -run 'Store|Client|Metadata|Registration' -count=1` + +Expected: PASS. + +- [ ] **Step 8: Commit OAuth state and registration** + +```bash +git add mcp/internal/oauth +git commit -m "feat(mcp): add OAuth state and metadata" +``` + +--- + +### Task 5: Implement Firebase login, authorization codes, and token grants + +**Files:** +- Create: `mcp/internal/auth/firebase.go` +- Create: `mcp/internal/auth/firebase_test.go` +- Create: `mcp/internal/oauth/authorize.go` +- Create: `mcp/internal/oauth/authorize_test.go` +- Create: `mcp/internal/oauth/token.go` +- Create: `mcp/internal/oauth/token_test.go` +- Create: `mcp/internal/oauth/templates/authorize.html` + +**Interfaces:** +- Produces: `auth.IdentityVerifier.Verify(ctx context.Context, raw string) (auth.Principal, error)`. +- Produces: `oauth.Server.HandleAuthorize`, `HandleFirebaseComplete`, and `HandleToken`. +- Consumes: Task 3 key set and Task 4 store/client resolver. + +- [ ] **Step 1: Write failing Firebase verifier tests** + +Serve a Firebase-style certificate map from `httptest`: + +```json +{"firebase-test-key":"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"} +``` + +Sign a token with issuer +`https://securetoken.google.com/httpsms-test`, audience `httpsms-test`, +`sub=user-id`, `user_id=user-id`, and `email=user@example.com`. Assert valid +tokens return that principal and wrong issuer/audience/expiry fail. + +- [ ] **Step 2: Implement the Firebase verifier** + +```go +type IdentityVerifier interface { + Verify(context.Context, string) (Principal, error) +} + +func (v *FirebaseVerifier) Verify(ctx context.Context, raw string) (Principal, error) { + claims := new(firebaseClaims) + token, err := jwt.ParseWithClaims( + raw, + claims, + v.keyfunc(ctx), + jwt.WithIssuer("https://securetoken.google.com/"+v.projectID), + jwt.WithAudience(v.projectID), + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + ) + if err != nil || !token.Valid || claims.Subject == "" { + return Principal{}, ErrInvalidIdentityToken + } + return Principal{UserID: claims.Subject, Email: claims.Email}, nil +} +``` + +Use the same bounded refresh-on-missing-`kid` behavior as the API JWKS loader, +but decode Google's certificate-map response. + +- [ ] **Step 3: Write failing authorization flow tests** + +Test: + +1. `/oauth/authorize` rejects missing state, PKCE, resource, or redirect URI. +2. a valid request creates a transaction and renders the Firebase page; +3. `/oauth/firebase/complete` rejects a bad identity token; +4. a valid token and approved scopes issue a one-time code redirect; +5. success and error redirects include the RFC 9207 `iss` parameter; +6. denial redirects with `error=access_denied`. + +- [ ] **Step 4: Implement authorization and consent** + +```go +type AuthorizationTransaction struct { + ID string + ClientID string + RedirectURI string + State string + Resource string + Scopes []string + CodeChallenge string + CodeChallengeMethod string + CreatedAt time.Time +} +``` + +Render `authorize.html` with the Firebase API key, auth domain, transaction ID, +client name, and human-readable scopes. The page must post the Firebase ID +token and approved scope list to `/oauth/firebase/complete`; it must never put +the token in a query string. + +- [ ] **Step 5: Write failing token endpoint tests** + +```go +func TestTokenEndpointConsumesCodeAndChecksPKCE(t *testing.T) { + code := issueTestAuthorizationCode(t, store, "verifier") + response := postToken(t, server, url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "code_verifier": {"verifier"}, + "client_id": {"https://client.example/client.json"}, + "redirect_uri": {"http://127.0.0.1:3210/callback"}, + "resource": {"https://mcp.httpsms.com/mcp"}, + }) + require.Equal(t, http.StatusOK, response.StatusCode) + postAgain := postToken(t, server, sameValues) + require.Equal(t, http.StatusBadRequest, postAgain.StatusCode) +} +``` + +Cover code replay, wrong verifier, wrong client, wrong redirect URI, wrong +resource, refresh rotation, old-refresh replay, and scope narrowing. + +- [ ] **Step 6: Implement authorization-code and refresh grants** + +Return: + +```json +{ + "access_token": "", + "token_type": "Bearer", + "expires_in": 900, + "refresh_token": "", + "scope": "messages:read messages:send" +} +``` + +Require the `resource` value to equal the MCP endpoint. Bind codes and refresh +grants to client ID, user, resource, redirect URI where applicable, and granted +scopes. Rotate refresh tokens on every use and reject scope expansion. + +- [ ] **Step 7: Run OAuth flow tests** + +Run: `cd mcp && go test ./internal/auth ./internal/oauth -count=1` + +Expected: PASS. + +- [ ] **Step 8: Commit Firebase-backed OAuth** + +```bash +git add mcp/internal/auth/firebase.go mcp/internal/auth/firebase_test.go mcp/internal/oauth +git commit -m "feat(mcp): add Firebase OAuth exchange" +``` + +--- + +### Task 6: Build the typed httpSMS API client + +**Files:** +- Create: `mcp/internal/httpsms/models.go` +- Create: `mcp/internal/httpsms/client.go` +- Create: `mcp/internal/httpsms/client_test.go` + +**Interfaces:** +- Produces: `httpsms.Client` methods used by all MCP tools. +- Consumes: delegated API JWT strings supplied per call. + +- [ ] **Step 1: Define the client interface and failing tests** + +```go +type Client interface { + ListPhones(context.Context, string, ListPhonesParams) ([]Phone, error) + SendSMS(context.Context, string, SendSMSParams) (Message, error) + ListMessageThreads(context.Context, string, ListMessageThreadsParams) ([]MessageThread, error) + ListThreadMessages(context.Context, string, ListThreadMessagesParams) ([]Message, error) + ListIncomingMessages(context.Context, string, ListIncomingMessagesParams) ([]Message, error) + CreatePhoneAPIKey(context.Context, string, CreatePhoneAPIKeyParams) (PhoneAPIKey, error) + RotateUserAPIKey(context.Context, string, string) (User, error) +} +``` + +For each method, use `httptest.Server` to assert method, path, encoded query or +JSON body, `Authorization: Bearer`, content type, request ID, and response +decoding. + +- [ ] **Step 2: Run client tests and confirm failure** + +Run: `cd mcp && go test ./internal/httpsms -count=1` + +Expected: FAIL because the client and models do not exist. + +- [ ] **Step 3: Implement API models and standard envelopes** + +```go +type Response[T any] struct { + Status string `json:"status"` + Message string `json:"message"` + Data T `json:"data"` +} + +type APIError struct { + StatusCode int + Message string + Fields map[string][]string + RequestID string +} +``` + +Define only fields required by MCP output schemas. Keep phone numbers, UUIDs, +timestamps, SIM, message type/status, encryption flag, content, attachments, +thread unread/archive state, and secret API-key fields. + +- [ ] **Step 4: Implement the bounded HTTP client** + +```go +func (c *client) do( + ctx context.Context, + token string, + method string, + path string, + query url.Values, + input any, + output any, +) error +``` + +Use an `http.Client` with explicit timeout, `otelhttp.Transport`, connection +pool limits, 2 MiB response cap, and no automatic retries for writes. Decode +non-2xx responses into `APIError`; return an error when the body is malformed +or exceeds the limit. Never include request bodies or bearer tokens in errors. + +- [ ] **Step 5: Run API client tests** + +Run: `cd mcp && go test ./internal/httpsms -count=1` + +Expected: PASS. + +- [ ] **Step 6: Commit the API client** + +```bash +git add mcp/internal/httpsms +git commit -m "feat(mcp): add httpSMS API client" +``` + +--- + +### Task 7: Implement read and send MCP tools + +**Files:** +- Create: `mcp/internal/auth/middleware.go` +- Create: `mcp/internal/auth/middleware_test.go` +- Create: `mcp/internal/tools/phones.go` +- Create: `mcp/internal/tools/messages.go` +- Create: `mcp/internal/tools/messages_test.go` +- Create: `mcp/internal/tools/register.go` + +**Interfaces:** +- Produces: typed handlers registered with `mcp.AddTool`. +- Consumes: `auth.PrincipalFromContext`, `auth.RequireScope`, `auth.KeySet`, and `httpsms.Client`. + +- [ ] **Step 1: Write failing MCP bearer middleware tests** + +Use `auth.RequireBearerToken` from the official SDK: + +```go +middleware := mcpauth.RequireBearerToken(verifier.VerifyMCPToken, &mcpauth.RequireBearerTokenOptions{ + ResourceMetadataURL: "https://mcp.httpsms.com/.well-known/oauth-protected-resource", +}) +``` + +Assert missing, expired, wrong-audience, and invalid tokens return `401` with +`WWW-Authenticate`, while a valid token stores `mcpauth.TokenInfo` containing +user ID, expiry, scopes, and the full principal in `Extra`. + +- [ ] **Step 2: Implement the MCP token verifier adapter** + +```go +func (v *Verifier) VerifyMCPToken( + ctx context.Context, + raw string, + _ *http.Request, +) (*mcpauth.TokenInfo, error) { + claims, err := v.VerifyAccessToken(raw) + if err != nil { + return nil, fmt.Errorf("%w: invalid access token", mcpauth.ErrInvalidToken) + } + return &mcpauth.TokenInfo{ + UserID: claims.Subject, + Scopes: claims.Scopes, + Expiration: claims.ExpiresAt.Time, + Extra: map[string]any{ + "principal": Principal{UserID: claims.Subject, Email: claims.Email}, + "client_id": claims.ClientID, + }, + }, nil +} +``` + +Add `RequireScope(ctx, scope)` and `PrincipalFromContext(ctx)` helpers that read +`mcpauth.TokenInfoFromContext`. + +- [ ] **Step 3: Write failing typed-tool tests** + +Create an in-memory MCP client/server pair. Register tools against an API client +stub and a test key set. Assert: + +- `list_phones` returns stable structured content; +- `send_sms` forwards all supported optional fields; +- `list_message_threads` enforces a maximum limit of 20; +- `list_thread_messages` requires owner and contact; +- `list_incoming_messages` calls the dedicated incoming endpoint; +- missing scopes produce tool errors without API calls. + +- [ ] **Step 4: Define typed tool inputs and outputs** + +```go +type SendSMSInput struct { + From string `json:"from" jsonschema:"registered httpSMS phone number in E.164 format"` + To string `json:"to" jsonschema:"destination phone number in E.164 format"` + Content string `json:"content" jsonschema:"SMS content"` + SIM string `json:"sim,omitempty" jsonschema:"SIM1, SIM2, or DEFAULT"` + RequestID string `json:"request_id,omitempty"` + Encrypted bool `json:"encrypted,omitempty"` + Attachments []string `json:"attachments,omitempty"` +} + +type MessageListOutput struct { + Messages []httpsms.Message `json:"messages"` + Count int `json:"count"` +} +``` + +Define corresponding inputs/outputs for phones, threads, thread messages, and +incoming messages. Use pointer fields where omission differs from a zero value. + +- [ ] **Step 5: Implement scoped handlers** + +Each handler follows this sequence: + +```go +principal, err := auth.RequireScope(ctx, auth.ScopeMessagesRead) +if err != nil { + return nil, Output{}, err +} +delegated, err := keys.SignAPIDelegationToken( + principal, + []string{auth.ScopeMessagesRead}, + http.MethodGet, + "/v1/messages/incoming", + apiTokenTTL, +) +if err != nil { + return nil, Output{}, fmt.Errorf("sign API delegation token: %w", err) +} +items, err := api.ListIncomingMessages(ctx, delegated, params) +if err != nil { + return toolError(err), Output{}, nil +} +return nil, MessageListOutput{Messages: items, Count: len(items)}, nil +``` + +Use `mcp.ToolAnnotations` to mark read tools as read-only and `send_sms` as +destructive/non-idempotent. Register tools in the approved deterministic order: +phones, send, threads, thread messages, incoming messages. + +- [ ] **Step 6: Run tool tests** + +Run: `cd mcp && go test ./internal/auth ./internal/tools -run 'Phones|SMS|Message|Scope' -count=1` + +Expected: PASS. + +- [ ] **Step 7: Commit read and send tools** + +```bash +git add mcp/internal/auth/middleware.go mcp/internal/auth/middleware_test.go mcp/internal/tools +git commit -m "feat(mcp): add SMS and message tools" +``` + +--- + +### Task 8: Implement API-key tools and confirmed rotation + +**Files:** +- Create: `mcp/internal/tools/api_keys.go` +- Create: `mcp/internal/tools/api_keys_test.go` +- Modify: `mcp/internal/tools/register.go` +- Modify: `mcp/internal/oauth/store.go` + +**Interfaces:** +- Produces: `create_phone_api_key`. +- Produces: `rotate_user_api_key` with MRTR confirmation and Redis state. +- Consumes: Task 4 confirmation store and Task 6 API client. + +- [ ] **Step 1: Write failing phone API-key creation tests** + +Assert the handler requires `phone-api-keys:write`, forwards only the name, and +returns: + +```go +type CreatePhoneAPIKeyOutput struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"api_key"` + Sensitive bool `json:"sensitive"` +} +``` + +Also assert the secret never appears in captured logs. + +- [ ] **Step 2: Implement `create_phone_api_key`** + +Mark the tool as non-idempotent and sensitive in its description. Mint only +`phone-api-keys:write` for the downstream call. Return +`Sensitive: true` and text instructing the user to store the key immediately. + +- [ ] **Step 3: Write failing rotation confirmation tests** + +The first invocation must not call the API: + +```go +result, output, err := handler(ctx, requestWithoutInputResponses, RotateUserAPIKeyInput{}) +require.NoError(t, err) +require.Nil(t, output) +require.Contains(t, result.InputRequests, "confirm_rotation") +require.NotEmpty(t, result.RequestState) +require.Zero(t, api.rotateCalls) +``` + +The confirmed retry must consume the stored handle, verify user/client/tool +binding, call the API once, and reject replay. Add a legacy explicit +`confirmation_handle` test for clients that cannot complete MRTR. + +- [ ] **Step 4: Implement confirmation state and MRTR** + +```go +type Confirmation struct { + UserID string + ClientID string + Operation string + CreatedAt time.Time +} +``` + +On the first call: + +1. generate and store a five-minute confirmation handle; +2. return `InputRequests` with an `mcp.ElicitParams` boolean confirmation; +3. set `RequestState` to the opaque handle; +4. include a warning that the current primary API key will stop working. + +On retry, require an accepted elicitation response or the explicit legacy +handle, atomically consume the handle, compare constant-time bindings, mint the +`user-api-key:rotate` API JWT, and call +`DELETE /v1/users/{principal.UserID}/api-keys`. + +- [ ] **Step 5: Run API-key tool tests** + +Run: `cd mcp && go test ./internal/tools -run 'APIKey|Rotate|Confirmation' -count=1` + +Expected: PASS. + +- [ ] **Step 6: Commit API-key tools** + +```bash +git add mcp/internal/tools mcp/internal/oauth/store.go +git commit -m "feat(mcp): add confirmed API key tools" +``` + +--- + +### Task 9: Assemble the MCP and OAuth HTTP server + +**Files:** +- Create: `mcp/internal/server/rate_limit.go` +- Create: `mcp/internal/server/rate_limit_test.go` +- Create: `mcp/internal/server/server.go` +- Create: `mcp/internal/server/server_test.go` +- Create: `mcp/cmd/server/main.go` +- Create: `mcp/cmd/server/main_test.go` + +**Interfaces:** +- Produces: `server.New(config.Config, Dependencies) (http.Handler, error)`. +- Produces: executable `mcp-server`. +- Consumes: all MCP, OAuth, auth, storage, API client, and observability components. + +- [ ] **Step 1: Write failing route and protocol tests** + +Assert: + +- `GET /health` returns 200; +- metadata, JWKS, authorize, token, and registration routes are mounted; +- unauthenticated `POST /mcp` returns 401 and protected-resource metadata; +- authenticated `server/discover` negotiates `2026-07-28`; +- legacy `initialize` negotiates `2025-11-25`; +- `tools/list` order is deterministic; +- `GET /mcp` and `DELETE /mcp` are rejected in stateless mode. + +- [ ] **Step 2: Write and implement Redis tool rate-limit tests** + +```go +func TestToolRateLimiterSeparatesUsersAndTools(t *testing.T) { + limiter := NewToolRateLimiter(redisClient, Limits{ReadPerMinute: 2}) + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + require.ErrorIs(t, limiter.Allow(ctx, "user-a", "list_phones"), ErrRateLimited) + require.NoError(t, limiter.Allow(ctx, "user-b", "list_phones")) +} +``` + +Use Redis `INCR` plus `EXPIRE` in a transaction or Lua script so the first +increment sets the window atomically. Key by SHA-256 user ID, tool name, and +window start. Apply the configured read, send, key-create, and key-rotation +budgets before tool execution. Return a structured MCP rate-limit error with a +retry-after duration. + +- [ ] **Step 3: Configure the official Streamable HTTP handler** + +```go +mcpServer := mcp.NewServer( + &mcp.Implementation{Name: "httpSMS", Version: version}, + &mcp.ServerOptions{}, +) +tools.Register(mcpServer, dependencies.Tools) + +mcpHandler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return mcpServer }, + &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + PropagateRequestCancellation: true, + MaxRequestBodyBytes: 1 << 20, + Logger: slogLogger, + }, +) +``` + +Use the SDK defaults that support `2026-07-28` and `2025-11-25`. Add an +explicit protocol-version test so a future SDK upgrade cannot silently remove +either required version. + +- [ ] **Step 4: Assemble the middleware chain** + +Order: + +1. request ID; +2. panic recovery; +3. secure response headers; +4. OpenTelemetry HTTP middleware; +5. redacted structured request logging; +6. OAuth/public routes; +7. official `auth.RequireBearerToken` around `/mcp`; +8. per-user/per-tool Redis rate limiting using `Mcp-Name`; +9. MCP Streamable HTTP handler. + +Set `Cache-Control: no-store` on token, authorization, Firebase completion, +secret-result, and error responses. Set permissive CORS only on public metadata +handlers; do not enable wildcard credentialed CORS. + +- [ ] **Step 5: Implement dependency construction and graceful shutdown** + +`main.go` must: + +```go +cfg, err := config.Load() +if err != nil { + log.Fatal().Err(err).Msg("load configuration") +} +ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) +defer stop() + +handler, shutdown, err := build(ctx, cfg, Version) +if err != nil { + log.Fatal().Err(err).Msg("build MCP server") +} +httpServer := &http.Server{ + Addr: ":" + cfg.Port, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, +} +``` + +Run the server, wait for cancellation or a serve error, then shut down HTTP, +Redis, MCP handler resources, and telemetry with a 10-second deadline. + +- [ ] **Step 6: Run server tests and a local smoke test** + +Run: + +```bash +cd mcp +go test ./internal/server ./cmd/server -count=1 +go build ./cmd/server +``` + +Expected: tests pass and the binary builds. + +- [ ] **Step 7: Commit server assembly** + +```bash +git add mcp/internal/server mcp/cmd/server +git commit -m "feat(mcp): serve stateless MCP over HTTP" +``` + +--- + +### Task 10: Add container and Cloud Run deployment configuration + +**Files:** +- Create: `mcp/Dockerfile` +- Create: `mcp/cloudbuild.yaml` +- Create: `mcp/.dockerignore` +- Create: `mcp/README.md` + +**Interfaces:** +- Produces: container listening on `$PORT`. +- Produces: Cloud Build deployment for service `http-sms-mcp`. + +- [ ] **Step 1: Add the multi-stage Dockerfile** + +```dockerfile +FROM golang:1.25-alpine AS builder +ARG GIT_COMMIT +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w -X main.Version=${GIT_COMMIT}" \ + -o /out/mcp-server ./cmd/server + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S mcp && adduser -S mcp -G mcp +USER mcp +COPY --from=builder /out/mcp-server /usr/local/bin/mcp-server +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/mcp-server"] +``` + +- [ ] **Step 2: Build and inspect the container** + +Run: + +```bash +docker build -t httpsms-mcp:test mcp +docker image inspect httpsms-mcp:test --format '{{.Config.User}} {{.Config.ExposedPorts}}' +``` + +Expected: image builds, runs as `mcp`, and exposes `8080/tcp`. + +- [ ] **Step 3: Add Cloud Build deployment** + +Mirror `api/cloudbuild.yaml` with: + +```yaml +substitutions: + _SERVICE_NAME: http-sms-mcp + _REGION: us-east1 +``` + +Build from `mcp/Dockerfile`, publish commit and `latest` tags, and deploy: + +```bash +gcloud run deploy $_SERVICE_NAME \ + --image=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:$SHORT_SHA \ + --region=$_REGION \ + --platform=managed \ + --allow-unauthenticated \ + --port=8080 +``` + +Use Cloud Run secret references for signing key, Redis URL, and Firebase +configuration. Do not place secret values in YAML. + +- [ ] **Step 4: Document operations** + +`mcp/README.md` must document: + +- required environment variables and safe defaults; +- local startup; +- health and MCP URLs; +- Cloud Build invocation; +- one-time `gcloud run domain-mappings create --service http-sms-mcp --domain mcp.httpsms.com --region us-east1`; +- DNS verification; +- signing-key rotation order; +- removal of `2025-11-25` compatibility; +- redaction and secret-management requirements. + +- [ ] **Step 5: Commit deployment files** + +```bash +git add mcp/Dockerfile mcp/.dockerignore mcp/cloudbuild.yaml mcp/README.md +git commit -m "build(mcp): add Cloud Run deployment" +``` + +--- + +### Task 11: Add MCP end-to-end integration tests + +**Files:** +- Create: `tests/mcp_helpers_test.go` +- Create: `tests/mcp_integration_test.go` +- Modify: `tests/generate-firebase-credentials.sh` +- Modify: `tests/.gitignore` +- Modify: `tests/docker-compose.yml` +- Modify: `tests/.env.test` +- Modify: `tests/seed.sql` +- Modify: `tests/go.mod` +- Modify: `tests/go.sum` +- Modify: `tests/README.md` + +**Interfaces:** +- Produces: full-stack tests against `http://localhost:8082/mcp`. +- Consumes: API, Redis, WireMock, database seed, and phone emulator stack. + +- [ ] **Step 1: Add integration dependencies and helpers** + +Add `github.com/modelcontextprotocol/go-sdk v1.7.0` to `tests/go.mod`. + +Implement: + +```go +const mcpBaseURL = "http://localhost:8082" +const mcpTestUserID = "mcp-test-user-id" + +func newMCPClient(t *testing.T, accessToken, protocolVersion string) *mcp.ClientSession +func completeOAuthCodeFlow(t *testing.T, scopes []string) tokenResponse +func signFirebaseTestToken(t *testing.T, userID, email string) string +func pkcePair(t *testing.T) (verifier, challenge string) +``` + +Extend `generate-firebase-credentials.sh` so the same invocation also writes: + +```text +tests/mcp-test-signing-key.pem +tests/mcp-test-signing-cert.pem +tests/wiremock/mappings/firebase-certs.generated.json +``` + +Generate the RSA key and self-signed certificate with OpenSSL, emit a WireMock +mapping whose response body is a Firebase certificate map keyed by +`mcp-test-key`, and add all three generated paths to `tests/.gitignore`. +`signFirebaseTestToken` reads the generated private key. The MCP container +mounts that key read-only. No private key or generated certificate is committed. + +Add a dedicated `mcp-test-user-id` user with primary key +`mcp-test-user-api-key` to `tests/seed.sql`. All MCP integration tokens use +that Firebase UID so key rotation cannot invalidate the shared user used by +pre-existing integration tests. + +- [ ] **Step 2: Extend Docker Compose** + +Add: + +```yaml +mcp: + build: + context: ../mcp + ports: + - "8082:8080" + depends_on: + api: + condition: service_healthy + redis: + condition: service_healthy + wiremock: + condition: service_healthy + env_file: + - .env.test + environment: + PORT: "8080" + MCP_BASE_URL: http://localhost:8082 + HTTPSMS_API_URL: http://api:8000 + FIREBASE_CERTS_URL: http://wiremock:8080/firebase-certs + MCP_SIGNING_PRIVATE_KEY_FILE: /run/secrets/mcp-test-signing-key.pem + volumes: + - ./mcp-test-signing-key.pem:/run/secrets/mcp-test-signing-key.pem:ro +``` + +Add an MCP health check at `http://localhost:8080/health`. Configure the API +with MCP issuer, audience, and JWKS URL using the Docker service URL. + +- [ ] **Step 3: Write metadata, OAuth, and authorization tests** + +Cover: + +- protected-resource and authorization-server metadata; +- unauthenticated 401 and `WWW-Authenticate`; +- PKCE authorization-code exchange; +- wrong issuer, audience, redirect URI, verifier, and replay; +- refresh-token rotation; +- insufficient scope. + +Run: `cd tests && go test -run 'TestMCPMetadata|TestMCPOAuth|TestMCPAuthorization' -count=1` + +Expected before the stack changes are complete: FAIL because MCP is unavailable. + +- [ ] **Step 4: Write protocol compatibility tests** + +Use the official client transport with an authenticated `http.Client`. Assert +`server/discover` and tool calls work for `2026-07-28`, and legacy initialize +works for `2025-11-25`. Assert the tool names exactly match the approved seven +tools. + +- [ ] **Step 5: Write read-tool integration tests** + +Assert: + +- `list_phones` returns the seeded phone; +- `list_message_threads` returns seeded/created threads; +- `list_thread_messages` returns the expected conversation; +- a received SMS created through the phone endpoint appears in + `list_incoming_messages`; +- a missed call does not appear in incoming messages. + +- [ ] **Step 6: Write send-SMS integration test** + +Call `send_sms`, wait for the existing FCM emulator request, fire SENT and +DELIVERED events, and assert the message reaches `delivered`. Reuse +`waitForFCMPush`, `fireEvent`, and `pollMessageStatus`. + +- [ ] **Step 7: Write API-key integration tests** + +Assert: + +- `create_phone_api_key` returns a `pk_` secret and the API accepts it; +- first rotation call does not rotate; +- confirmed rotation returns a new `uk_` secret; +- the old seeded user API key returns 401; +- the replacement key authenticates successfully; +- the confirmation handle cannot be replayed. + +Use only `mcp-test-user-api-key` for this assertion; never rotate the existing +`test-user-api-key`. + +- [ ] **Step 8: Run the complete integration stack** + +Run: + +```bash +cd tests +bash generate-firebase-credentials.sh firebase-credentials.json +export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) +docker compose up -d --build --wait +docker compose wait seed +sleep 2 +go test -v -timeout 300s ./... +docker compose down -v +``` + +Expected: all existing and MCP integration tests pass. + +- [ ] **Step 9: Update integration documentation** + +Update `tests/README.md` architecture, service table, test coverage, startup, +ports, troubleshooting, and CI sections for the MCP service. + +- [ ] **Step 10: Commit integration coverage** + +```bash +git add tests +git commit -m "test(mcp): add full-stack integration coverage" +``` + +--- + +### Task 12: Wire CI, format, and verify the complete feature + +**Files:** +- Modify: `.github/workflows/api.yml` +- Modify as produced by formatting: all changed Go files + +**Interfaces:** +- Produces: required CI gate for API and MCP tests. +- Consumes: every prior task. + +- [ ] **Step 1: Extend CI service readiness checks** + +After the API health loop, add an MCP health loop: + +```bash +echo "Waiting for MCP to be healthy..." +for i in $(seq 1 40); do + if docker compose exec mcp wget -qO- http://localhost:8080/health >/dev/null 2>&1; then + echo "MCP is healthy!" + break + fi + if [ "$i" -eq 40 ]; then + docker compose logs mcp + exit 1 + fi + sleep 5 +done +``` + +Keep deployment gated on the full integration job. + +- [ ] **Step 2: Add MCP unit-test and build steps** + +Before integration tests: + +```yaml +- name: Run MCP Unit Tests + working-directory: ./mcp + run: go test -race -count=1 ./... + +- name: Build MCP Server + working-directory: ./mcp + run: go build ./cmd/server +``` + +- [ ] **Step 3: Format and tidy modules** + +Run: + +```bash +cd api +go mod tidy +go-fumpt -w pkg/auth pkg/middlewares pkg/requests pkg/validators pkg/handlers pkg/di +goimports -w pkg/auth pkg/middlewares pkg/requests pkg/validators pkg/handlers pkg/di + +cd ../mcp +go mod tidy +go-fumpt -w . +goimports -w . + +cd ../tests +go mod tidy +go-fumpt -w mcp_helpers_test.go mcp_integration_test.go +goimports -w mcp_helpers_test.go mcp_integration_test.go +``` + +Expected: all formatters and module tidies complete without errors. + +- [ ] **Step 4: Run targeted unit suites** + +Run: + +```bash +cd api +go test ./pkg/auth ./pkg/middlewares ./pkg/requests ./pkg/validators ./pkg/handlers ./pkg/di + +cd ../mcp +go test -race ./... +go build ./cmd/server +``` + +Expected: PASS. + +- [ ] **Step 5: Run complete API tests** + +Run: `cd api && go test ./...` + +Expected: PASS. + +- [ ] **Step 6: Run complete integration suite** + +Run the Task 11 Docker Compose command. + +Expected: PASS with both protocol versions and all seven tools covered. + +- [ ] **Step 7: Inspect generated and deployment artifacts** + +Run: + +```bash +git diff --check +git status --short +grep -n '"/messages/incoming"' api/docs/swagger.json +grep -n '_SERVICE_NAME: http-sms-mcp' mcp/cloudbuild.yaml +grep -n 'github.com/modelcontextprotocol/go-sdk v1.7.0' mcp/go.mod tests/go.mod +``` + +Expected: no whitespace errors; generated Swagger, deployment service name, +and pinned SDK versions are present. + +- [ ] **Step 8: Commit CI and final formatting** + +```bash +git add .github/workflows/api.yml api mcp tests +git commit -m "ci(mcp): gate deploys on MCP tests" +``` + +- [ ] **Step 9: Review final history and worktree state** + +Run: + +```bash +git log --oneline main..HEAD +git status --short --branch +``` + +Expected: focused commits for API auth, incoming messages, MCP foundation, +OAuth, API client, tools, server, deployment, integration tests, and CI; the +worktree is clean. diff --git a/docs/superpowers/specs/2026-09-03-httpsms-mcp-server-design.md b/docs/superpowers/specs/2026-09-03-httpsms-mcp-server-design.md new file mode 100644 index 00000000..3b184348 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-httpsms-mcp-server-design.md @@ -0,0 +1,497 @@ +# httpSMS MCP Server Design + +## Summary + +Add a separately deployed Model Context Protocol server for httpSMS at +`https://mcp.httpsms.com/mcp`. The service will use the official +`github.com/modelcontextprotocol/go-sdk`, implement MCP `2026-07-28`, and +temporarily support `2025-11-25` clients during migration. + +The server will expose a curated set of tools for sending SMS messages, reading +phones and message history, creating phone API keys, and rotating the user's +primary API key. It will call the existing httpSMS HTTP API for all product +operations and will not access the httpSMS database directly. + +## Goals + +- Provide a production-ready remote MCP endpoint at `/mcp`. +- Reuse existing Firebase accounts for interactive user login. +- Implement MCP-compliant OAuth 2.1 authorization with resource-specific + access tokens. +- Keep MCP protocol requests stateless and horizontally scalable. +- Call the existing httpSMS API through a scoped service-to-service identity. +- Preserve current API validation, entitlement, rate-limit, and encryption + behavior. +- Add end-to-end integration coverage under the repository's `tests/` module. +- Deploy the service independently through the repository's existing Cloud + Build and Cloud Run pattern. + +## Non-Goals + +- Generating MCP tools automatically from the complete Swagger document. +- Exposing every httpSMS API endpoint in the first release. +- Reading or writing the httpSMS database from the MCP service. +- Decrypting end-to-end encrypted SMS content. +- Replacing Firebase Authentication or existing API-key authentication. +- Removing the CAPTCHA requirement from the general message search endpoint. +- Supporting deprecated HTTP+SSE as a new transport. + +## Repository and Deployment Boundary + +Implementation will be developed on branch `feat/mcp-server` in the +`.worktrees/mcp-server` worktree created from `main`. + +A new top-level `mcp/` Go module will contain: + +- the HTTP server entry point; +- MCP server and tool registration; +- OAuth authorization-server and protected-resource endpoints; +- JWT signing, validation, and JWKS publication; +- Redis-backed authorization and confirmation state; +- a typed httpSMS API client; +- configuration, telemetry, and HTTP middleware; +- unit and component tests; +- a Dockerfile, README, and `cloudbuild.yaml`. + +The MCP service will be deployed as a separate Cloud Run service in `us-east1`. +Cloud Build will build and publish its container and deploy it with the Cloud +Run port supplied through `PORT`. The Cloud Run service will allow public +network access because OAuth and MCP discovery endpoints must be reachable; +application-layer authorization will protect every tool call. + +Mapping `mcp.httpsms.com` to the Cloud Run service and creating its DNS record +are one-time infrastructure steps documented in `mcp/README.md`, not repeated +on every build. + +## Protocol + +The implementation will pin a released official Go SDK version that supports +MCP `2026-07-28`; the currently verified release is `v1.7.0`. + +The primary transport is Streamable HTTP at: + +```text +https://mcp.httpsms.com/mcp +``` + +For `2026-07-28`: + +- the MCP handler will use the stateless protocol core; +- requests will not depend on `initialize`, `initialized`, or + `Mcp-Session-Id`; +- `server/discover` will advertise server identity, supported versions, and + capabilities; +- the SDK will validate per-request protocol metadata; +- `Mcp-Method` and `Mcp-Name` headers will be supported as required by the + transport; +- tool, resource, and discovery responses will use deterministic ordering and + SDK-supported cache hints where applicable. + +The server will also accept `2025-11-25` through the SDK's legacy negotiation +path. It will not enable deprecated HTTP+SSE. Legacy support is a compatibility +window, not a dependency for new features. + +The first release exposes tools only. It does not add prompts, resources, +sampling, roots, or logging capabilities. + +## OAuth and Firebase Identity + +### Token Roles + +Three distinct token types prevent audience confusion: + +1. A Firebase ID token proves the user's identity during browser login. +2. An MCP access token authorizes calls to `mcp.httpsms.com`. +3. A downstream API JWT authorizes the MCP service to call + `api.httpsms.com` for that user. + +A Firebase ID token will not be accepted directly as an MCP access token. Its +audience is the Firebase project, not the MCP protected resource, so direct use +would not satisfy MCP's audience-bound access-token requirements. + +### Discovery Endpoints + +The service will expose: + +- OAuth protected-resource metadata for the MCP endpoint; +- OAuth authorization-server metadata; +- a JWKS endpoint for MCP access-token verification and API delegation; +- an authorization endpoint; +- a token endpoint; +- a legacy dynamic-client-registration endpoint for compatible older clients. + +Client ID Metadata Documents are the preferred client identity mechanism. +Dynamic Client Registration is supported only for compatibility and will be +isolated behind the same redirect-URI and metadata validation rules. + +### Authorization Code Flow + +The authorization flow will: + +1. validate client metadata, redirect URI, requested scopes, state, and PKCE + challenge; +2. create a short-lived authorization transaction in Redis; +3. render a browser page using existing Firebase Authentication providers; +4. verify the resulting Firebase ID token server-side; +5. display the scopes requested by the MCP client; +6. issue a random, one-time, PKCE-bound authorization code; +7. exchange the code at the token endpoint after exact redirect-URI and PKCE + verification. + +MCP access tokens will be short-lived asymmetric JWTs with explicit issuer, +audience, subject, client, scope, issued-at, expiry, and key ID claims. + +Refresh tokens will be high-entropy opaque values. Only hashes will be stored +in Redis, bound to the user, client, granted scopes, and token family. Refresh +rotation will invalidate the previous value. Authorization codes, transaction +records, registration records, and refresh-token records will have explicit +TTLs. + +### Client Metadata Security + +Client metadata retrieval will: + +- require HTTPS outside explicitly configured local test environments; +- reject private, loopback, link-local, and otherwise non-public targets; +- limit response size and request duration; +- validate content type and required metadata fields; +- reject unsafe redirects; +- cache validated metadata for a bounded period. + +These controls prevent the authorization server from becoming an SSRF proxy. + +## Delegated MCP-to-API Authentication + +The MCP server will not store users' primary httpSMS API keys or Firebase +refresh tokens. + +After validating an MCP access token and tool scope, the service will mint a +separate short-lived JWT for the API. The token will contain: + +- issuer identifying the MCP service; +- audience identifying `api.httpsms.com`; +- Firebase UID as the subject; +- only the downstream scopes needed by the current tool; +- short issued-at, not-before, and expiry windows; +- a unique token ID and signing key ID. + +The API will add an MCP delegation authentication middleware. It will: + +- accept only the configured MCP issuer; +- fetch and cache the MCP JWKS; +- validate signature, key ID, audience, issuer, time claims, and scopes; +- load the existing user authentication context from the Firebase UID; +- reject malformed or over-scoped tokens; +- leave existing Firebase bearer and `x-api-key` behavior unchanged. + +The delegated identity is valid only for existing authenticated user routes. It +does not grant phone API-key privileges or administrative access implicitly. + +## OAuth Scopes + +The initial authorization scopes are: + +| Scope | Purpose | +| --- | --- | +| `phones:read` | List the user's registered phones and sending numbers. | +| `messages:read` | List threads, thread messages, and incoming messages. | +| `messages:send` | Queue an SMS message for sending. | +| `phone-api-keys:write` | Create a phone API key. | +| `user-api-key:rotate` | Rotate the user's primary API key. | + +The authorization page will display requested scopes in user-facing language. +Each MCP tool will require its corresponding MCP scope and mint only the +matching downstream API scope. + +## Tool Catalog + +### `list_phones` + +Calls `GET /v1/phones`. + +Inputs include bounded pagination and an optional query. The result contains +the registered phone records needed to select a valid sending number and SIM. + +Required scope: `phones:read`. + +### `send_sms` + +Calls `POST /v1/messages/send`. + +Inputs: + +- `from`; +- `to`; +- `content`; +- optional `sim`; +- optional `request_id`; +- optional `encrypted`; +- optional attachments supported by the API. + +The tool preserves API validation, billing entitlement, scheduling, and +delivery behavior. It will not automatically retry unless the request includes +an idempotency value that makes the retry safe. + +Required scope: `messages:send`. + +### `list_message_threads` + +Calls `GET /v1/message-threads`. + +Inputs: + +- owner phone number; +- optional archive filter; +- optional contact enrichment; +- optional text query; +- bounded `skip` and `limit`. + +Required scope: `messages:read`. + +### `list_thread_messages` + +Calls `GET /v1/messages`. + +Inputs: + +- owner phone number; +- contact phone number; +- optional text query; +- bounded `skip` and `limit`. + +Required scope: `messages:read`. + +### `list_incoming_messages` + +Calls a new API endpoint, `GET /v1/messages/incoming`. + +The existing `GET /v1/messages/search` route requires Cloudflare Turnstile and +is not suitable for server-to-server calls. The new endpoint will use normal +user authentication and the MCP delegated JWT path. It will expose: + +- optional owner filters; +- optional received status filters supported by the use case; +- optional text query; +- bounded pagination; +- supported sort inputs. + +The handler will reuse `MessageService.SearchMessages` while forcing the +message type to `mobile-originated`. It will not weaken or bypass CAPTCHA on +the general search route. Missed calls are excluded from the initial tool. + +Required scope: `messages:read`. + +### `create_phone_api_key` + +Calls `POST /v1/phone-api-keys`. + +Input: the key name. + +The result includes the newly created phone API key as a sensitive, one-time +display value. The value must never be logged or added to telemetry. + +Required scope: `phone-api-keys:write`. + +### `rotate_user_api_key` + +Calls `DELETE /v1/users/{authenticated-user}/api-keys`. + +The user ID is derived from the authenticated subject and is never accepted as +a tool argument. The operation invalidates the current primary API key and +returns its replacement as a sensitive, one-time display value. + +For `2026-07-28`, the tool will use Multi Round-Trip Requests and return +`input_required` before rotation. For legacy clients, the first call will +return a short-lived random confirmation handle stored as a hash in Redis; a +second call must present that handle. Handles are user-, client-, operation-, +and expiry-bound and are consumed once. + +Required scope: `user-api-key:rotate`. + +## API Changes + +The API changes are intentionally narrow: + +1. Add configuration for the MCP delegated JWT issuer, audience, JWKS URL, and + permitted scopes. +2. Add middleware that validates delegated MCP API JWTs and loads the existing + authentication context. +3. Add a request model and validator for incoming-message filters. +4. Add `GET /v1/messages/incoming`. +5. Reuse `MessageService.SearchMessages` with a fixed + `mobile-originated` type. +6. Register the route through the existing dependency-injection container. +7. Add handler, middleware, service-boundary, and integration tests. +8. Regenerate Swagger documentation after changing annotations. + +No raw SQL or new direct database access is required. + +## API Client and Result Mapping + +The `mcp/` module will contain a typed client only for endpoints used by the +tool catalog. The client will: + +- use `context.Context` deadlines and cancellation; +- apply bounded connection, header, and overall request timeouts; +- send the downstream delegated JWT; +- propagate a request ID; +- set explicit content types; +- enforce response-size limits; +- decode the standard httpSMS response envelope and error envelope; +- close response bodies on every path. + +MCP tools will return structured content with stable field names. Upstream +errors remain distinguishable: + +- invalid tool input; +- API field validation; +- unauthenticated or insufficient scope; +- payment or entitlement failure; +- not found; +- rate limited; +- API unavailable or timed out; +- unexpected API response. + +The server will not convert failures into success-shaped empty results. + +## Sensitive Data and Encryption + +The MCP service will not receive or store the user's SMS encryption key. +Encrypted message content will be returned exactly as stored by the API. + +The following values must be redacted from logs, traces, metrics, and error +messages: + +- MCP and downstream bearer tokens; +- Firebase ID tokens; +- authorization codes and refresh tokens; +- PKCE verifiers; +- primary and phone API keys; +- SMS content and attachment payloads. + +Tool results that contain a newly created or rotated key will identify it as a +sensitive one-time value. The values will not be cached by the MCP service. + +## Rate Limiting and Reliability + +Redis-backed limits will apply by authenticated user and tool. They complement, +rather than replace, API-side entitlement and sending limits. + +The MCP service will not retry non-idempotent calls such as SMS sending, phone +API-key creation, or primary API-key rotation by default. Read-only calls may +use a small bounded retry for connection failures and retryable upstream +statuses while respecting request deadlines. + +The service will expose a lightweight unauthenticated health endpoint for +Cloud Run checks. Health will report process readiness without disclosing +dependency details. OAuth and MCP handlers will fail explicitly when Redis, +key material, or the API is unavailable. + +## Observability + +The MCP service will follow the repository's OpenTelemetry and structured +logging conventions. Traces will cover: + +- OAuth authorization and token exchange; +- MCP request parsing and authorization; +- tool execution; +- downstream API calls; +- Redis grant and confirmation operations. + +Allowed telemetry attributes include tool name, protocol version, user ID, +OAuth client ID, scope set, request ID, status, and latency. Sensitive values +listed above are prohibited. + +## Testing + +### MCP Module Tests + +Unit and component tests under `mcp/` will cover: + +- configuration validation; +- Firebase identity-token verification with configurable test issuer/JWKS; +- JWT signing, JWKS publication, rotation, audience, issuer, and time claims; +- PKCE verification and exact redirect-URI matching; +- one-time authorization code use; +- refresh-token rotation and replay rejection; +- scope enforcement; +- CIMD validation and SSRF protections; +- DCR compatibility; +- Redis-backed confirmation handles; +- API request construction and response/error mapping; +- every tool handler; +- secret redaction. + +Tests will use `httptest` and an isolated Redis test dependency already +available through the integration stack where persistence semantics matter. + +### End-to-End Integration Tests + +The repository's `tests/docker-compose.yml` will add the MCP service and the +test identity/JWKS endpoints needed to issue deterministic Firebase-style +identity tokens. Integration tests under `/tests` will cover: + +1. protected-resource and authorization-server metadata; +2. unauthenticated MCP rejection with `WWW-Authenticate`; +3. OAuth authorization-code exchange with PKCE; +4. invalid issuer, audience, redirect URI, code replay, and insufficient scope; +5. MCP `2026-07-28` discovery, tool listing, and tool calls; +6. MCP `2025-11-25` initialization and tool calls; +7. listing phones; +8. listing message threads and thread messages; +9. listing incoming messages through `/v1/messages/incoming`; +10. sending an SMS through MCP and observing delivery through the existing + phone emulator; +11. creating a phone API key; +12. refusing unconfirmed primary API-key rotation; +13. completing confirmed rotation and proving the previous key is invalid. + +The existing integration-test GitHub Actions workflow will build the MCP +container and run these tests with the rest of the stack. + +## Deployment Configuration + +`mcp/cloudbuild.yaml` will: + +1. build the MCP Docker image; +2. publish commit-specific and `latest` tags; +3. deploy the dedicated Cloud Run service in `us-east1`; +4. configure the service port; +5. inject only secret references and non-sensitive environment configuration; +6. leave the service publicly reachable for protocol and OAuth discovery. + +Signing keys, Firebase credentials, Redis credentials, and other secrets will +come from Google Secret Manager or Cloud Run secret references, not committed +files or plain-text Cloud Build substitutions. + +## Rollout + +1. Deploy API support for delegated MCP JWTs and the incoming-message endpoint. +2. Deploy the MCP Cloud Run service with a temporary Cloud Run URL. +3. Run protocol, OAuth, tool, and full integration tests against the deployed + services. +4. Map `mcp.httpsms.com` and publish DNS. +5. Verify discovery metadata uses the final HTTPS issuer and resource URLs. +6. Enable access for initial users while monitoring authentication failures, + tool errors, rate limits, and API latency. +7. Remove `2025-11-25` support in a later change after client usage confirms it + is no longer needed. + +## Acceptance Criteria + +- `https://mcp.httpsms.com/mcp` serves MCP `2026-07-28`. +- `2025-11-25` clients work through the documented compatibility path. +- OAuth uses Firebase for identity and issues MCP audience-bound tokens. +- MCP access tokens cannot be used directly against the API. +- Downstream API JWTs cannot be used against the MCP endpoint. +- Every tool enforces its documented OAuth scope. +- All product operations go through `api.httpsms.com`. +- `/v1/messages/search` remains CAPTCHA-protected. +- `/v1/messages/incoming` returns only authenticated users' mobile-originated + messages. +- API-key rotation requires user confirmation and never accepts a user ID from + tool input. +- Secrets and SMS content do not appear in logs or traces. +- Unit and `/tests` integration suites cover both protocol versions and every + tool. +- Cloud Build deploys the MCP service independently to Cloud Run. diff --git a/mcp/.dockerignore b/mcp/.dockerignore new file mode 100644 index 00000000..468803fd --- /dev/null +++ b/mcp/.dockerignore @@ -0,0 +1,24 @@ +# Keep the Docker build context limited to what the builder stage needs +# (go.mod, go.sum, and Go source). Everything else here either doesn't exist +# in a fresh clone of mcp/ or must never end up inside an image layer. +.git +.gitignore +.dockerignore +Dockerfile +cloudbuild.yaml +README.md +*.md + +# Local build artifacts and editor/OS cruft. +server.exe +*.exe +*.test +tmp/ +.idea/ +.vscode/ + +# Never bake local env files or key material into an image layer. +.env +.env.* +*.pem +*.key diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 00000000..6fbeac7b --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 + +# ---- builder ----------------------------------------------------------- +# Compiles a static (CGO_ENABLED=0) binary from ./cmd/server. Building in a +# throwaway stage keeps the Go toolchain, module cache, and source tree out +# of the final image. +FROM golang:1.25-alpine AS builder + +ARG GIT_COMMIT=dev + +WORKDIR /src + +# Copy go.mod/go.sum first so `go mod download` is cached across builds that +# only change application source, not dependencies. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w -X main.Version=${GIT_COMMIT}" \ + -o /out/mcp-server ./cmd/server + +# ---- runtime ------------------------------------------------------------- +# alpine (not scratch) so ca-certificates and tzdata are trivially +# installable: this service makes outbound HTTPS calls to the httpSMS API, +# Firebase's JWKS endpoint, and OAuth client metadata documents, all of +# which require a validated CA trust store. +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S mcp && adduser -S mcp -G mcp + +# Run as the unprivileged "mcp" user for the entire runtime stage: Cloud Run +# does not require this, but nothing in this service needs root. +USER mcp + +COPY --from=builder /out/mcp-server /usr/local/bin/mcp-server + +# Cloud Run injects PORT at runtime and the server binds to it +# (see mcp/internal/config.Config.Port, default 8080); EXPOSE documents the +# default for local `docker run` and `docker image inspect`. +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/mcp-server"] diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..30d88ccd --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,334 @@ +# httpSMS MCP Server + +A standalone Model Context Protocol (MCP) server for httpSMS, deployed as its +own Cloud Run service and reachable at `https://mcp.httpsms.com/mcp`. It +implements MCP `2026-07-28` (with a temporary `2025-11-25` compatibility +path), authenticates users through existing Firebase accounts via OAuth 2.1, +and calls the httpSMS API (`api.httpsms.com`) on the user's behalf. It does +not access the httpSMS database directly and never stores a user's primary +API key or Firebase refresh token. + +See `docs/superpowers/specs/2026-09-03-httpsms-mcp-server-design.md` at the +repository root for the full design. + +## Environment variables + +`internal/config.Load()` reads all configuration from the environment and +fails fast, naming every missing/invalid setting, if anything required is +absent. In `production` (`ENV=production`), every URL-valued setting must use +`https`. + +### Required (no default) + +| Variable | Purpose | +| --- | --- | +| `MCP_BASE_URL` | This service's own public base URL, e.g. `https://mcp.httpsms.com`. Used as the JWT issuer and to derive `MCP_AUDIENCE`. | +| `HTTPSMS_API_URL` | The httpSMS API base URL, e.g. `https://api.httpsms.com`. Used to derive `API_AUDIENCE`. | +| `REDIS_URL` | Connection string for OAuth/rate-limit/confirmation state. **Must** be a standalone Redis instance (see [Redis constraint](#redis-constraint-standalone-only)). | +| `FIREBASE_PROJECT_ID` | Firebase project used to verify ID tokens during login. | +| `FIREBASE_API_KEY` | Firebase Web API key used by the hosted login page's client-side SDK. | +| `FIREBASE_AUTH_DOMAIN` | Firebase Auth domain used by the hosted login page's client-side SDK. | +| `MCP_SIGNING_KEY_ID` | The `kid` this instance signs with and publishes at `/.well-known/jwks.json`. | +| `MCP_SIGNING_PRIVATE_KEY` **or** `MCP_SIGNING_PRIVATE_KEY_FILE` | PEM-encoded RSA private key (>=2048 bits, PKCS#1) used to sign MCP access tokens and API delegation tokens. Exactly one of these two must be set. | + +### Optional (safe defaults) + +| Variable | Default | Purpose | +| --- | --- | --- | +| `ENV` | `local` | Set to `production` in Cloud Run to enforce HTTPS on every configured URL. | +| `PORT` | `8080` | TCP port the HTTP server listens on. Cloud Run supplies this automatically; do not set it manually in Cloud Run. | +| `FIREBASE_CERTS_URL` | Google's public Firebase JWKS endpoint | Override only for local/test identity providers. | +| `MCP_AUDIENCE` | `/mcp` | Audience MCP access tokens are bound to. | +| `API_AUDIENCE` | `` | Audience API delegation tokens are bound to. **Must match** the API's `MCP_AUTH_AUDIENCE` (see [Coordinating with the API](#coordinating-with-the-api)). | +| `MCP_ACCESS_TOKEN_TTL` | `15m` | MCP access token lifetime. | +| `API_DELEGATION_TOKEN_TTL` | `2m` | Downstream API delegation token lifetime. | +| `AUTHORIZATION_CODE_TTL` | `2m` | OAuth authorization code lifetime. | +| `REFRESH_TOKEN_TTL` | `720h` (30 days) | OAuth refresh token lifetime. | +| `CONFIRMATION_TTL` | `5m` | Primary API-key-rotation confirmation handle lifetime. | +| `HTTP_TIMEOUT` | `10s` | Bounds every outbound call to the httpSMS API and to OAuth client metadata documents. | +| `READ_TOOLS_PER_MINUTE` | `120` | Per-user rate limit for read-only tools. | +| `SEND_TOOLS_PER_MINUTE` | `30` | Per-user rate limit for `send_sms`. | +| `KEY_CREATES_PER_HOUR` | `10` | Per-user rate limit for `create_phone_api_key`. | +| `KEY_ROTATIONS_PER_HOUR` | `3` | Per-user rate limit for `rotate_user_api_key`. | + +None of the values above have a safe committed default for secrets: `.env` +files, `*.pem`/`*.key` files, and anything matching `mcp/.dockerignore` must +never be committed. See [Secrets and redaction](#secrets-and-redaction). + +## Local startup + +```bash +cd mcp +go run ./cmd/server +``` + +Required variables must be exported first, e.g. via a local (not committed) +`.env` loaded by your shell, or `export`. A quick way to generate a local +throwaway signing key: + +```bash +mkdir -p .local && openssl genrsa -out .local/mcp-signing-key.pem 2048 # .local/ is gitignored; never commit +export MCP_SIGNING_PRIVATE_KEY_FILE=.local/mcp-signing-key.pem +export MCP_SIGNING_KEY_ID=local-dev-1 +``` + +A local standalone Redis (`redis-server`, or the one already provided by +`tests/docker-compose.yml`) satisfies `REDIS_URL`. + +## Tests and CI + +```bash +cd mcp +go test -race ./... +go build ./cmd/server +``` + +The unit suite is hermetic: Redis is faked with `miniredis`, and every HTTP +dependency (the httpSMS API, Firebase certificates, Client ID Metadata +Documents) is served by an in-process `httptest` server. No Docker stack and no +network access are required. + +Both commands also run in the `test` job of +[`.github/workflows/api.yml`](../.github/workflows/api.yml), ahead of the +full-stack integration suite in [`tests/`](../tests/README.md), and the +`deploy` job is gated on that job. A failing MCP unit test, a broken +`cmd/server` build, an unhealthy MCP container, or a failing MCP integration +test therefore blocks the deploy. + +## Health and MCP URLs + +| Path | Purpose | +| --- | --- | +| `GET /healthz`, `GET /health` | Unauthenticated liveness/readiness check used by Cloud Run. Reports process readiness only; it does not probe Redis or the API, by design (see design doc, "Rate Limiting and Reliability"). | +| `POST /mcp` | The MCP Streamable HTTP endpoint. Requires a valid MCP access token. | +| `GET /.well-known/oauth-protected-resource`, `GET /.well-known/oauth-protected-resource/mcp` | OAuth protected-resource metadata. | +| `GET /.well-known/oauth-authorization-server` | OAuth authorization-server metadata. | +| `GET /.well-known/jwks.json` | This service's public signing key(s), consumed by MCP clients and by the httpSMS API's delegated-auth middleware. | +| `POST /oauth/register` | Legacy Dynamic Client Registration (compatibility only; Client ID Metadata Documents are preferred). | +| `GET /oauth/authorize`, `POST /oauth/firebase/complete`, `POST /oauth/token` | OAuth authorization-code + PKCE flow. | + +Cloud Run supplies the listen port through `PORT`; the container's `EXPOSE +8080` documents the default used both locally and by Cloud Build's +`--port=8080` deploy flag. These must stay in sync: if `PORT` is ever +overridden in Cloud Run, `--port` in `cloudbuild.yaml` must change to match, +or the health check will fail and the revision will never become ready. + +## Cloud Build invocation + +`cloudbuild.yaml` mirrors `api/cloudbuild.yaml`: it builds `mcp/Dockerfile` +with Kaniko, publishes `:$SHORT_SHA` and `:latest` tags to +`us.gcr.io/$PROJECT_ID/http-sms-mcp`, and deploys the dedicated Cloud Run +service `http-sms-mcp` in `us-east1`. + +Trigger manually with: + +```bash +gcloud builds submit --config=mcp/cloudbuild.yaml . +``` + +or wire it to a Cloud Build trigger the same way `api/cloudbuild.yaml` is +wired, scoped to changes under `mcp/`. + +Non-sensitive configuration (`ENV`, `MCP_BASE_URL`, `HTTPSMS_API_URL`, +`FIREBASE_PROJECT_ID`, `FIREBASE_AUTH_DOMAIN`) is passed with +`--set-env-vars` from `cloudbuild.yaml` substitutions. **Secrets are never +placed in `cloudbuild.yaml` or Cloud Build substitutions.** They are +referenced from Google Secret Manager with `--set-secrets`: + +| Cloud Run env var | Secret Manager secret | +| --- | --- | +| `MCP_SIGNING_PRIVATE_KEY` | `mcp-signing-private-key` | +| `MCP_SIGNING_KEY_ID` | `mcp-signing-key-id` | +| `REDIS_URL` | `mcp-redis-url` | +| `FIREBASE_API_KEY` | `mcp-firebase-api-key` | + +Create/update these once with, e.g.: + +```bash +printf '%s' "$PRIVATE_KEY_PEM" | gcloud secrets create mcp-signing-private-key --data-file=- +printf '%s' "prod-mcp-key-1" | gcloud secrets create mcp-signing-key-id --data-file=- +printf '%s' "$REDIS_URL" | gcloud secrets create mcp-redis-url --data-file=- +printf '%s' "$FIREBASE_API_KEY" | gcloud secrets create mcp-firebase-api-key --data-file=- +``` + +The Cloud Run service's runtime service account needs +`roles/secretmanager.secretAccessor` on each secret. + +## One-time custom-domain mapping + +Ensure the Cloud project has verified ownership of `httpsms.com` in Google +Search Console before creating its first domain mapping. Existing +`app.httpsms.com` or `api.httpsms.com` mappings usually mean this prerequisite +is already satisfied. + +`mcp.httpsms.com` is mapped once, not on every deploy: + +```bash +gcloud run domain-mappings create \ + --service=http-sms-mcp \ + --domain=mcp.httpsms.com \ + --region=us-east1 +``` + +Then add the DNS records the command prints (typically a `CNAME` to +`ghs.googlehosted.com` or the A/AAAA records Cloud Run reports) at the DNS +provider for `httpsms.com`. Verify with: + +```bash +gcloud run domain-mappings describe \ + --domain=mcp.httpsms.com --region=us-east1 \ + --format="value(status.conditions)" +dig +short mcp.httpsms.com +curl -sf https://mcp.httpsms.com/healthz +``` + +`MCP_BASE_URL` must already be `https://mcp.httpsms.com` (see +`cloudbuild.yaml` substitutions) **before** mapping the domain: this service +signs every JWT with that value as the issuer, and OAuth/MCP discovery +documents publish it as the resource/issuer URL. Changing `MCP_BASE_URL` +after clients have discovered the old value invalidates every previously +issued MCP access and refresh token (see rotation notes below) and +re-triggers client-side discovery. + +## Firebase authorized domains and providers + +The hosted login page (served from this service, not from `web/`) uses the +Firebase Web SDK client-side with `FIREBASE_API_KEY` / `FIREBASE_AUTH_DOMAIN` +/ `FIREBASE_PROJECT_ID`. Firebase only allows sign-in from **authorized +domains** configured per-project in the Firebase console +(*Authentication → Settings → Authorized domains*): + +- `mcp.httpsms.com` must be added there before the first real login, or every + provider sign-in attempt served from `mcp.httpsms.com` will fail + client-side with `auth/unauthorized-domain`. +- This is a **separate, one-time console step**, independent of DNS mapping + and independent of `web/`'s own authorized-domain entry (`app.httpsms.com` + or equivalent) — adding one does not add the other. +- Enable the same identity providers already enabled for `web/` (Email/ + Password, Google, GitHub) for this project; this service reuses the + existing Firebase project (`FIREBASE_PROJECT_ID=httpsms-86c51`), it does + not create a second one. + +## Coordinating with the API + +This service and `api/` share exactly one trust relationship: the API trusts +this service's JWKS to validate delegated API JWTs (see design doc, +"Delegated MCP-to-API Authentication"). The API side needs three variables +set (in `api`'s own deployment, not here): + +| API variable | Must equal | +| --- | --- | +| `MCP_AUTH_ISSUER` | This service's `MCP_BASE_URL` (with no trailing slash). | +| `MCP_AUTH_AUDIENCE` | This service's `API_AUDIENCE` (defaults to `HTTPSMS_API_URL`). | +| `MCP_AUTH_JWKS_URL` | `https://mcp.httpsms.com/.well-known/jwks.json`. | + +The API's JWKS cache (`api/pkg/auth/mcp_jwks.go`) refreshes automatically, +at most once per request, whenever it sees a `kid` it doesn't already have +cached, and otherwise every 15 minutes. **No manual API restart or cache +flush is required after this service rotates its signing key** — the API +self-heals on the next delegated request that carries the new `kid`. + +## Signing-key rotation + +This service holds exactly **one** active signing key/`kid` at a time (see +`internal/auth.KeySet`); it does not publish overlapping old+new keys in its +JWKS. Rotation therefore works like this, in order: + +1. Generate a new RSA private key (>=2048 bits) and choose a **new, never + reused** `kid` (e.g. append an incrementing suffix: `prod-mcp-key-2`). +2. Update the `mcp-signing-private-key` and `mcp-signing-key-id` secrets in + Secret Manager with the new values (add new versions; do not delete the + old versions until the rollout below is confirmed healthy, so you can roll + back). +3. Redeploy this service (`gcloud builds submit --config=mcp/cloudbuild.yaml + .`, or `gcloud run services update http-sms-mcp + --update-secrets=...:latest`). The new revision immediately signs with, + and publishes, only the new key. +4. Confirm `/.well-known/jwks.json` now serves the new `kid` and that the API + picks it up (its cache self-heals; see above). Watch API logs for + delegated-auth failures during the rollout window. +5. **Expected, safe side effect:** every previously issued MCP access token + (`MCP_ACCESS_TOKEN_TTL`, default 15 minutes) can no longer be verified + once the old key is gone from this service's in-memory `KeySet`, because + verification here has no fallback to an old key. Connected MCP clients see + a `401` and must use their (unaffected) refresh token to obtain a new + access token signed with the new key — refresh tokens are opaque, hashed, + Redis-stored values unrelated to the signing key, so they are **not** + invalidated by rotation. +6. Once you've confirmed the new revision is healthy and no client-visible + regressions appear, destroy the old secret versions (`gcloud secrets + versions destroy`) if you want to fully retire the old key material. + +Rotate on a schedule and immediately, out of band, if the private key or its +`.pem`/`.env` file is ever suspected compromised. + +## Redis constraint: standalone only + +`REDIS_URL` **must** point at a standalone Redis instance (e.g. Memorystore +Basic tier, or a single `redis-server`) — never a Redis Cluster or Ring +endpoint. `internal/oauth.NewRedisStore` **panics at startup** if given a +cluster/ring client, because refresh-token rotation runs a Lua script that +touches multiple related keys and cannot execute across cluster hash slots. +This is enforced in code, not just documentation, so a misconfigured cluster +URL fails fast at boot instead of silently corrupting refresh-token state. + +## Removing `2025-11-25` compatibility + +This service currently negotiates both MCP `2026-07-28` (primary) and +`2025-11-25` (temporary compatibility, per the design doc's rollout step 7). +When client telemetry confirms `2025-11-25` initialization requests have +stopped: + +1. Remove the legacy negotiation path and its tests in + `internal/server` (search for `2025-11-25`). +2. Update the protected-resource/authorization-server metadata and any + client-facing documentation that still mentions the legacy version. +3. Deploy and confirm `TestLegacyInitializeNegotiates20251125`-equivalent + coverage has been removed, not just skipped. + +This is a follow-up code change, not a configuration flag — there is no +environment variable that disables `2025-11-25` today. + +## Deployment and rollback + +Deploy: + +```bash +gcloud builds submit --config=mcp/cloudbuild.yaml . +``` + +Each deploy publishes both `:$SHORT_SHA` and `:latest` image tags. To roll +back to a previously known-good commit's image without rebuilding: + +```bash +gcloud run deploy http-sms-mcp \ + --image=us.gcr.io/$PROJECT_ID/http-sms-mcp: \ + --region=us-east1 --platform=managed --allow-unauthenticated --port=8080 +``` + +Cloud Run also keeps prior revisions: you can shift traffic back instantly +without a new image, without touching secrets, via: + +```bash +gcloud run services update-traffic http-sms-mcp \ + --region=us-east1 --to-revisions==100 +``` + +Prefer `update-traffic` for a fast rollback (no rebuild, seconds) and the +`--image=` redeploy when you also need to restore a previous +secret binding. + +## Secrets and redaction + +- Never commit `.env` files, `*.pem`/`*.key` files, or literal secret values + into `cloudbuild.yaml`, `Dockerfile`, or any other tracked file (enforced + by `mcp/.dockerignore` for image layers; enforce it for source control with + your usual pre-commit/secret-scanning tooling). +- Secrets are injected only via Cloud Run `--set-secrets` from Google Secret + Manager, per the [Cloud Build invocation](#cloud-build-invocation) table. +- The following must never appear in logs, traces, metrics, or error + messages: MCP and downstream bearer tokens, Firebase ID tokens, + authorization codes and refresh tokens, PKCE verifiers, primary and phone + API keys, and SMS content/attachment payloads. Tool results that return a + newly created or rotated key mark it as a sensitive, one-time value and + this service never caches it. diff --git a/mcp/cloudbuild.yaml b/mcp/cloudbuild.yaml new file mode 100644 index 00000000..bbd92914 --- /dev/null +++ b/mcp/cloudbuild.yaml @@ -0,0 +1,34 @@ +steps: + - name: "gcr.io/kaniko-project/executor:v1.23.2" + id: "Build image and push" + dir: "mcp" + args: + - "--destination=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:$SHORT_SHA" + - "--destination=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:latest" + - "--dockerfile=Dockerfile" + - "--context=." + - "--build-arg=GIT_COMMIT=$SHORT_SHA" + - "--snapshot-mode=time" + + - id: "Deploy to cloud run" + name: "gcr.io/cloud-builders/gcloud" + entrypoint: "bash" + args: + - "-c" + - | + gcloud run deploy $_SERVICE_NAME \ + --image=us.gcr.io/$PROJECT_ID/$_SERVICE_NAME:$SHORT_SHA \ + --region=$_REGION --platform managed --allow-unauthenticated \ + --port=8080 \ + --set-env-vars="ENV=production,MCP_BASE_URL=$_MCP_BASE_URL,HTTPSMS_API_URL=$_HTTPSMS_API_URL,FIREBASE_PROJECT_ID=$_FIREBASE_PROJECT_ID,FIREBASE_AUTH_DOMAIN=$_FIREBASE_AUTH_DOMAIN" \ + --set-secrets="MCP_SIGNING_PRIVATE_KEY=mcp-signing-private-key:latest,MCP_SIGNING_KEY_ID=mcp-signing-key-id:latest,REDIS_URL=mcp-redis-url:latest,FIREBASE_API_KEY=mcp-firebase-api-key:latest" +options: + substitutionOption: ALLOW_LOOSE + +substitutions: + _SERVICE_NAME: http-sms-mcp + _REGION: us-east1 + _MCP_BASE_URL: https://mcp.httpsms.com + _HTTPSMS_API_URL: https://api.httpsms.com + _FIREBASE_PROJECT_ID: httpsms-86c51 + _FIREBASE_AUTH_DOMAIN: auth.httpsms.com diff --git a/mcp/cmd/server/main.go b/mcp/cmd/server/main.go new file mode 100644 index 00000000..8c572495 --- /dev/null +++ b/mcp/cmd/server/main.go @@ -0,0 +1,245 @@ +// Command mcp-server runs the httpSMS MCP service: it loads configuration, +// assembles every dependency (signing keys, Firebase identity +// verification, Redis-backed OAuth/rate-limit state, the typed httpSMS API +// client, and the MCP tool catalog), builds the HTTP surface (see the +// server package), and serves it until asked to shut down. +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/config" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" + "github.com/NdoleStudio/httpsms/mcp/internal/observability" + "github.com/NdoleStudio/httpsms/mcp/internal/server" +) + +// serviceName identifies this service in structured logs and traces. +const serviceName = "httpsms-mcp-server" + +// Version is this service's build version, overridden at build time with +// -ldflags "-X main.Version=...". It is published in the MCP +// Implementation and as the observability service.version. +var Version = "dev" + +// shutdownTimeout bounds how long graceful shutdown (draining in-flight +// HTTP requests, then closing Redis and telemetry) may take before this +// process exits regardless. +const shutdownTimeout = 10 * time.Second + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := run(ctx, Version); err != nil { + log.Error().Err(err).Msg("httpSMS MCP server exited with an error") + stop() + os.Exit(1) + } +} + +// run loads configuration, assembles every dependency, serves HTTP until +// ctx is cancelled or the listener fails, and then shuts everything down. +// +// It returns an error instead of exiting the process itself, so every +// failure path -- including a listener that never starts (a port already in +// use, an invalid PORT value, a missing bind permission), which must be a +// non-zero process exit rather than a log line followed by a "clean" +// shutdown -- is reachable from a test. +func run(ctx context.Context, version string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + + handler, shutdown, err := build(ctx, cfg, version) + if err != nil { + return fmt.Errorf("build MCP server: %w", err) + } + + return serve(ctx, ":"+cfg.Port, handler, shutdown) +} + +// serve runs handler on addr until ctx is cancelled or ListenAndServe +// fails, then drains in-flight requests and calls shutdown. +// +// It returns a non-nil error when the listener failed for any reason other +// than a graceful http.ErrServerClosed, or when draining/shutdown itself +// failed; a shutdown triggered by ctx being cancelled (SIGINT/SIGTERM, the +// normal Cloud Run path) returns nil. +func serve(ctx context.Context, addr string, handler http.Handler, shutdown func(context.Context) error) error { + httpServer := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + serveErr := make(chan error, 1) + go func() { + err := httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + serveErr <- err + }() + + var errs []error + select { + case <-ctx.Done(): + log.Info().Msg("received shutdown signal") + case err := <-serveErr: + if err != nil { + errs = append(errs, fmt.Errorf("serve HTTP on %q: %w", addr, err)) + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + errs = append(errs, fmt.Errorf("shut down HTTP server: %w", err)) + } + if err := shutdown(shutdownCtx); err != nil { + errs = append(errs, fmt.Errorf("shut down MCP server dependencies: %w", err)) + } + + return errors.Join(errs...) +} + +// build loads and wires every dependency the httpSMS MCP service needs and +// returns the assembled HTTP handler, a shutdown function that releases +// every resource build itself opened (the Redis client and the +// observability tracer provider), and any assembly error. +// +// build never partially starts serving traffic: it either returns a fully +// wired handler and a working shutdown func, or a non-nil error and a nil +// handler. Callers must still call the returned shutdown func exactly when +// build itself returns a non-nil error only if shutdown is non-nil; on +// error, build closes anything it already opened itself and returns a nil +// shutdown func. +func build(ctx context.Context, cfg config.Config, version string) (http.Handler, func(context.Context) error, error) { + logger, shutdownObservability, err := observability.New(ctx, serviceName, version) + if err != nil { + return nil, nil, fmt.Errorf("build observability: %w", err) + } + + redisOptions, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + _ = shutdownObservability(ctx) + return nil, nil, fmt.Errorf("parse REDIS_URL: %w", err) + } + // redis.NewClient always returns a standalone client, never a cluster + // or ring client: RedisStore's cross-slot refresh-token rotation + // script and this service's rate limiter both depend on that (see + // oauth.NewRedisStore's doc comment). + redisClient := redis.NewClient(redisOptions) + + issuer := strings.TrimRight(cfg.BaseURL.String(), "/") + + keys, err := auth.NewKeySet(cfg.SigningPrivateKeyPEM, cfg.SigningKeyID) + if err != nil { + _ = redisClient.Close() + _ = shutdownObservability(ctx) + return nil, nil, fmt.Errorf("build signing key set: %w", err) + } + if err := keys.Configure(issuer, cfg.MCPAudience, cfg.APIAudience); err != nil { + _ = redisClient.Close() + _ = shutdownObservability(ctx) + return nil, nil, fmt.Errorf("configure signing key set: %w", err) + } + + firebaseVerifier, err := auth.NewFirebaseVerifier(cfg.FirebaseProjectID, cfg.FirebaseCertsURL.String(), nil, 0, 0) + if err != nil { + _ = redisClient.Close() + _ = shutdownObservability(ctx) + return nil, nil, fmt.Errorf("build Firebase verifier: %w", err) + } + + store := oauth.NewRedisStore(redisClient) + + // The Client ID Metadata Document (CIMD) fetch transport must never + // route through a configured HTTP(S)_PROXY: this service pins the + // fetch to a validated public IP address (see oauth.ClientResolver) + // specifically to defeat DNS-rebinding SSRF, and a proxy would + // reintroduce a second, unvalidated hop between that validation and + // the actual connection. + cimdTransport := &http.Transport{Proxy: nil} + cimdHTTPClient := &http.Client{Timeout: cfg.HTTPTimeout, Transport: cimdTransport} + resolver := oauth.NewClientResolver(cimdHTTPClient, store) + + oauthServerConfig := oauth.ServerConfig{ + Issuer: issuer, + Resource: cfg.MCPAudience, + FirebaseAPIKey: cfg.FirebaseAPIKey, + FirebaseAuthDomain: cfg.FirebaseAuthDomain, + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + + oauthServer, err := oauth.NewServer(store, resolver, keys, firebaseVerifier, oauthServerConfig) + if err != nil { + _ = redisClient.Close() + _ = shutdownObservability(ctx) + return nil, nil, fmt.Errorf("build OAuth server: %w", err) + } + + // cfg.HTTPTimeout bounds every outbound call this service makes, + // including calls to the httpSMS API: without passing it here the API + // client would silently keep its own built-in default and the + // configured HTTP_TIMEOUT would apply only to CIMD fetches. + apiClient := httpsms.NewClient(cfg.APIURL.String(), httpsms.WithTimeout(cfg.HTTPTimeout)) + + handler, err := server.New(cfg, server.Dependencies{ + Logger: logger, + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: oauthServerConfig, + OAuthStore: store, + APIClient: apiClient, + RedisClient: redisClient, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: server.Limits{ + ReadPerMinute: cfg.ReadToolsPerMinute, + SendPerMinute: cfg.SendToolsPerMinute, + KeyCreatesPerHour: cfg.KeyCreatesPerHour, + KeyRotationsPerHour: cfg.KeyRotationsPerHour, + }, + Version: version, + }) + if err != nil { + _ = redisClient.Close() + _ = shutdownObservability(ctx) + return nil, nil, fmt.Errorf("assemble HTTP server: %w", err) + } + + shutdown := func(shutdownCtx context.Context) error { + var errs []error + if err := redisClient.Close(); err != nil { + errs = append(errs, fmt.Errorf("close Redis client: %w", err)) + } + if err := shutdownObservability(shutdownCtx); err != nil { + errs = append(errs, fmt.Errorf("shut down observability: %w", err)) + } + return errors.Join(errs...) + } + + return handler, shutdown, nil +} diff --git a/mcp/cmd/server/main_test.go b/mcp/cmd/server/main_test.go new file mode 100644 index 00000000..bd85395b --- /dev/null +++ b/mcp/cmd/server/main_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/config" +) + +// generateTestSigningKeyPEM returns a fresh PKCS#1-encoded RSA private key, +// suitable for MCP_SIGNING_PRIVATE_KEY in tests. +func generateTestSigningKeyPEM(t *testing.T) string { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) +} + +// setTestEnv sets every environment variable config.Load requires, +// pointing REDIS_URL at mr, and returns a cleanup func that restores every +// variable this func touched to its previous value. +func setTestEnv(t *testing.T, mr *miniredis.Miniredis) { + t.Helper() + + env := map[string]string{ + "ENV": "test", + "MCP_BASE_URL": "https://mcp.httpsms.test", + "HTTPSMS_API_URL": "https://api.httpsms.test", + "REDIS_URL": "redis://" + mr.Addr(), + "FIREBASE_PROJECT_ID": "httpsms-test", + "FIREBASE_API_KEY": "test-firebase-api-key", + "FIREBASE_AUTH_DOMAIN": "httpsms-test.firebaseapp.com", + "MCP_SIGNING_PRIVATE_KEY": generateTestSigningKeyPEM(t), + "MCP_SIGNING_KEY_ID": "test-key-1", + } + + for key, value := range env { + t.Setenv(key, value) + } + _ = os.Unsetenv("MCP_SIGNING_PRIVATE_KEY_FILE") +} + +// TestBuildAssemblesAWorkingHandler is this package's local smoke test +// (brief Step 6): it loads configuration from environment variables set to +// point at an in-process miniredis instance, calls build, and exercises the +// resulting handler's health, metadata, and bearer-auth-rejection routes +// over real HTTP. +func TestBuildAssemblesAWorkingHandler(t *testing.T) { + mr := miniredis.RunT(t) + setTestEnv(t, mr) + + cfg, err := config.Load() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + handler, shutdown, err := build(ctx, cfg, "test") + require.NoError(t, err) + require.NotNil(t, handler) + require.NotNil(t, shutdown) + defer func() { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + require.NoError(t, shutdown(shutdownCtx)) + }() + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + resp, err := http.Get(httpServer.URL + "/healthz") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + resp2, err := http.Get(httpServer.URL + "/.well-known/oauth-protected-resource") + require.NoError(t, err) + defer resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) + + req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/mcp", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp3, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp3.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp3.StatusCode) +} + +// TestBuildFailsFastOnInvalidConfiguration exercises build's own error +// path: an unparseable REDIS_URL is a build-time (not request-time) error. +func TestBuildFailsFastOnInvalidConfiguration(t *testing.T) { + mr := miniredis.RunT(t) + setTestEnv(t, mr) + t.Setenv("REDIS_URL", "not-a-valid-redis-url") + + cfg, err := config.Load() + require.NoError(t, err) + + handler, shutdown, err := build(context.Background(), cfg, "test") + require.Error(t, err) + require.Nil(t, handler) + require.Nil(t, shutdown) +} + +// TestServeReturnsAnErrorWhenTheListenerFails asserts a listener that can +// never start (here: an unparseable address, standing in for a port already +// in use or a PORT value Cloud Run could not bind) surfaces as a non-nil +// error from serve -- which is what makes main exit non-zero instead of +// logging and "shutting down" as if it had served successfully. +func TestServeReturnsAnErrorWhenTheListenerFails(t *testing.T) { + shutdownCalls := 0 + shutdown := func(context.Context) error { + shutdownCalls++ + return nil + } + + err := serve(context.Background(), "not-an-address", http.NewServeMux(), shutdown) + + require.Error(t, err) + require.Contains(t, err.Error(), "not-an-address") + // Dependencies opened before serving must still be released. + require.Equal(t, 1, shutdownCalls) +} + +// TestServeReturnsNilOnGracefulShutdown asserts the normal Cloud Run path +// -- SIGTERM cancels the context -- drains and returns nil, so main exits +// zero. +func TestServeReturnsNilOnGracefulShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + shutdownCalls := 0 + shutdown := func(context.Context) error { + shutdownCalls++ + return nil + } + + done := make(chan error, 1) + go func() { done <- serve(ctx, "127.0.0.1:0", mux, shutdown) }() + + // Give the listener a moment to start, then ask for shutdown. + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(15 * time.Second): + t.Fatal("serve did not return after its context was cancelled") + } + + require.Equal(t, 1, shutdownCalls) +} + +// TestServeReportsShutdownFailures asserts a dependency that fails to close +// is also a non-zero exit, not a silent log line. +func TestServeReportsShutdownFailures(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := serve(ctx, "127.0.0.1:0", http.NewServeMux(), func(context.Context) error { + return errors.New("redis close failed") + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "redis close failed") +} + +// TestRunFailsWhenConfigurationIsInvalid asserts run surfaces a +// configuration error as an error return (a non-zero exit) rather than +// exiting deep inside the call stack. +func TestRunFailsWhenConfigurationIsInvalid(t *testing.T) { + t.Setenv("MCP_BASE_URL", "") + t.Setenv("REDIS_URL", "") + + err := run(context.Background(), "test") + + require.Error(t, err) + require.Contains(t, err.Error(), "load configuration") +} diff --git a/mcp/go.mod b/mcp/go.mod new file mode 100644 index 00000000..bf1657ba --- /dev/null +++ b/mcp/go.mod @@ -0,0 +1,50 @@ +module github.com/NdoleStudio/httpsms/mcp + +go 1.25.0 + +require ( + github.com/alicebob/miniredis/v2 v2.35.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/jsonschema-go v0.4.3 + github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.7.0 + github.com/redis/go-redis/v9 v9.21.0 + github.com/rs/zerolog v1.35.1 + github.com/stretchr/testify v1.12.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/mcp/go.sum b/mcp/go.sum new file mode 100644 index 00000000..5aa24a4a --- /dev/null +++ b/mcp/go.sum @@ -0,0 +1,104 @@ +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/mcp/internal/auth/claims.go b/mcp/internal/auth/claims.go new file mode 100644 index 00000000..af26f113 --- /dev/null +++ b/mcp/internal/auth/claims.go @@ -0,0 +1,55 @@ +// Package auth loads the RSA signing key material for the hosted MCP +// service and mints/validates the JWTs the service issues: MCP access +// tokens (audience-bound to the MCP endpoint) and downstream API +// delegation tokens (audience-bound to api.httpsms.com, scoped to a +// single HTTP method and path). +package auth + +import "github.com/golang-jwt/jwt/v5" + +// Principal identifies the authenticated Firebase user a token is being +// minted for. It never carries a raw Firebase ID token, API key, or any +// other secret material. +type Principal struct { + // UserID is the Firebase UID. It is always used as the JWT subject. + UserID string + + // Email is the user's Firebase account email. It is included in minted + // tokens for observability only; authorization decisions never depend + // on it. + Email string +} + +// AccessClaims are the claims embedded in every JWT minted by this service, +// whether an MCP access token or a downstream API delegation token. +// +// MCP access tokens carry ClientID and Scopes but omit Method/Path (they +// authorize calling the MCP endpoint generally, not a single downstream API +// operation). API delegation tokens carry Method, Path, and Scopes bound to +// exactly one downstream API operation; ClientID is not applicable and is +// left empty. +// +// The JSON field names for Scopes, Method, and Path (`scopes`, `http_method`, +// `http_path`) are a wire contract with the httpSMS API's delegated MCP +// token verifier and must not change independently of it. +type AccessClaims struct { + // ClientID is the OAuth client this MCP access token was issued to. It + // is empty for API delegation tokens. + ClientID string `json:"client_id,omitempty"` + + // Email is the Firebase account email of the token's subject. + Email string `json:"email,omitempty"` + + // Scopes are the scopes granted to this token. + Scopes []string `json:"scopes"` + + // Method is the HTTP method an API delegation token is bound to. It is + // empty for MCP access tokens. + Method string `json:"http_method,omitempty"` + + // Path is the HTTP request path an API delegation token is bound to. It + // is empty for MCP access tokens. + Path string `json:"http_path,omitempty"` + + jwt.RegisteredClaims +} diff --git a/mcp/internal/auth/firebase.go b/mcp/internal/auth/firebase.go new file mode 100644 index 00000000..c2e7de56 --- /dev/null +++ b/mcp/internal/auth/firebase.go @@ -0,0 +1,371 @@ +package auth + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Bounds applied to every fetch of Google's Firebase certificate endpoint. +const ( + firebaseCertsHTTPTimeout = 2 * time.Second + firebaseCertsMaxResponseBytes = 1 << 20 // 1 MiB + firebaseCertsDefaultCacheTTL = time.Hour + + // firebaseCertsDefaultMinRefreshInterval is the default minimum delay + // between two outbound fetches of the certificate endpoint. It bounds + // refresh amplification: without it, a flood of tokens carrying random + // unknown "kid" headers would cause one outbound fetch per request. + // Google publishes a rotated signing key well before it starts signing + // with it, so a legitimate rotation is still picked up -- at worst one + // interval late. + firebaseCertsDefaultMinRefreshInterval = time.Minute + + // firebaseClockSkewLeeway is the tolerance applied to the "iat" and + // "auth_time" claims, which are stamped by Google's clock and compared + // against ours. + firebaseClockSkewLeeway = time.Minute +) + +// ErrInvalidIdentityToken is returned by IdentityVerifier.Verify for any +// identity token that does not parse, does not verify against a known +// signing certificate, or fails an issuer/audience/expiry/subject check. It +// deliberately does not distinguish the failure reason, so a caller can +// never learn from the error alone which specific check failed. +var ErrInvalidIdentityToken = errors.New("auth: invalid identity token") + +// errFirebaseCertsRefreshThrottled reports that a certificate refresh was +// skipped because the minimum refresh interval has not elapsed yet. +var errFirebaseCertsRefreshThrottled = errors.New("auth: Firebase certificate refresh is rate limited") + +// IdentityVerifier verifies a raw bearer identity token -- a Firebase ID +// token presented during the browser login step of the OAuth authorization +// flow -- and returns the Principal it identifies. +type IdentityVerifier interface { + Verify(ctx context.Context, raw string) (Principal, error) +} + +// firebaseClaims are the claims read from a Firebase ID token, beyond the +// registered claims already validated by the jwt.ParseWithClaims options in +// FirebaseVerifier.Verify. +type firebaseClaims struct { + Email string `json:"email,omitempty"` + UserID string `json:"user_id,omitempty"` + + // AuthTime is the Firebase "auth_time" claim: when the user actually + // authenticated. Firebase's own ID-token verification contract requires + // it to be present and in the past. + AuthTime *jwt.NumericDate `json:"auth_time,omitempty"` + + jwt.RegisteredClaims +} + +// FirebaseVerifier verifies Firebase ID tokens for a single Firebase +// project directly against Google's public certificate endpoint. It never +// depends on the Firebase Admin SDK, and it never logs or returns the raw +// token it verifies. +type FirebaseVerifier struct { + projectID string + certs *firebaseCertCache +} + +// NewFirebaseVerifier returns a FirebaseVerifier for projectID, fetching +// signing certificates from certsURL (Google's +// "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com" +// endpoint in production) through httpClient. +// +// httpClient may be nil, in which case http.DefaultClient is used (its +// Transport, if any, is preserved so tests can point it at an httptest +// server; a bounded per-request timeout is always enforced regardless). +// cacheTTL may be <= 0, in which case a one-hour default is used. +// minRefreshInterval bounds how often an unknown "kid" (or an expired +// cache) may trigger an outbound fetch; it may be <= 0, in which case a +// one-minute default is used. +func NewFirebaseVerifier(projectID string, certsURL string, httpClient *http.Client, cacheTTL time.Duration, minRefreshInterval time.Duration) (*FirebaseVerifier, error) { + if projectID == "" { + return nil, errors.New("auth: Firebase project ID must not be empty") + } + if certsURL == "" { + return nil, errors.New("auth: Firebase certificate URL must not be empty") + } + + return &FirebaseVerifier{ + projectID: projectID, + certs: newFirebaseCertCache(certsURL, httpClient, cacheTTL, minRefreshInterval), + }, nil +} + +// Verify implements IdentityVerifier. It requires raw to be signed RS256, +// issued by "https://securetoken.google.com/", audienced to +// projectID, unexpired (with an expiry claim required to be present at +// all), carrying "iat" and "auth_time" claims that are not in the future, +// and carrying a non-empty subject. +func (v *FirebaseVerifier) Verify(ctx context.Context, raw string) (Principal, error) { + claims := new(firebaseClaims) + token, err := jwt.ParseWithClaims( + raw, + claims, + v.keyfunc(ctx), + jwt.WithIssuer("https://securetoken.google.com/"+v.projectID), + jwt.WithAudience(v.projectID), + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + ) + if err != nil || !token.Valid || claims.Subject == "" || !hasValidFirebaseIssueTimes(claims) { + return Principal{}, ErrInvalidIdentityToken + } + + return Principal{UserID: claims.Subject, Email: claims.Email}, nil +} + +// hasValidFirebaseIssueTimes reports whether the token's "iat" and +// "auth_time" claims are both present and not in the future (allowing for +// firebaseClockSkewLeeway). Firebase's documented ID-token verification +// contract requires both, and neither is validated by the registered-claim +// options passed to jwt.ParseWithClaims. +func hasValidFirebaseIssueTimes(claims *firebaseClaims) bool { + if claims.IssuedAt == nil || claims.AuthTime == nil { + return false + } + + latest := time.Now().Add(firebaseClockSkewLeeway) + + return !claims.IssuedAt.After(latest) && !claims.AuthTime.After(latest) +} + +// keyfunc returns a jwt.Keyfunc that resolves the RSA public key matching +// the token's "kid" header from the cached Firebase certificate map. +func (v *FirebaseVerifier) keyfunc(ctx context.Context) jwt.Keyfunc { + return func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, ErrInvalidIdentityToken + } + + kid, ok := token.Header["kid"].(string) + if !ok || kid == "" { + return nil, ErrInvalidIdentityToken + } + + return v.certs.key(ctx, kid) + } +} + +// firebaseCertCache fetches and caches the RSA public keys published by +// Google's Firebase certificate endpoint, keyed by "kid". Google's endpoint +// serves a flat JSON object mapping key ID to a PEM-encoded X.509 +// certificate (not a JWKS document), so this cache is deliberately separate +// from any generic JWKS/JWK cache. +// +// Two bounds keep an attacker from turning a stream of tokens carrying +// random unknown "kid" headers into a stream of outbound fetches: +// +// - concurrent refreshes are collapsed into a single in-flight fetch that +// every waiting caller shares, and +// - a new fetch is never started until minRefreshInterval has elapsed +// since the previous attempt (successful or not); until then, callers +// either reuse the cached key or fail closed. +// +// A legitimate key rotation is still picked up: Google publishes a rotated +// certificate before signing with it, and a missing "kid" triggers a real +// refresh as soon as the interval has elapsed. +type firebaseCertCache struct { + url string + httpClient *http.Client + cacheTTL time.Duration + minRefreshInterval time.Duration + + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time + lastAttemptAt time.Time + inflight *firebaseCertRefresh +} + +// firebaseCertRefresh is a single in-flight certificate refresh shared by +// every caller that arrives while it is running. err is written before done +// is closed, so a waiter that observes done may safely read it. +type firebaseCertRefresh struct { + done chan struct{} + err error +} + +// newFirebaseCertCache builds a firebaseCertCache for url. +func newFirebaseCertCache(url string, httpClient *http.Client, cacheTTL time.Duration, minRefreshInterval time.Duration) *firebaseCertCache { + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Reuse the caller's transport (important for tests using httptest + // servers) but always enforce our own bounded timeout. + client := &http.Client{ + Transport: httpClient.Transport, + Timeout: firebaseCertsHTTPTimeout, + } + + if cacheTTL <= 0 { + cacheTTL = firebaseCertsDefaultCacheTTL + } + if minRefreshInterval <= 0 { + minRefreshInterval = firebaseCertsDefaultMinRefreshInterval + } + + return &firebaseCertCache{ + url: url, + httpClient: client, + cacheTTL: cacheTTL, + minRefreshInterval: minRefreshInterval, + keys: map[string]*rsa.PublicKey{}, + } +} + +// key returns the cached RSA public key for kid, refreshing the +// certificate map when the cache is stale or the key is not yet known -- +// subject to the collapsing and rate limiting described on +// firebaseCertCache. +func (cache *firebaseCertCache) key(ctx context.Context, kid string) (*rsa.PublicKey, error) { + cache.mu.Lock() + key, ok := cache.keys[kid] + expired := time.Since(cache.fetchedAt) >= cache.cacheTTL + cache.mu.Unlock() + + if ok && !expired { + return key, nil + } + + if err := cache.refreshOnce(ctx); err != nil { + // A rate-limited refresh must not invalidate a key we already + // hold: serving the (stale but still published) cached key is + // strictly better than failing a legitimate login because the + // cache TTL elapsed moments after the last fetch attempt. + if errors.Is(err, errFirebaseCertsRefreshThrottled) && ok { + return key, nil + } + return nil, fmt.Errorf("auth: cannot refresh Firebase certificates: %w", err) + } + + cache.mu.Lock() + key, ok = cache.keys[kid] + cache.mu.Unlock() + if !ok { + return nil, fmt.Errorf("%w: no certificate for kid %q", ErrInvalidIdentityToken, kid) + } + + return key, nil +} + +// refreshOnce performs at most one outbound certificate fetch on behalf of +// every caller that needs one at the same time, and refuses to start a new +// fetch until minRefreshInterval has elapsed since the previous attempt. +func (cache *firebaseCertCache) refreshOnce(ctx context.Context) error { + cache.mu.Lock() + + if inflight := cache.inflight; inflight != nil { + cache.mu.Unlock() + select { + case <-inflight.done: + return inflight.err + case <-ctx.Done(): + return ctx.Err() + } + } + + if !cache.lastAttemptAt.IsZero() && time.Since(cache.lastAttemptAt) < cache.minRefreshInterval { + cache.mu.Unlock() + return errFirebaseCertsRefreshThrottled + } + + inflight := &firebaseCertRefresh{done: make(chan struct{})} + cache.inflight = inflight + cache.lastAttemptAt = time.Now() + cache.mu.Unlock() + + err := cache.refresh(ctx) + inflight.err = err + + cache.mu.Lock() + cache.inflight = nil + cache.mu.Unlock() + close(inflight.done) + + return err +} + +// refresh fetches and replaces the cached certificate map. +func (cache *firebaseCertCache) refresh(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cache.url, nil) + if err != nil { + return fmt.Errorf("auth: cannot create request for Firebase certificate URL %q: %w", cache.url, err) + } + + resp, err := cache.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth: cannot fetch Firebase certificates from %q: %w", cache.url, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("auth: Firebase certificate endpoint %q returned status %d", cache.url, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, firebaseCertsMaxResponseBytes+1)) + if err != nil { + return fmt.Errorf("auth: cannot read Firebase certificate response from %q: %w", cache.url, err) + } + if len(body) > firebaseCertsMaxResponseBytes { + return fmt.Errorf("auth: Firebase certificate response from %q exceeds the %d byte limit", cache.url, firebaseCertsMaxResponseBytes) + } + + var certs map[string]string + if err := json.Unmarshal(body, &certs); err != nil { + return fmt.Errorf("auth: cannot decode Firebase certificate response from %q: %w", cache.url, err) + } + + keys := make(map[string]*rsa.PublicKey, len(certs)) + for kid, certPEM := range certs { + publicKey, err := rsaPublicKeyFromCertificatePEM(certPEM) + if err != nil { + // Skip a single malformed entry rather than failing the whole + // refresh; an unusable "kid" simply remains unresolvable. + continue + } + keys[kid] = publicKey + } + + cache.mu.Lock() + cache.keys = keys + cache.fetchedAt = time.Now() + cache.mu.Unlock() + + return nil +} + +// rsaPublicKeyFromCertificatePEM decodes a single PEM-encoded X.509 +// certificate and returns its RSA public key. +func rsaPublicKeyFromCertificatePEM(certPEM string) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, errors.New("auth: not a PEM-encoded certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("auth: cannot parse X.509 certificate: %w", err) + } + + publicKey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("auth: certificate public key is %T, not RSA", cert.PublicKey) + } + + return publicKey, nil +} diff --git a/mcp/internal/auth/firebase_test.go b/mcp/internal/auth/firebase_test.go new file mode 100644 index 00000000..59dfd8f0 --- /dev/null +++ b/mcp/internal/auth/firebase_test.go @@ -0,0 +1,497 @@ +package auth_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +const ( + testFirebaseProjectID = "httpsms-test" + testFirebaseIssuer = "https://securetoken.google.com/httpsms-test" +) + +// firebaseTestClaims mirrors the unexported claims shape FirebaseVerifier +// decodes, so tests can build tokens with exactly the fields a real +// Firebase ID token carries without depending on any unexported type. +type firebaseTestClaims struct { + Email string `json:"email,omitempty"` + UserID string `json:"user_id,omitempty"` + AuthTime *jwt.NumericDate `json:"auth_time,omitempty"` + jwt.RegisteredClaims +} + +// validFirebaseClaims returns a claim set that a genuine, current Firebase +// ID token for testFirebaseProjectID would carry. +func validFirebaseClaims() firebaseTestClaims { + now := time.Now() + return firebaseTestClaims{ + Email: "user@example.com", + UserID: "user-id", + AuthTime: jwt.NewNumericDate(now.Add(-time.Minute)), + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: testFirebaseIssuer, + Subject: "user-id", + Audience: jwt.ClaimStrings{testFirebaseProjectID}, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + }, + } +} + +// testRSAKeyPair generates a throwaway 2048-bit RSA key, for use only in +// tests. +func testRSAKeyPair(t *testing.T) *rsa.PrivateKey { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return key +} + +// selfSignedCertificatePEM returns a PEM-encoded self-signed X.509 +// certificate for key, in the same shape Google's Firebase certificate +// endpoint serves ("kid" -> PEM certificate). +func selfSignedCertificatePEM(t *testing.T, key *rsa.PrivateKey) string { + t.Helper() + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "firebase-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +// firebaseCertsHandler serves a Firebase-style certificate map response +// mapping "kid" to a PEM certificate, exactly as Google's endpoint does. +func firebaseCertsHandler(certs map[string]string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(certs) + } +} + +// signFirebaseToken signs claims as a Firebase-style RS256 ID token under +// kid. +func signFirebaseToken(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.Claims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + + raw, err := token.SignedString(key) + require.NoError(t, err) + + return raw +} + +// newTestVerifier builds a FirebaseVerifier pointed at a test certificate +// endpoint, with the production default (one minute) minimum refresh +// interval. +func newTestVerifier(t *testing.T, certsURL string, client *http.Client) *auth.FirebaseVerifier { + t.Helper() + + verifier, err := auth.NewFirebaseVerifier(testFirebaseProjectID, certsURL, client, 0, 0) + require.NoError(t, err) + + return verifier +} + +func TestNewFirebaseVerifierRequiresProjectID(t *testing.T) { + _, err := auth.NewFirebaseVerifier("", "https://example.com/certs", nil, 0, 0) + require.Error(t, err) +} + +func TestNewFirebaseVerifierRequiresCertsURL(t *testing.T) { + _, err := auth.NewFirebaseVerifier("httpsms-test", "", nil, 0, 0) + require.Error(t, err) +} + +func TestFirebaseVerifierAcceptsValidToken(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + raw := signFirebaseToken(t, key, "firebase-test-key", validFirebaseClaims()) + + principal, err := verifier.Verify(context.Background(), raw) + require.NoError(t, err) + assert.Equal(t, "user-id", principal.UserID) + assert.Equal(t, "user@example.com", principal.Email) +} + +func TestFirebaseVerifierRejectsWrongIssuer(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.Issuer = "https://securetoken.google.com/some-other-project" + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsWrongAudience(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.Audience = jwt.ClaimStrings{"some-other-project"} + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsExpiredToken(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute)) + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsMissingExpiry(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.ExpiresAt = nil + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsUnknownKid(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"a-different-kid": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + raw := signFirebaseToken(t, key, "firebase-test-key", validFirebaseClaims()) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsWrongSigningMethod(t *testing.T) { + server := httptest.NewServer(firebaseCertsHandler(map[string]string{})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, validFirebaseClaims()) + raw, err := token.SignedString([]byte("does-not-matter")) + require.NoError(t, err) + + _, err = verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsMalformedToken(t *testing.T) { + server := httptest.NewServer(firebaseCertsHandler(map[string]string{})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + _, err := verifier.Verify(context.Background(), "not-a-jwt") + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsTokenWhenCertsEndpointUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + raw := signFirebaseToken(t, testRSAKeyPair(t), "any-kid", validFirebaseClaims()) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +// TestFirebaseVerifierCachesCertificatesAndRefreshesOnRotation asserts the +// bounded cached-certificate-fetching behavior: a cache hit never refetches; +// a "kid" rotated in after the cache was populated triggers exactly one +// additional bounded fetch before the newly-signed token verifies, once the +// minimum refresh interval has elapsed. +func TestFirebaseVerifierCachesCertificatesAndRefreshesOnRotation(t *testing.T) { + firstKey := testRSAKeyPair(t) + secondKey := testRSAKeyPair(t) + firstCert := selfSignedCertificatePEM(t, firstKey) + secondCert := selfSignedCertificatePEM(t, secondKey) + + var requestCount atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requestCount.Add(1) == 1 { + firebaseCertsHandler(map[string]string{"key-1": firstCert})(w, r) + return + } + firebaseCertsHandler(map[string]string{"key-2": secondCert})(w, r) + })) + defer server.Close() + + verifier, err := auth.NewFirebaseVerifier(testFirebaseProjectID, server.URL, server.Client(), 0, time.Millisecond) + require.NoError(t, err) + + firstToken := signFirebaseToken(t, firstKey, "key-1", validFirebaseClaims()) + _, err = verifier.Verify(context.Background(), firstToken) + require.NoError(t, err) + assert.Equal(t, int64(1), requestCount.Load()) + + // A cache hit for the already-known "key-1" must not trigger another + // fetch. + _, err = verifier.Verify(context.Background(), firstToken) + require.NoError(t, err) + assert.Equal(t, int64(1), requestCount.Load()) + + // The cache only has "key-1"; a token signed with the newly rotated + // "key-2" forces exactly one bounded refresh before it can verify -- + // legitimate rotation still works, it is only rate limited. + time.Sleep(5 * time.Millisecond) + secondToken := signFirebaseToken(t, secondKey, "key-2", validFirebaseClaims()) + principal, err := verifier.Verify(context.Background(), secondToken) + require.NoError(t, err) + assert.Equal(t, "user-id", principal.UserID) + assert.Equal(t, int64(2), requestCount.Load()) +} + +// TestFirebaseVerifierRefreshesAfterCacheTTLExpires asserts the cache also +// refreshes on a plain TTL expiry, not only on a missing "kid". +func TestFirebaseVerifierRefreshesAfterCacheTTLExpires(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + + var requestCount atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})(w, r) + })) + defer server.Close() + + verifier, err := auth.NewFirebaseVerifier(testFirebaseProjectID, server.URL, server.Client(), 10*time.Millisecond, time.Millisecond) + require.NoError(t, err) + + raw := signFirebaseToken(t, key, "firebase-test-key", validFirebaseClaims()) + + _, err = verifier.Verify(context.Background(), raw) + require.NoError(t, err) + assert.Equal(t, int64(1), requestCount.Load()) + + time.Sleep(20 * time.Millisecond) + + _, err = verifier.Verify(context.Background(), raw) + require.NoError(t, err) + assert.Equal(t, int64(2), requestCount.Load()) +} + +// TestFirebaseVerifierRateLimitsUnknownKidRefreshes asserts an attacker +// cannot amplify a flood of tokens carrying random unknown "kid" headers +// into one outbound certificate fetch per request: after the first fetch, +// no further fetch happens until the minimum refresh interval elapses. +// +// No token, certificate, or key material is logged by this test; only the +// outbound request count is asserted. +func TestFirebaseVerifierRateLimitsUnknownKidRefreshes(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + + var requestCount atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})(w, r) + })) + defer server.Close() + + // A one-minute minimum refresh interval, i.e. the production default. + verifier := newTestVerifier(t, server.URL, server.Client()) + + valid := signFirebaseToken(t, key, "firebase-test-key", validFirebaseClaims()) + _, err := verifier.Verify(context.Background(), valid) + require.NoError(t, err) + require.Equal(t, int64(1), requestCount.Load()) + + for i := 0; i < 200; i++ { + unknown := signFirebaseToken(t, key, fmt.Sprintf("random-kid-%d", i), validFirebaseClaims()) + _, err := verifier.Verify(context.Background(), unknown) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) + } + + assert.Equal(t, int64(1), requestCount.Load(), "unknown kids must not cause one outbound fetch per request") + + // The still-cached, still-published key keeps verifying throughout. + _, err = verifier.Verify(context.Background(), valid) + require.NoError(t, err) + assert.Equal(t, int64(1), requestCount.Load()) +} + +// TestFirebaseVerifierCollapsesConcurrentRefreshes asserts that a burst of +// concurrent verifications arriving against a cold cache shares a single +// outbound fetch instead of issuing one per goroutine. +func TestFirebaseVerifierCollapsesConcurrentRefreshes(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + + var requestCount atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + // Hold the fetch open long enough for every caller to pile up + // behind the single in-flight refresh. + time.Sleep(50 * time.Millisecond) + firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})(w, r) + })) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + raw := signFirebaseToken(t, key, "firebase-test-key", validFirebaseClaims()) + + const callers = 25 + var wg sync.WaitGroup + errs := make([]error, callers) + for i := 0; i < callers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + _, err := verifier.Verify(context.Background(), raw) + errs[index] = err + }(i) + } + wg.Wait() + + for index, err := range errs { + require.NoError(t, err, "caller %d must verify against the shared refresh", index) + } + assert.Equal(t, int64(1), requestCount.Load(), "concurrent refreshes must be collapsed into one fetch") +} + +func TestFirebaseVerifierRejectsMissingIssuedAt(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.IssuedAt = nil + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsFutureIssuedAt(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(time.Hour)) + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsMissingAuthTime(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.AuthTime = nil + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsFutureAuthTime(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.AuthTime = jwt.NewNumericDate(time.Now().Add(time.Hour)) + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} + +func TestFirebaseVerifierRejectsEmptySubject(t *testing.T) { + key := testRSAKeyPair(t) + certPEM := selfSignedCertificatePEM(t, key) + server := httptest.NewServer(firebaseCertsHandler(map[string]string{"firebase-test-key": certPEM})) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + claims := validFirebaseClaims() + claims.Subject = "" + raw := signFirebaseToken(t, key, "firebase-test-key", claims) + + _, err := verifier.Verify(context.Background(), raw) + require.ErrorIs(t, err, auth.ErrInvalidIdentityToken) +} diff --git a/mcp/internal/auth/keys.go b/mcp/internal/auth/keys.go new file mode 100644 index 00000000..0473f7cd --- /dev/null +++ b/mcp/internal/auth/keys.go @@ -0,0 +1,347 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// minRSAKeyBits is the minimum accepted RSA signing key size. Keys smaller +// than this are rejected by NewKeySet regardless of encoding. +const minRSAKeyBits = 2048 + +// JWK is a single RSA public key published in JWKS format. It never carries +// private key material. +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +// JWKS is a JSON Web Key Set document as published at the MCP service's +// JWKS endpoint for downstream verifiers (including the httpSMS API). +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// keySetConfig holds the deployment-derived issuer/audiences a KeySet signs +// with, published atomically as a single immutable value so a concurrent +// reader either sees no configuration or sees all three fields fully +// populated — never a partially-applied Configure call. +type keySetConfig struct { + issuer string + mcpAudience string + apiAudience string +} + +// KeySet loads a single RSA signing key and mints/publishes the JWTs issued +// by the hosted MCP service. A KeySet never logs, and never exposes through +// any method, the private key it holds. +// +// issuer, mcpAudience, and apiAudience are deployment configuration (derived +// from config.Config) rather than key material, so NewKeySet returns a +// KeySet that cannot sign anything until the caller calls Configure exactly +// once. Configure is deliberately one-shot (not a plain setter) so a KeySet +// can safely be shared across goroutines without a data race: the +// issuer/audiences are stored behind a single atomic.Pointer swap, so +// Configure either fully publishes a complete, immutable *keySetConfig or +// does nothing, and every signing method only ever reads the published +// value through an atomic load — there is no window in which a concurrent +// reader can observe a partially-configured KeySet. +type KeySet struct { + privateKey *rsa.PrivateKey + keyID string + + // config is nil until Configure succeeds, after which it is never + // written again. atomic.Pointer.CompareAndSwap makes "claim the + // one-shot slot" and "publish the fully-built value" a single atomic + // step, so concurrent Configure calls race safely (exactly one wins) + // and concurrent signing calls never observe a half-written config. + config atomic.Pointer[keySetConfig] +} + +// NewKeySet parses privateKeyPEM (PKCS#1 or PKCS#8, RSA only, at least +// minRSAKeyBits bits) and returns a KeySet that signs with it under keyID. +func NewKeySet(privateKeyPEM []byte, keyID string) (*KeySet, error) { + if keyID == "" { + return nil, errors.New("auth: signing key ID must not be empty") + } + + privateKey, err := parseRSAPrivateKeyPEM(privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("auth: cannot load RSA signing key: %w", err) + } + + if bits := privateKey.N.BitLen(); bits < minRSAKeyBits { + return nil, fmt.Errorf("auth: RSA signing key has %d bits, must be at least %d", bits, minRSAKeyBits) + } + + return &KeySet{privateKey: privateKey, keyID: keyID}, nil +} + +// parseRSAPrivateKeyPEM decodes a single PEM block and parses it as either a +// PKCS#1 or PKCS#8 RSA private key. Any other key type is rejected. +func parseRSAPrivateKeyPEM(privateKeyPEM []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(privateKeyPEM) + if block == nil { + return nil, errors.New("no PEM block found") + } + + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("not a PKCS#1 or PKCS#8 private key: %w", err) + } + + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("PKCS#8 key is %T, not an RSA private key", key) + } + + return rsaKey, nil +} + +// Configure sets issuer, mcpAudience, and apiAudience exactly once. It must +// be called before any signing method and must not be called more than +// once; both are programmer errors and return an error rather than +// panicking, so callers (and their tests) can assert on them. +// +// Configure builds the complete configuration value first, then publishes +// it with a single atomic.Pointer.CompareAndSwap. This makes "claim the +// one-shot slot" and "make the new issuer/audiences visible" the same +// indivisible step, so KeySet is safe to share across goroutines: a +// concurrent signing call either reads nil (and fails closed) or reads a +// fully-populated *keySetConfig, never a partially-applied one. +func (keys *KeySet) Configure(issuer, mcpAudience, apiAudience string) error { + if issuer == "" { + return errors.New("auth: KeySet issuer must not be empty") + } + if mcpAudience == "" { + return errors.New("auth: KeySet MCP audience must not be empty") + } + if apiAudience == "" { + return errors.New("auth: KeySet API audience must not be empty") + } + + cfg := &keySetConfig{issuer: issuer, mcpAudience: mcpAudience, apiAudience: apiAudience} + if !keys.config.CompareAndSwap(nil, cfg) { + return errors.New("auth: KeySet is already configured") + } + + return nil +} + +// PublicKey returns the RSA public key corresponding to the loaded signing +// key, for verifying tokens minted by this KeySet in tests and internal +// callers. It never exposes the private key. +func (keys *KeySet) PublicKey() *rsa.PublicKey { + return &keys.privateKey.PublicKey +} + +// KeyID returns the `kid` this KeySet signs with and publishes in its JWKS. +func (keys *KeySet) KeyID() string { + return keys.keyID +} + +// SignMCPAccessToken mints a short-lived MCP access token for principal, +// scoped to scopes and bound to the OAuth client identified by clientID. The +// token is audience-bound to the configured MCP audience and must never be +// accepted by the httpSMS API. +func (keys *KeySet) SignMCPAccessToken(principal Principal, clientID string, scopes []string, ttl time.Duration) (string, error) { + cfg, err := keys.requireConfig() + if err != nil { + return "", err + } + + claims, err := keys.baseClaims(cfg.issuer, principal, cfg.mcpAudience, scopes, ttl) + if err != nil { + return "", err + } + claims.ClientID = clientID + + return keys.sign(claims) +} + +// SignAPIDelegationToken mints a short-lived downstream API delegation token +// for principal, scoped to scopes, and bound to exactly one API operation +// (method, path). The token is audience-bound to the configured API +// audience. +// +// The resulting JWT carries JSON fields `scopes`, `http_method`, and +// `http_path`; the configured issuer; the configured API audience; subject +// principal.UserID; is signed RS256; and carries a `kid` header. This is a +// wire contract with the httpSMS API's delegated MCP token verifier +// (api/pkg/auth.MCPClaims) and must not change independently of it. +func (keys *KeySet) SignAPIDelegationToken(principal Principal, scopes []string, method string, path string, ttl time.Duration) (string, error) { + if method == "" || path == "" { + return "", errors.New("auth: API delegation token requires a non-empty method and path") + } + + cfg, err := keys.requireConfig() + if err != nil { + return "", err + } + + claims, err := keys.baseClaims(cfg.issuer, principal, cfg.apiAudience, scopes, ttl) + if err != nil { + return "", err + } + claims.Method = method + claims.Path = path + + return keys.sign(claims) +} + +// VerifyAccessToken validates raw as an MCP access token minted by this +// same KeySet: signed RS256 with this KeySet's own key, issued by the +// configured issuer, audienced to the configured MCP audience (never the +// API audience -- this rejects a downstream API delegation token presented +// as an MCP access token), unexpired, and carrying a non-empty subject. It +// returns the token's claims on success. +func (keys *KeySet) VerifyAccessToken(raw string) (*AccessClaims, error) { + cfg, err := keys.requireConfig() + if err != nil { + return nil, err + } + + claims := new(AccessClaims) + token, err := jwt.ParseWithClaims( + raw, + claims, + keys.verifyKeyfunc, + jwt.WithIssuer(cfg.issuer), + jwt.WithAudience(cfg.mcpAudience), + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + ) + if err != nil { + return nil, fmt.Errorf("auth: invalid MCP access token: %w", err) + } + if !token.Valid || claims.Subject == "" { + return nil, errors.New("auth: invalid MCP access token") + } + + return claims, nil +} + +// verifyKeyfunc resolves the RSA public key used to verify every token this +// KeySet mints. Every minted token is signed by this same KeySet, so there +// is exactly one verification key: the public half of keys.privateKey. +func (keys *KeySet) verifyKeyfunc(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("auth: unexpected token signing method %q", token.Header["alg"]) + } + return keys.PublicKey(), nil +} + +// requireConfig returns the KeySet's published configuration, or an error if +// Configure has not yet been called successfully. +func (keys *KeySet) requireConfig() (*keySetConfig, error) { + cfg := keys.config.Load() + if cfg == nil { + return nil, errors.New("auth: KeySet.Configure must be called before signing tokens") + } + return cfg, nil +} + +// baseClaims builds the claims shared by every token this KeySet mints. +func (keys *KeySet) baseClaims(issuer string, principal Principal, audience string, scopes []string, ttl time.Duration) (*AccessClaims, error) { + if principal.UserID == "" { + return nil, errors.New("auth: token subject (Firebase UID) must not be empty") + } + if ttl <= 0 { + return nil, errors.New("auth: token TTL must be positive") + } + + jti, err := newTokenID() + if err != nil { + return nil, fmt.Errorf("auth: cannot generate token ID: %w", err) + } + + now := time.Now().UTC() + return &AccessClaims{ + Email: principal.Email, + Scopes: scopes, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: issuer, + Subject: principal.UserID, + Audience: jwt.ClaimStrings{audience}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + ID: jti, + }, + }, nil +} + +// sign signs claims with keys.privateKey using RS256 and publishes keys.keyID +// as the token's `kid` header. +func (keys *KeySet) sign(claims *AccessClaims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = keys.keyID + + raw, err := token.SignedString(keys.privateKey) + if err != nil { + return "", fmt.Errorf("auth: cannot sign token: %w", err) + } + + return raw, nil +} + +// newTokenID returns a random 128-bit token identifier encoded as hex, used +// as the JWT `jti` claim. +func newTokenID() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// JWKS returns the JSON Web Key Set publishing keys.PublicKey() under +// keys.keyID. It never publishes private key material. +func (keys *KeySet) JWKS() JWKS { + publicKey := keys.PublicKey() + + return JWKS{ + Keys: []JWK{ + { + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: keys.keyID, + N: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(bigEndianBytes(publicKey.E)), + }, + }, + } +} + +// bigEndianBytes returns the minimal big-endian encoding of a positive int, +// as required for a JWK's "e" member. +func bigEndianBytes(n int) []byte { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(int64(n))) + + i := 0 + for i < len(buf)-1 && buf[i] == 0 { + i++ + } + return buf[i:] +} diff --git a/mcp/internal/auth/keys_test.go b/mcp/internal/auth/keys_test.go new file mode 100644 index 00000000..03dd3f63 --- /dev/null +++ b/mcp/internal/auth/keys_test.go @@ -0,0 +1,440 @@ +package auth_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +const ( + testMCPIssuer = "https://mcp.httpsms.com" + testMCPAudience = "https://mcp.httpsms.com/mcp" + testAPIAudience = "https://api.httpsms.com" + testSigningKeyID = "test-key-1" + testFirebaseUserID = "user-id" + testUserEmail = "user@example.com" +) + +// newTestPrivateKeyPEM generates a throwaway RSA private key of the given +// size encoded as PKCS#1 PEM, for use only in tests. +func newTestPrivateKeyPEM(t *testing.T, bits int) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, bits) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) +} + +// newTestPKCS8PrivateKeyPEM generates a throwaway 2048-bit RSA private key +// encoded as PKCS#8 PEM, for use only in tests. +func newTestPKCS8PrivateKeyPEM(t *testing.T) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + bytes, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: bytes}) +} + +// newTestKeySet builds a KeySet with test issuer/audiences already +// configured, as a production caller would after loading them from +// config.Config. +func newTestKeySet(t *testing.T) *auth.KeySet { + t.Helper() + + keys, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + require.NoError(t, keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) + + return keys +} + +// parseTestClaims verifies raw against publicKey and returns its claims, +// failing the test if raw does not parse or verify. +func parseTestClaims(t *testing.T, raw string, publicKey *rsa.PublicKey) *auth.AccessClaims { + t.Helper() + + claims := new(auth.AccessClaims) + token, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) { + return publicKey, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})) + require.NoError(t, err) + require.True(t, token.Valid) + + return claims +} + +func TestNewKeySetRejectsEmptyKeyID(t *testing.T) { + _, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), "") + require.Error(t, err) +} + +func TestNewKeySetRejectsInvalidPEM(t *testing.T) { + _, err := auth.NewKeySet([]byte("not a pem block"), testSigningKeyID) + require.Error(t, err) +} + +func TestNewKeySetRejectsKeysSmallerThan2048Bits(t *testing.T) { + _, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 1024), testSigningKeyID) + require.ErrorContains(t, err, "2048") +} + +func TestNewKeySetAcceptsPKCS1AndPKCS8Encodings(t *testing.T) { + _, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + _, err = auth.NewKeySet(newTestPKCS8PrivateKeyPEM(t), testSigningKeyID) + require.NoError(t, err) +} + +func TestKeySetSigningFailsUntilConfigured(t *testing.T) { + keys, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + _, err = keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Minute) + require.ErrorContains(t, err, "Configure") + + _, err = keys.SignAPIDelegationToken(auth.Principal{UserID: testFirebaseUserID}, []string{"phones:read"}, "GET", "/v1/phones", time.Minute) + require.ErrorContains(t, err, "Configure") +} + +func TestKeySetConfigureSucceedsOnce(t *testing.T) { + keys, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + require.NoError(t, keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Minute) + require.NoError(t, err) + + claims := parseTestClaims(t, raw, keys.PublicKey()) + assert.Equal(t, testMCPIssuer, claims.Issuer) + assert.Equal(t, testMCPAudience, claims.Audience[0]) +} + +func TestKeySetConfigureRejectsEmptyValues(t *testing.T) { + testCases := map[string]struct { + issuer string + mcpAudience string + apiAudience string + }{ + "empty issuer": {issuer: "", mcpAudience: testMCPAudience, apiAudience: testAPIAudience}, + "empty mcpAudience": {issuer: testMCPIssuer, mcpAudience: "", apiAudience: testAPIAudience}, + "empty apiAudience": {issuer: testMCPIssuer, mcpAudience: testMCPAudience, apiAudience: ""}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + keys, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + err = keys.Configure(tc.issuer, tc.mcpAudience, tc.apiAudience) + require.Error(t, err) + + // A rejected Configure call must not leave the KeySet able to + // sign, nor able to be configured again with valid values (an + // empty-value call must not consume the one-shot slot). + _, signErr := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Minute) + require.Error(t, signErr) + + require.NoError(t, keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) + }) + } +} + +func TestKeySetConfigureRejectsSecondCall(t *testing.T) { + keys, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + require.NoError(t, keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) + + err = keys.Configure("https://other.example", "https://other.example/mcp", "https://other.example/api") + require.ErrorContains(t, err, "already configured") + + // The rejected reconfiguration must not have overwritten the original + // issuer/audiences. + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Minute) + require.NoError(t, err) + + claims := parseTestClaims(t, raw, keys.PublicKey()) + assert.Equal(t, testMCPIssuer, claims.Issuer) + assert.Equal(t, testMCPAudience, claims.Audience[0]) +} + +func TestKeySetConfigureIsRaceFreeUnderConcurrentCalls(t *testing.T) { + keys, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + + const attempts = 16 + results := make(chan error, attempts) + for i := 0; i < attempts; i++ { + go func() { + results <- keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience) + }() + } + + successes := 0 + for i := 0; i < attempts; i++ { + if err := <-results; err == nil { + successes++ + } + } + assert.Equal(t, 1, successes, "exactly one concurrent Configure call must succeed") + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Minute) + require.NoError(t, err) + claims := parseTestClaims(t, raw, keys.PublicKey()) + assert.Equal(t, testMCPIssuer, claims.Issuer) +} + +func TestKeySetSignsAudienceBoundTokens(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignMCPAccessToken( + auth.Principal{UserID: testFirebaseUserID, Email: testUserEmail}, + "https://client.example/metadata.json", + []string{"messages:read"}, + 15*time.Minute, + ) + require.NoError(t, err) + + claims := parseTestClaims(t, raw, keys.PublicKey()) + require.Len(t, claims.Audience, 1) + assert.Equal(t, testMCPAudience, claims.Audience[0]) + assert.Equal(t, testFirebaseUserID, claims.Subject) + assert.Equal(t, testMCPIssuer, claims.Issuer) + assert.Equal(t, []string{"messages:read"}, claims.Scopes) + assert.Equal(t, "https://client.example/metadata.json", claims.ClientID) + assert.Empty(t, claims.Method) + assert.Empty(t, claims.Path) +} + +func TestKeySetSignsAPIDelegationTokensBoundToOneOperation(t *testing.T) { + keys := newTestKeySet(t) + ttl := 2 * time.Minute + + before := time.Now() + raw, err := keys.SignAPIDelegationToken( + auth.Principal{UserID: testFirebaseUserID, Email: testUserEmail}, + []string{"messages:send"}, + "POST", + "/v1/messages/send", + ttl, + ) + require.NoError(t, err) + + claims := parseTestClaims(t, raw, keys.PublicKey()) + require.Len(t, claims.Audience, 1) + assert.Equal(t, testAPIAudience, claims.Audience[0]) + assert.Equal(t, testMCPIssuer, claims.Issuer) + assert.Equal(t, testFirebaseUserID, claims.Subject) + assert.Equal(t, []string{"messages:send"}, claims.Scopes) + assert.Equal(t, "POST", claims.Method) + assert.Equal(t, "/v1/messages/send", claims.Path) + assert.Empty(t, claims.ClientID) + + require.NotNil(t, claims.ExpiresAt) + assert.WithinDuration(t, before.Add(ttl), claims.ExpiresAt.Time, 5*time.Second) + assert.False(t, claims.ExpiresAt.Time.After(before.Add(ttl+5*time.Second))) +} + +func TestKeySetSignsOnlyRequestedScopes(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignAPIDelegationToken( + auth.Principal{UserID: testFirebaseUserID}, + []string{"phones:read"}, + "GET", + "/v1/phones", + time.Minute, + ) + require.NoError(t, err) + + claims := parseTestClaims(t, raw, keys.PublicKey()) + assert.Equal(t, []string{"phones:read"}, claims.Scopes) + assert.NotContains(t, claims.Scopes, "messages:send") +} + +func TestKeySetAPIDelegationTokenHasWireContractFields(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignAPIDelegationToken( + auth.Principal{UserID: testFirebaseUserID}, + []string{"phone-api-keys:write"}, + "POST", + "/v1/phone-api-keys", + time.Minute, + ) + require.NoError(t, err) + + // The wire contract with api/pkg/auth.MCPClaims requires exactly these + // JSON field names: scopes, http_method, http_path. + assert.True(t, strings.Contains(raw, ".")) // sanity: looks like a JWT + + token, _, err := jwt.NewParser().ParseUnverified(raw, jwt.MapClaims{}) + require.NoError(t, err) + + kid, ok := token.Header["kid"].(string) + require.True(t, ok) + assert.Equal(t, testSigningKeyID, kid) + + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.Equal(t, []any{"phone-api-keys:write"}, claims["scopes"]) + assert.Equal(t, "POST", claims["http_method"]) + assert.Equal(t, "/v1/phone-api-keys", claims["http_path"]) + assert.Equal(t, []any{testAPIAudience}, claims["aud"]) + assert.Equal(t, testMCPIssuer, claims["iss"]) + assert.Equal(t, testFirebaseUserID, claims["sub"]) +} + +func TestKeySetSignMCPAccessTokenRejectsMissingSubject(t *testing.T) { + keys := newTestKeySet(t) + + _, err := keys.SignMCPAccessToken(auth.Principal{}, "client", []string{"phones:read"}, time.Minute) + require.Error(t, err) +} + +func TestKeySetJWKSPublishesOnlyThePublicKey(t *testing.T) { + keys := newTestKeySet(t) + + jwks := keys.JWKS() + require.Len(t, jwks.Keys, 1) + + key := jwks.Keys[0] + assert.Equal(t, testSigningKeyID, key.Kid) + assert.Equal(t, "RSA", key.Kty) + assert.Equal(t, "RS256", key.Alg) + assert.NotEmpty(t, key.N) + assert.NotEmpty(t, key.E) +} + +func TestKeySetVerifyAccessTokenAcceptsItsOwnMCPAccessToken(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignMCPAccessToken( + auth.Principal{UserID: testFirebaseUserID, Email: testUserEmail}, + "https://client.example/metadata.json", + []string{"messages:read"}, + 15*time.Minute, + ) + require.NoError(t, err) + + claims, err := keys.VerifyAccessToken(raw) + require.NoError(t, err) + assert.Equal(t, testFirebaseUserID, claims.Subject) + assert.Equal(t, testUserEmail, claims.Email) + assert.Equal(t, []string{"messages:read"}, claims.Scopes) + assert.Equal(t, "https://client.example/metadata.json", claims.ClientID) +} + +func TestKeySetVerifyAccessTokenRejectsAPIDelegationToken(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignAPIDelegationToken( + auth.Principal{UserID: testFirebaseUserID}, + []string{"messages:send"}, + "POST", + "/v1/messages/send", + time.Minute, + ) + require.NoError(t, err) + + _, err = keys.VerifyAccessToken(raw) + require.Error(t, err) +} + +func TestKeySetVerifyAccessTokenRejectsExpiredToken(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Nanosecond) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + _, err = keys.VerifyAccessToken(raw) + require.Error(t, err) +} + +func TestKeySetVerifyAccessTokenRejectsWrongSigningKey(t *testing.T) { + keys := newTestKeySet(t) + other, err := auth.NewKeySet(newTestPrivateKeyPEM(t, 2048), testSigningKeyID) + require.NoError(t, err) + require.NoError(t, other.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) + + raw, err := other.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{"phones:read"}, time.Minute) + require.NoError(t, err) + + _, err = keys.VerifyAccessToken(raw) + require.Error(t, err) +} + +func TestKeySetVerifyAccessTokenRejectsMalformedToken(t *testing.T) { + keys := newTestKeySet(t) + + _, err := keys.VerifyAccessToken("not-a-jwt") + require.Error(t, err) +} + +func TestKeySetJWKSRoundTripsToAWorkingVerificationKey(t *testing.T) { + keys := newTestKeySet(t) + + raw, err := keys.SignMCPAccessToken( + auth.Principal{UserID: testFirebaseUserID}, + "client", + []string{"phones:read"}, + time.Minute, + ) + require.NoError(t, err) + + jwk := keys.JWKS().Keys[0] + publicKey := rsaPublicKeyFromJWK(t, jwk) + + claims := parseTestClaims(t, raw, publicKey) + assert.Equal(t, testFirebaseUserID, claims.Subject) +} + +// rsaPublicKeyFromJWK reconstructs an *rsa.PublicKey from a JWK's base64url +// modulus/exponent, independently of any production decoding code, so the +// round-trip test exercises exactly the bytes KeySet.JWKS() publishes. +func rsaPublicKeyFromJWK(t *testing.T, jwk auth.JWK) *rsa.PublicKey { + t.Helper() + + nBytes := mustBase64URLDecode(t, jwk.N) + eBytes := mustBase64URLDecode(t, jwk.E) + + e := 0 + for _, b := range eBytes { + e = e<<8 | int(b) + } + + return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e} +} + +func mustBase64URLDecode(t *testing.T, s string) []byte { + t.Helper() + + decoded, err := base64.RawURLEncoding.DecodeString(s) + require.NoError(t, err) + + return decoded +} diff --git a/mcp/internal/auth/middleware.go b/mcp/internal/auth/middleware.go new file mode 100644 index 00000000..5da6bb8a --- /dev/null +++ b/mcp/internal/auth/middleware.go @@ -0,0 +1,128 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "time" + + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" +) + +// OAuth scopes issued by this service and required by MCP tools. These are +// the same wire values published in oauth.Scopes (the source of truth for +// values presented in OAuth discovery metadata and the consent screen); +// both lists must be kept in sync. +const ( + ScopePhonesRead = "phones:read" + ScopeMessagesRead = "messages:read" + ScopeMessagesSend = "messages:send" + ScopePhoneAPIKeysWrite = "phone-api-keys:write" + ScopeUserAPIKeyRotate = "user-api-key:rotate" +) + +// tokenInfoPrincipalKey and tokenInfoClientIDKey are the mcpauth.TokenInfo +// Extra map keys Verifier.VerifyMCPToken populates. They are internal to +// this package: callers must use PrincipalFromContext and RequireScope +// rather than reading mcpauth.TokenInfo.Extra directly. +const ( + tokenInfoPrincipalKey = "principal" + tokenInfoClientIDKey = "client_id" +) + +// Verifier authenticates MCP bearer tokens presented to this service's own +// `/mcp` endpoint. Every such token is an MCP access token minted by this +// same service's KeySet (see KeySet.SignMCPAccessToken); Verifier never +// authenticates a Firebase ID token or a downstream API delegation token. +type Verifier struct { + keys *KeySet +} + +// NewVerifier returns a Verifier that authenticates MCP bearer tokens +// against keys' own signing key, issuer, and MCP audience. +func NewVerifier(keys *KeySet) *Verifier { + return &Verifier{keys: keys} +} + +// VerifyMCPToken implements mcpauth.TokenVerifier for use with +// mcpauth.RequireBearerToken. It never logs or returns raw, and the +// mcpauth.TokenInfo it returns never carries raw or any other secret +// material -- only the claims already present in an MCP access token +// (subject, scopes, expiry, client, email). +func (v *Verifier) VerifyMCPToken(_ context.Context, raw string, _ *http.Request) (*mcpauth.TokenInfo, error) { + claims, err := v.keys.VerifyAccessToken(raw) + if err != nil { + return nil, fmt.Errorf("%w: invalid access token", mcpauth.ErrInvalidToken) + } + + var expiration time.Time + if claims.ExpiresAt != nil { + expiration = claims.ExpiresAt.Time + } + + return &mcpauth.TokenInfo{ + UserID: claims.Subject, + Scopes: claims.Scopes, + Expiration: expiration, + Extra: map[string]any{ + tokenInfoPrincipalKey: Principal{UserID: claims.Subject, Email: claims.Email}, + tokenInfoClientIDKey: claims.ClientID, + }, + }, nil +} + +// PrincipalFromContext returns the Principal carried by the MCP access +// token that mcpauth.RequireBearerToken (configured with a Verifier's +// VerifyMCPToken) has already validated for the current request, or false +// if ctx carries no verified token. +func PrincipalFromContext(ctx context.Context) (Principal, bool) { + info := mcpauth.TokenInfoFromContext(ctx) + if info == nil { + return Principal{}, false + } + + principal, ok := info.Extra[tokenInfoPrincipalKey].(Principal) + return principal, ok +} + +// ClientIDFromContext returns the OAuth client ID carried by the MCP access +// token that mcpauth.RequireBearerToken (configured with a Verifier's +// VerifyMCPToken) has already validated for the current request, or false +// if ctx carries no verified token. Tools use this to bind sensitive +// confirmation state (see the rotate_user_api_key tool) to the exact OAuth +// client that requested the operation, not just the authenticated user. +func ClientIDFromContext(ctx context.Context) (string, bool) { + info := mcpauth.TokenInfoFromContext(ctx) + if info == nil { + return "", false + } + + clientID, ok := info.Extra[tokenInfoClientIDKey].(string) + return clientID, ok +} + +// RequireScope returns the Principal carried by ctx's already-validated MCP +// access token, or an error if ctx carries no verified token or the token's +// scopes do not include scope. It never calls the httpSMS API and never +// mints a token itself; callers use the returned Principal to mint their +// own scope-bound API delegation token for the single downstream operation +// they are about to perform. +func RequireScope(ctx context.Context, scope string) (Principal, error) { + info := mcpauth.TokenInfoFromContext(ctx) + if info == nil { + return Principal{}, errors.New("auth: request has no verified MCP bearer token") + } + + if !slices.Contains(info.Scopes, scope) { + return Principal{}, fmt.Errorf("auth: this operation requires the %q scope", scope) + } + + principal, ok := info.Extra[tokenInfoPrincipalKey].(Principal) + if !ok { + return Principal{}, errors.New("auth: verified MCP bearer token is missing its principal") + } + + return principal, nil +} diff --git a/mcp/internal/auth/middleware_test.go b/mcp/internal/auth/middleware_test.go new file mode 100644 index 00000000..d909e0ad --- /dev/null +++ b/mcp/internal/auth/middleware_test.go @@ -0,0 +1,180 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +const testResourceMetadataURL = "https://mcp.httpsms.com/.well-known/oauth-protected-resource" + +// newMiddlewareTestServer builds an http.Handler protected by +// mcpauth.RequireBearerToken(verifier.VerifyMCPToken, ...), whose inner +// handler reports the verified mcpauth.TokenInfo (or its absence) as JSON, +// so tests can assert on both the HTTP-level response and what ends up in +// the request context. +func newMiddlewareTestServer(t *testing.T, keys *auth.KeySet, requiredScopes []string) *httptest.Server { + t.Helper() + + verifier := auth.NewVerifier(keys) + middleware := mcpauth.RequireBearerToken(verifier.VerifyMCPToken, &mcpauth.RequireBearerTokenOptions{ + ResourceMetadataURL: testResourceMetadataURL, + Scopes: requiredScopes, + }) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := mcpauth.TokenInfoFromContext(r.Context()) + require.NotNil(t, info, "middleware must store TokenInfo in the request context on success") + + principal, ok := auth.PrincipalFromContext(r.Context()) + require.True(t, ok, "auth.PrincipalFromContext must find the principal the middleware stored") + + clientID, ok := auth.ClientIDFromContext(r.Context()) + require.True(t, ok, "auth.ClientIDFromContext must find the client ID the middleware stored") + + w.Header().Set("X-Test-User-ID", info.UserID) + w.Header().Set("X-Test-Principal-Email", principal.Email) + w.Header().Set("X-Test-Client-ID", clientID) + w.WriteHeader(http.StatusOK) + }) + + return httptest.NewServer(middleware(inner)) +} + +func TestRequireBearerTokenRejectsMissingToken(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, nil) + defer server.Close() + + resp, err := http.Get(server.URL) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, resp.Header.Get("WWW-Authenticate"), testResourceMetadataURL) +} + +func TestRequireBearerTokenRejectsMalformedAuthorizationHeader(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, nil) + defer server.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "not-a-bearer-token") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestRequireBearerTokenRejectsExpiredToken(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, nil) + defer server.Close() + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{auth.ScopePhonesRead}, time.Nanosecond) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + resp := doBearerRequest(t, server.URL, raw) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, resp.Header.Get("WWW-Authenticate"), testResourceMetadataURL) +} + +func TestRequireBearerTokenRejectsWrongAudienceToken(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, nil) + defer server.Close() + + // An API delegation token is audienced to the API, not the MCP + // endpoint, and must never authenticate an MCP request. + raw, err := keys.SignAPIDelegationToken(auth.Principal{UserID: testFirebaseUserID}, []string{auth.ScopePhonesRead}, http.MethodGet, "/v1/phones", time.Minute) + require.NoError(t, err) + + resp := doBearerRequest(t, server.URL, raw) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestRequireBearerTokenRejectsInvalidToken(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, nil) + defer server.Close() + + resp := doBearerRequest(t, server.URL, "this-is-not-a-jwt") + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, resp.Header.Get("WWW-Authenticate"), testResourceMetadataURL) +} + +func TestRequireBearerTokenRejectsInsufficientScope(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, []string{auth.ScopeMessagesSend}) + defer server.Close() + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID}, "client", []string{auth.ScopePhonesRead}, time.Minute) + require.NoError(t, err) + + resp := doBearerRequest(t, server.URL, raw) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestRequireBearerTokenAcceptsValidTokenAndStoresTokenInfo(t *testing.T) { + keys := newTestKeySet(t) + server := newMiddlewareTestServer(t, keys, []string{auth.ScopePhonesRead}) + defer server.Close() + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUserID, Email: testUserEmail}, "client", []string{auth.ScopePhonesRead, auth.ScopeMessagesRead}, time.Minute) + require.NoError(t, err) + + resp := doBearerRequest(t, server.URL, raw) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, testFirebaseUserID, resp.Header.Get("X-Test-User-ID")) + assert.Equal(t, testUserEmail, resp.Header.Get("X-Test-Principal-Email")) + assert.Equal(t, "client", resp.Header.Get("X-Test-Client-ID")) +} + +func doBearerRequest(t *testing.T, url string, token string) *http.Response { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func TestPrincipalFromContextReturnsFalseWithoutToken(t *testing.T) { + _, ok := auth.PrincipalFromContext(t.Context()) + assert.False(t, ok) +} + +func TestClientIDFromContextReturnsFalseWithoutToken(t *testing.T) { + _, ok := auth.ClientIDFromContext(t.Context()) + assert.False(t, ok) +} + +func TestRequireScopeReturnsErrorWithoutToken(t *testing.T) { + _, err := auth.RequireScope(t.Context(), auth.ScopePhonesRead) + require.Error(t, err) +} diff --git a/mcp/internal/config/config.go b/mcp/internal/config/config.go new file mode 100644 index 00000000..222de86d --- /dev/null +++ b/mcp/internal/config/config.go @@ -0,0 +1,339 @@ +// Package config loads and validates the httpSMS MCP service's runtime +// configuration from environment variables. Load returns a single error +// naming every missing or invalid setting so misconfiguration fails fast at +// startup instead of surfacing as a confusing runtime error later. +package config + +import ( + "encoding/pem" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// Environment values recognized by Load. Production enforces HTTPS on every +// configured URL; any other value is treated as a local/test environment. +const ( + EnvironmentProduction = "production" + defaultEnvironment = "local" +) + +// Default values used when their corresponding environment variable is unset. +const ( + defaultPort = "8080" + defaultAccessTokenTTL = 15 * time.Minute + defaultAPIDelegationTokenTTL = 2 * time.Minute + defaultAuthorizationCodeTTL = 2 * time.Minute + defaultRefreshTokenTTL = 30 * 24 * time.Hour + defaultConfirmationTTL = 5 * time.Minute + defaultHTTPTimeout = 10 * time.Second + defaultReadToolsPerMinute = 120 + defaultSendToolsPerMinute = 30 + defaultKeyCreatesPerHour = 10 + defaultKeyRotationsPerHour = 3 + defaultFirebaseCertsURL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com" +) + +// Config is the validated runtime configuration for the httpSMS MCP service. +type Config struct { + // Environment is "production" or a local/development/test value. It + // controls whether Load enforces HTTPS on every configured URL. + Environment string + + // Port is the TCP port the HTTP server listens on (Cloud Run supplies + // this through the PORT environment variable). + Port string + + // BaseURL is this MCP service's own public base URL, e.g. + // "https://mcp.httpsms.com". It is used as the issuer of every JWT this + // service mints. + BaseURL *url.URL + + // APIURL is the httpSMS API's base URL this service calls on behalf of + // authenticated users, e.g. "https://api.httpsms.com". + APIURL *url.URL + + // RedisURL is the connection string for the Redis instance backing + // OAuth authorization/refresh-token state and confirmation handles. + RedisURL string + + // FirebaseProjectID is the Firebase project used to verify user ID + // tokens during the browser login step of the authorization flow. + FirebaseProjectID string + + // FirebaseAPIKey is the Firebase Web API key used by the hosted login + // page's client-side Firebase SDK. + FirebaseAPIKey string + + // FirebaseAuthDomain is the Firebase Auth domain used by the hosted + // login page's client-side Firebase SDK. + FirebaseAuthDomain string + + // FirebaseCertsURL is the JWKS endpoint used to verify Firebase ID + // token signatures. + FirebaseCertsURL *url.URL + + // SigningPrivateKeyPEM is the PEM-encoded RSA private key this service + // signs MCP access tokens and API delegation tokens with. + SigningPrivateKeyPEM []byte + + // SigningKeyID is the `kid` this service signs with and publishes in + // its JWKS document. + SigningKeyID string + + // MCPAudience is the audience MCP access tokens are bound to, e.g. + // "https://mcp.httpsms.com/mcp". + MCPAudience string + + // APIAudience is the audience API delegation tokens are bound to. It + // must match the httpSMS API's configured API_AUDIENCE. + APIAudience string + + // AccessTokenTTL is how long a minted MCP access token is valid. + AccessTokenTTL time.Duration + + // APIDelegationTokenTTL is how long a minted downstream API delegation + // token is valid. + APIDelegationTokenTTL time.Duration + + // AuthorizationCodeTTL is how long an issued OAuth authorization code + // remains redeemable. + AuthorizationCodeTTL time.Duration + + // RefreshTokenTTL is how long an issued OAuth refresh token remains + // valid. + RefreshTokenTTL time.Duration + + // ConfirmationTTL is how long a primary API-key-rotation confirmation + // handle remains redeemable. + ConfirmationTTL time.Duration + + // HTTPTimeout bounds every outbound HTTP call this service makes to the + // httpSMS API or to OAuth client metadata documents. + HTTPTimeout time.Duration + + // ReadToolsPerMinute is the per-user rate limit applied to read-only MCP + // tools (list phones, threads, messages). + ReadToolsPerMinute int + + // SendToolsPerMinute is the per-user rate limit applied to the send_sms + // MCP tool. + SendToolsPerMinute int + + // KeyCreatesPerHour is the per-user rate limit applied to the + // create_phone_api_key MCP tool. + KeyCreatesPerHour int + + // KeyRotationsPerHour is the per-user rate limit applied to the + // rotate_user_api_key MCP tool. + KeyRotationsPerHour int +} + +// Load reads and validates the MCP service configuration from environment +// variables. It returns a single error naming every missing or invalid +// setting. +func Load() (Config, error) { + var problems []string + add := func(problem string) { problems = append(problems, problem) } + + environment := stringEnv("ENV", defaultEnvironment) + production := environment == EnvironmentProduction + + cfg := Config{ + Environment: environment, + Port: stringEnv("PORT", defaultPort), + } + + cfg.BaseURL = requiredURL("MCP_BASE_URL", production, add) + cfg.APIURL = requiredURL("HTTPSMS_API_URL", production, add) + + cfg.RedisURL = requiredString("REDIS_URL", add) + + cfg.FirebaseProjectID = requiredString("FIREBASE_PROJECT_ID", add) + cfg.FirebaseAPIKey = requiredString("FIREBASE_API_KEY", add) + cfg.FirebaseAuthDomain = requiredString("FIREBASE_AUTH_DOMAIN", add) + cfg.FirebaseCertsURL = optionalURL("FIREBASE_CERTS_URL", defaultFirebaseCertsURL, production, add) + + cfg.SigningPrivateKeyPEM = loadSigningPrivateKeyPEM(add) + cfg.SigningKeyID = requiredString("MCP_SIGNING_KEY_ID", add) + + if cfg.BaseURL != nil { + cfg.MCPAudience = stringEnv("MCP_AUDIENCE", strings.TrimRight(cfg.BaseURL.String(), "/")+"/mcp") + } else { + cfg.MCPAudience = os.Getenv("MCP_AUDIENCE") + } + if cfg.APIURL != nil { + cfg.APIAudience = stringEnv("API_AUDIENCE", strings.TrimRight(cfg.APIURL.String(), "/")) + } else { + cfg.APIAudience = os.Getenv("API_AUDIENCE") + } + + cfg.AccessTokenTTL = durationEnv("MCP_ACCESS_TOKEN_TTL", defaultAccessTokenTTL, add) + cfg.APIDelegationTokenTTL = durationEnv("API_DELEGATION_TOKEN_TTL", defaultAPIDelegationTokenTTL, add) + cfg.AuthorizationCodeTTL = durationEnv("AUTHORIZATION_CODE_TTL", defaultAuthorizationCodeTTL, add) + cfg.RefreshTokenTTL = durationEnv("REFRESH_TOKEN_TTL", defaultRefreshTokenTTL, add) + cfg.ConfirmationTTL = durationEnv("CONFIRMATION_TTL", defaultConfirmationTTL, add) + cfg.HTTPTimeout = durationEnv("HTTP_TIMEOUT", defaultHTTPTimeout, add) + + cfg.ReadToolsPerMinute = intEnv("READ_TOOLS_PER_MINUTE", defaultReadToolsPerMinute, add) + cfg.SendToolsPerMinute = intEnv("SEND_TOOLS_PER_MINUTE", defaultSendToolsPerMinute, add) + cfg.KeyCreatesPerHour = intEnv("KEY_CREATES_PER_HOUR", defaultKeyCreatesPerHour, add) + cfg.KeyRotationsPerHour = intEnv("KEY_ROTATIONS_PER_HOUR", defaultKeyRotationsPerHour, add) + + if len(problems) > 0 { + return Config{}, fmt.Errorf("config: invalid configuration: %s", strings.Join(problems, "; ")) + } + + return cfg, nil +} + +// stringEnv returns the environment variable named key, or fallback when it +// is unset or empty. +func stringEnv(key string, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +// requiredString returns the environment variable named key, recording a +// problem through add when it is unset or empty. +func requiredString(key string, add func(string)) string { + value := os.Getenv(key) + if value == "" { + add(fmt.Sprintf("%s is required", key)) + } + return value +} + +// requiredURL parses the environment variable named key as an absolute URL, +// recording a problem through add when it is unset, invalid, or (in +// production) not HTTPS. +func requiredURL(key string, production bool, add func(string)) *url.URL { + raw := os.Getenv(key) + if raw == "" { + add(fmt.Sprintf("%s is required", key)) + return nil + } + + parsed, err := parseAbsoluteURL(key, raw, production) + if err != nil { + add(err.Error()) + return nil + } + return parsed +} + +// optionalURL parses the environment variable named key as an absolute URL, +// falling back to fallback when it is unset, and recording a problem through +// add when the resulting value is invalid or (in production) not HTTPS. +func optionalURL(key string, fallback string, production bool, add func(string)) *url.URL { + raw := stringEnv(key, fallback) + parsed, err := parseAbsoluteURL(key, raw, production) + if err != nil { + add(err.Error()) + return nil + } + return parsed +} + +// parseAbsoluteURL parses raw as an absolute http(s) URL and, when +// production is true, requires the "https" scheme. +func parseAbsoluteURL(key string, raw string, production bool) (*url.URL, error) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("%s must be an absolute URL, got %q", key, raw) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("%s must use http or https, got %q", key, raw) + } + if production && parsed.Scheme != "https" { + return nil, fmt.Errorf("%s must use https in production, got %q", key, raw) + } + return parsed, nil +} + +// loadSigningPrivateKeyPEM loads the RSA signing key material from either +// MCP_SIGNING_PRIVATE_KEY or MCP_SIGNING_PRIVATE_KEY_FILE, recording a +// problem through add when neither, both, or an unreadable/malformed value is +// configured. +func loadSigningPrivateKeyPEM(add func(string)) []byte { + inline := os.Getenv("MCP_SIGNING_PRIVATE_KEY") + file := os.Getenv("MCP_SIGNING_PRIVATE_KEY_FILE") + + switch { + case inline != "" && file != "": + add("only one of MCP_SIGNING_PRIVATE_KEY or MCP_SIGNING_PRIVATE_KEY_FILE may be set, not both") + return nil + case inline != "": + return validatePEM("MCP_SIGNING_PRIVATE_KEY", []byte(inline), add) + case file != "": + contents, err := os.ReadFile(file) + if err != nil { + add(fmt.Sprintf("cannot read MCP_SIGNING_PRIVATE_KEY_FILE %q: %v", file, err)) + return nil + } + return validatePEM("MCP_SIGNING_PRIVATE_KEY_FILE", contents, add) + default: + add("one of MCP_SIGNING_PRIVATE_KEY or MCP_SIGNING_PRIVATE_KEY_FILE is required") + return nil + } +} + +// validatePEM confirms keyPEM decodes as a PEM block. It does not parse the +// key's ASN.1 structure or enforce key type/size; that validation belongs to +// auth.NewKeySet, which is the single source of truth for what key material +// this service accepts. +func validatePEM(key string, keyPEM []byte, add func(string)) []byte { + block, _ := pem.Decode(keyPEM) + if block == nil { + add(fmt.Sprintf("%s does not contain a PEM-encoded private key", key)) + return nil + } + return keyPEM +} + +// durationEnv parses the environment variable named key as a time.Duration, +// falling back to fallback when unset and recording a problem through add +// when set but invalid or not positive. +func durationEnv(key string, fallback time.Duration, add func(string)) time.Duration { + raw := os.Getenv(key) + if raw == "" { + return fallback + } + + value, err := time.ParseDuration(raw) + if err != nil { + add(fmt.Sprintf("%s must be a valid duration, got %q", key, raw)) + return fallback + } + if value <= 0 { + add(fmt.Sprintf("%s must be positive, got %q", key, raw)) + return fallback + } + return value +} + +// intEnv parses the environment variable named key as a positive int, +// falling back to fallback when unset and recording a problem through add +// when set but invalid or not positive. +func intEnv(key string, fallback int, add func(string)) int { + raw := os.Getenv(key) + if raw == "" { + return fallback + } + + value, err := strconv.Atoi(raw) + if err != nil { + add(fmt.Sprintf("%s must be a valid integer, got %q", key, raw)) + return fallback + } + if value <= 0 { + add(fmt.Sprintf("%s must be positive, got %q", key, raw)) + return fallback + } + return value +} diff --git a/mcp/internal/config/config_test.go b/mcp/internal/config/config_test.go new file mode 100644 index 00000000..a6ac5b12 --- /dev/null +++ b/mcp/internal/config/config_test.go @@ -0,0 +1,312 @@ +package config_test + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/config" +) + +// setValidEnv sets every environment variable Load requires to succeed, so +// individual tests can override or unset just the one setting under test. +func setValidEnv(t *testing.T) { + t.Helper() + + t.Setenv("ENV", "local") + t.Setenv("MCP_BASE_URL", "https://mcp.httpsms.com") + t.Setenv("HTTPSMS_API_URL", "https://api.httpsms.com") + t.Setenv("REDIS_URL", "redis://localhost:6379") + t.Setenv("FIREBASE_PROJECT_ID", "httpsms") + t.Setenv("FIREBASE_API_KEY", "test-firebase-api-key") + t.Setenv("FIREBASE_AUTH_DOMAIN", "httpsms.firebaseapp.com") + t.Setenv("MCP_SIGNING_PRIVATE_KEY", testPrivateKeyPEM) + t.Setenv("MCP_SIGNING_PRIVATE_KEY_FILE", "") + t.Setenv("MCP_SIGNING_KEY_ID", "test-key-1") +} + +func TestLoadSucceedsWithAValidEnvironment(t *testing.T) { + setValidEnv(t) + + cfg, err := config.Load() + + require.NoError(t, err) + assert.Equal(t, "local", cfg.Environment) + assert.Equal(t, "8080", cfg.Port) + assert.Equal(t, "https://mcp.httpsms.com", cfg.BaseURL.String()) + assert.Equal(t, "https://api.httpsms.com", cfg.APIURL.String()) + assert.Equal(t, "redis://localhost:6379", cfg.RedisURL) + assert.Equal(t, "httpsms", cfg.FirebaseProjectID) + assert.Equal(t, "test-key-1", cfg.SigningKeyID) + assert.Equal(t, []byte(testPrivateKeyPEM), cfg.SigningPrivateKeyPEM) + assert.Equal(t, "https://mcp.httpsms.com/mcp", cfg.MCPAudience) + assert.Equal(t, "https://api.httpsms.com", cfg.APIAudience) + assert.Equal(t, 15*time.Minute, cfg.AccessTokenTTL) + assert.Equal(t, 2*time.Minute, cfg.APIDelegationTokenTTL) + assert.Equal(t, 2*time.Minute, cfg.AuthorizationCodeTTL) + assert.Equal(t, 30*24*time.Hour, cfg.RefreshTokenTTL) + assert.Equal(t, 5*time.Minute, cfg.ConfirmationTTL) + assert.Equal(t, 10*time.Second, cfg.HTTPTimeout) + assert.Equal(t, 120, cfg.ReadToolsPerMinute) + assert.Equal(t, 30, cfg.SendToolsPerMinute) + assert.Equal(t, 10, cfg.KeyCreatesPerHour) + assert.Equal(t, 3, cfg.KeyRotationsPerHour) + assert.Equal(t, "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", cfg.FirebaseCertsURL.String()) +} + +func TestLoadRejectsPartialConfiguration(t *testing.T) { + setValidEnv(t) + t.Setenv("HTTPSMS_API_URL", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "HTTPSMS_API_URL") +} + +func TestLoadRejectsMissingBaseURL(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_BASE_URL", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_BASE_URL") +} + +func TestLoadRejectsInvalidBaseURL(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_BASE_URL", "not-a-url") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_BASE_URL") +} + +func TestLoadRejectsMissingRedisURL(t *testing.T) { + setValidEnv(t) + t.Setenv("REDIS_URL", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "REDIS_URL") +} + +func TestLoadRejectsMissingFirebaseProjectID(t *testing.T) { + setValidEnv(t) + t.Setenv("FIREBASE_PROJECT_ID", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "FIREBASE_PROJECT_ID") +} + +func TestLoadRejectsMissingFirebaseAPIKey(t *testing.T) { + setValidEnv(t) + t.Setenv("FIREBASE_API_KEY", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "FIREBASE_API_KEY") +} + +func TestLoadRejectsMissingFirebaseAuthDomain(t *testing.T) { + setValidEnv(t) + t.Setenv("FIREBASE_AUTH_DOMAIN", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "FIREBASE_AUTH_DOMAIN") +} + +func TestLoadRejectsMissingSigningKeyID(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_SIGNING_KEY_ID", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_SIGNING_KEY_ID") +} + +func TestLoadRejectsMissingSigningKeyMaterial(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_SIGNING_PRIVATE_KEY", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_SIGNING_PRIVATE_KEY") +} + +func TestLoadRejectsBothSigningKeyEnvAndFileSet(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_SIGNING_PRIVATE_KEY_FILE", "some-file.pem") + + _, err := config.Load() + + require.ErrorContains(t, err, "not both") +} + +func TestLoadRejectsMalformedSigningKeyPEM(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_SIGNING_PRIVATE_KEY", "not a pem block") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_SIGNING_PRIVATE_KEY") +} + +func TestLoadReadsSigningKeyFromFile(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_SIGNING_PRIVATE_KEY", "") + keyFile := writeTempKeyFile(t, testPrivateKeyPEM) + t.Setenv("MCP_SIGNING_PRIVATE_KEY_FILE", keyFile) + + cfg, err := config.Load() + + require.NoError(t, err) + assert.Equal(t, []byte(testPrivateKeyPEM), cfg.SigningPrivateKeyPEM) +} + +func TestLoadRejectsUnreadableSigningKeyFile(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_SIGNING_PRIVATE_KEY", "") + t.Setenv("MCP_SIGNING_PRIVATE_KEY_FILE", "does-not-exist.pem") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_SIGNING_PRIVATE_KEY_FILE") +} + +func TestLoadRejectsInvalidAccessTokenTTL(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_ACCESS_TOKEN_TTL", "not-a-duration") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_ACCESS_TOKEN_TTL") +} + +func TestLoadRejectsNonPositiveRefreshTokenTTL(t *testing.T) { + setValidEnv(t) + t.Setenv("REFRESH_TOKEN_TTL", "0s") + + _, err := config.Load() + + require.ErrorContains(t, err, "REFRESH_TOKEN_TTL") +} + +func TestLoadRejectsInvalidHTTPTimeout(t *testing.T) { + setValidEnv(t) + t.Setenv("HTTP_TIMEOUT", "-5s") + + _, err := config.Load() + + require.ErrorContains(t, err, "HTTP_TIMEOUT") +} + +func TestLoadRequiresHTTPSForEveryURLInProduction(t *testing.T) { + setValidEnv(t) + t.Setenv("ENV", "production") + t.Setenv("MCP_BASE_URL", "http://mcp.httpsms.com") + + _, err := config.Load() + + require.ErrorContains(t, err, "MCP_BASE_URL") + require.ErrorContains(t, err, "https") +} + +func TestLoadRequiresHTTPSForAPIURLInProduction(t *testing.T) { + setValidEnv(t) + t.Setenv("ENV", "production") + t.Setenv("HTTPSMS_API_URL", "http://api.httpsms.com") + + _, err := config.Load() + + require.ErrorContains(t, err, "HTTPSMS_API_URL") + require.ErrorContains(t, err, "https") +} + +func TestLoadAllowsHTTPURLsOutsideProduction(t *testing.T) { + setValidEnv(t) + t.Setenv("MCP_BASE_URL", "http://localhost:8090") + t.Setenv("HTTPSMS_API_URL", "http://localhost:8000") + + cfg, err := config.Load() + + require.NoError(t, err) + assert.Equal(t, "http", cfg.BaseURL.Scheme) + assert.Equal(t, "http", cfg.APIURL.Scheme) +} + +func TestLoadOverridesRateLimitDefaults(t *testing.T) { + setValidEnv(t) + t.Setenv("READ_TOOLS_PER_MINUTE", "60") + t.Setenv("SEND_TOOLS_PER_MINUTE", "15") + t.Setenv("KEY_CREATES_PER_HOUR", "5") + t.Setenv("KEY_ROTATIONS_PER_HOUR", "1") + + cfg, err := config.Load() + + require.NoError(t, err) + assert.Equal(t, 60, cfg.ReadToolsPerMinute) + assert.Equal(t, 15, cfg.SendToolsPerMinute) + assert.Equal(t, 5, cfg.KeyCreatesPerHour) + assert.Equal(t, 1, cfg.KeyRotationsPerHour) +} + +func TestLoadReportsEveryProblemAtOnce(t *testing.T) { + setValidEnv(t) + t.Setenv("HTTPSMS_API_URL", "") + t.Setenv("REDIS_URL", "") + + _, err := config.Load() + + require.ErrorContains(t, err, "HTTPSMS_API_URL") + require.ErrorContains(t, err, "REDIS_URL") +} + +// testPrivateKeyPEM is a throwaway 2048-bit RSA private key used only to +// exercise config.Load's PEM validation. It is not used to sign anything and +// is not the same key used by any other package's tests. +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAsaRrsPaMlhkOb2j7UOaCShBBZNZ5nz0AGJq3HHW92Rd+VQ3l +/vl1Zed0laz9lUyxWqR6vVR0fuK5reBVaN1GYHV9GgT9x1HM9cTg6eN0n8qpblWo +DBKq8Qi4o2D7sNr2tl3SWbrUfKaKnBd6bFRHihJyEZXwc6zCXoPQ7eBQ7ozy99g7 +nyXtBse5Z5VY563W+hRbqOqHzzZ3qFwDv1Gy0VQZuMz2Paik1cY+XhVIdA2D3pAh +UxDxG1TYkBKxsLuM+LmH3HgUGba+Pu9QGYe8PaH5SqGGX3EZxLDyClaaxQgmsZpt +KzlwNSZk2sPAvCrYQxto8gelflYPw0jOSX/6EQIDAQABAoIBAEhQrcJZa7vCsXyr +GPvDCrEJ0wUwxkwLshlSCk7co49XoAcR5FoaxS7ZvT0dMhHwKZbDtG+UjOQGeh4N +X9eTlI255laMR583bp9yKTktbhGKl9ShrApWIx6CNV/VIEDLsnlk0jfS9aNUzMJk +UGL/ICxV+/equTrtziZZtNjRY0DolFbo7swFhwey9K4bT7JGl5W+fpRLz3ucjN0z +mBU7yI6CAM7YXH0kR4DXSZKiEUZ8xf0fbbraBpjbrA9hTVSWvouEtBJfyIjs6oXy +ktchAWydNILqjiQzsNWLI/Vt3PdG9Gs2QT7ZpDxjuOiP7J9CDphDqhLoutD+bHJ8 +K4i+s/0CgYEAzaRj9KVt8x8IGv68gHShL+4eqXB6MAItLYFlLMDWWo5QK3l7ppFi +dwHf3GpAdftQxzCy/R2TARu9oC822DiJJE+8YFci9uIH06adW1nqMPdIorPkvF8Y +fKB7Sudw/2ILjeT0wg2AAaDw2VutVvSEpm5j9zA0NSUyKhNYt5thXbcCgYEA3SS7 +FfFM3EWhsjlKoa6RY6djTZzt7osMGy8u52nqPiFZR7fCbhrxJYROh2UmFn0/J8RB +gLoHN4ZbmBze6cro8aTScFmz7cK6bT/eCLq0NopAL+OFP9jGkawo5UMZ7/hfBX8P +gMoBD97VkTZw75uAyuVwbKMfPKF6lsFKUMNN5ncCgYEAxawQ3TksAHjC3NgjMMNr +sdwOI0fYXE+rR8PLEoLnSbLlA3VKU+oKoWTu4DxObFrA4khAtah5B6a318Oqz5tA +0OPIqz73gCPz7BKLziUXRixd6PBNnnk2242UFoN1Djgb7TC5ydMaSfZ/riA+9ogi +/qy8cP8oIDH6D5H7RLsak+8CgYEAiPzY25XXS9fiezmcLp2puHaXQBvHE+6UeD55 +KqbkkMotuQxu56/O07OqxZp1xpadSa/795bFI7MaCBdSSrcEJ7Q3G5ulptHqlARt +MTEes25epoulHlDVaKWhy6sOZSWRDyGPY/M+Ryt9Vm/H89V7KbSJOPKvReqturdP +psnk9q8CgYB0knFbkzt3R7mowiiXqj4MhfO4baCPk9PeOslujQIJoX1Ca+/wQdox +F2m9w4bRMrdsT19eMrRZsJYslJc6s2tNlCuUDMgFk3FUrmpFDQlq/taUCB/wDUxp +3SBuTr9BHx8yJc9p6hYkjI3HZ+aqsImZIxN/23OFEvtOH2z3m8JPnA== +-----END RSA PRIVATE KEY----- +` + +// writeTempKeyFile writes contents to a new file inside t.TempDir() and +// returns its path. +func writeTempKeyFile(t *testing.T, contents string) string { + t.Helper() + + dir := t.TempDir() + path := dir + "/signing-key.pem" + + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + + return path +} diff --git a/mcp/internal/httpsms/client.go b/mcp/internal/httpsms/client.go new file mode 100644 index 00000000..6d79ff58 --- /dev/null +++ b/mcp/internal/httpsms/client.go @@ -0,0 +1,451 @@ +package httpsms + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +const ( + // requestIDHeader is the header this client sets on every outgoing + // request with a per-call, client-generated identifier, so a returned + // APIError can be correlated with the client-side log line and trace + // span that issued the call. The httpSMS API does not currently read or + // echo this header back. + requestIDHeader = "X-Request-Id" + + // maxResponseBytes bounds how much of a response body this client will + // read, so a misbehaving or unexpectedly large upstream response cannot + // exhaust memory. + maxResponseBytes = 2 * 1024 * 1024 // 2 MiB + + // requestTimeout bounds the total time (dial, TLS, request, and + // response) any single call to the httpSMS API is allowed to take, on + // top of whatever deadline the caller's context already carries. + requestTimeout = 15 * time.Second + + // dialTimeout bounds how long TCP connection establishment (DNS + // resolution plus connect) may take for a single dial, independently + // of the overall requestTimeout, so a slow or black-holed network path + // fails fast instead of consuming the whole request budget on dialing + // alone. + dialTimeout = 5 * time.Second + + // tlsHandshakeTimeout bounds how long the TLS handshake may take once a + // TCP connection is established. + tlsHandshakeTimeout = 5 * time.Second + + // responseHeaderTimeout bounds how long this client waits for the + // response status line and headers after the request (including its + // body, if any) has been fully written, so a server that accepts a + // connection but never responds cannot hold a call open until the + // overall requestTimeout. + responseHeaderTimeout = 10 * time.Second + + maxIdleConns = 100 + maxIdleConnsPerHost = 10 + idleConnTimeout = 90 * time.Second +) + +// Client is the typed httpSMS API client used by every MCP tool. Every +// method takes a delegated API bearer token minted by the caller for this +// exact operation (never minted, cached, or inspected by the client) and +// the parameters that operation supports. +type Client interface { + // ListPhones calls GET /v1/phones. + ListPhones(ctx context.Context, token string, params ListPhonesParams) ([]Phone, error) + + // SendSMS calls POST /v1/messages/send. + SendSMS(ctx context.Context, token string, params SendSMSParams) (Message, error) + + // ListMessageThreads calls GET /v1/message-threads. + ListMessageThreads(ctx context.Context, token string, params ListMessageThreadsParams) ([]MessageThread, error) + + // ListThreadMessages calls GET /v1/messages. + ListThreadMessages(ctx context.Context, token string, params ListThreadMessagesParams) ([]Message, error) + + // ListIncomingMessages calls GET /v1/messages/incoming. + ListIncomingMessages(ctx context.Context, token string, params ListIncomingMessagesParams) ([]Message, error) + + // CreatePhoneAPIKey calls POST /v1/phone-api-keys. + CreatePhoneAPIKey(ctx context.Context, token string, params CreatePhoneAPIKeyParams) (PhoneAPIKey, error) + + // RotateUserAPIKey calls DELETE /v1/users/{userID}/api-keys. + RotateUserAPIKey(ctx context.Context, token string, userID string) (User, error) +} + +// HTTPClient is the Client implementation calling the httpSMS HTTP API. +type HTTPClient struct { + baseURL string + httpClient *http.Client +} + +var _ Client = (*HTTPClient)(nil) + +// ClientOption customizes an *HTTPClient built by NewClient. Options exist +// so a caller can override a bounded default (today: the overall per-call +// timeout) without NewClient growing a parameter every deployment-specific +// knob, and without any option being able to remove a bound entirely. +type ClientOption func(*clientOptions) + +// clientOptions is the resolved set of NewClient overrides. +type clientOptions struct { + // timeout overrides the overall per-call timeout. A non-positive + // value is ignored, so an option can never disable the timeout. + timeout time.Duration +} + +// WithTimeout overrides the overall per-call timeout (dial, TLS, request, +// and response) for every call the returned client makes. A non-positive +// timeout is ignored and the built-in default (requestTimeout) is kept: a +// client with no overall deadline could hang a tool call until the MCP +// request itself times out, which is never what a caller wants. +// +// The response-header timeout is clamped to at most this value, so a +// shorter overall timeout is actually enforced at the point a server stops +// responding rather than only at the very end of the call. +func WithTimeout(timeout time.Duration) ClientOption { + return func(options *clientOptions) { + if timeout > 0 { + options.timeout = timeout + } + } +} + +// NewClient returns an *HTTPClient calling baseURL (for example +// "https://api.httpsms.com"). The returned client is bounded and makes a +// single attempt per call: an explicit overall request timeout plus +// separate dial, TLS handshake, and response header timeouts, a +// size-limited connection pool, OpenTelemetry context propagation through +// otelhttp.Transport (with query string values redacted from span +// attributes; see queryRedactingTransport), and no automatic retries. +// Retrying automatically would risk duplicating the side effect of a +// non-idempotent call such as sending an SMS, creating a phone API key, or +// rotating the user's primary API key. +// +// Called with no options, it keeps exactly the defaults it has always had; +// see WithTimeout to override the overall per-call timeout. +func NewClient(baseURL string, opts ...ClientOption) *HTTPClient { + options := clientOptions{timeout: requestTimeout} + for _, opt := range opts { + opt(&options) + } + + transport := &http.Transport{ + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + IdleConnTimeout: idleConnTimeout, + TLSHandshakeTimeout: tlsHandshakeTimeout, + ResponseHeaderTimeout: min(responseHeaderTimeout, options.timeout), + DialContext: (&net.Dialer{ + Timeout: dialTimeout, + }).DialContext, + } + + instrumented := otelhttp.NewTransport(&queryRestoringTransport{base: transport}) + + return &HTTPClient{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: options.timeout, + Transport: &queryRedactingTransport{next: instrumented}, + }, + } +} + +// ListPhones calls GET /v1/phones. +func (c *HTTPClient) ListPhones(ctx context.Context, token string, params ListPhonesParams) ([]Phone, error) { + query := url.Values{} + setIntIfPositive(query, "skip", params.Skip) + setStringIfNotEmpty(query, "query", params.Query) + setIntIfPositive(query, "limit", params.Limit) + + var phones []Phone + if err := c.do(ctx, token, http.MethodGet, "/v1/phones", query, nil, &phones); err != nil { + return nil, err + } + return phones, nil +} + +// messageSendRequest is the wire body for POST /v1/messages/send. Its JSON +// field names are a contract with api/pkg/requests.MessageSend and must not +// change independently of it. +type messageSendRequest struct { + From string `json:"from"` + To string `json:"to"` + Content string `json:"content"` + Attachments []string `json:"attachments,omitempty"` + Encrypted bool `json:"encrypted,omitempty"` + RequestID string `json:"request_id,omitempty"` + SendAt *time.Time `json:"send_at,omitempty"` +} + +// SendSMS calls POST /v1/messages/send. +func (c *HTTPClient) SendSMS(ctx context.Context, token string, params SendSMSParams) (Message, error) { + body := messageSendRequest{ + From: params.From, + To: params.To, + Content: params.Content, + Attachments: params.Attachments, + Encrypted: params.Encrypted, + RequestID: params.RequestID, + SendAt: params.SendAt, + } + + var message Message + if err := c.do(ctx, token, http.MethodPost, "/v1/messages/send", nil, body, &message); err != nil { + return Message{}, err + } + return message, nil +} + +// ListMessageThreads calls GET /v1/message-threads. +func (c *HTTPClient) ListMessageThreads(ctx context.Context, token string, params ListMessageThreadsParams) ([]MessageThread, error) { + query := url.Values{} + setStringIfNotEmpty(query, "owner", params.Owner) + setBoolPointer(query, "is_archived", params.IsArchived) + setBoolIfTrue(query, "contacts", params.WithContacts) + setStringIfNotEmpty(query, "query", params.Query) + setIntIfPositive(query, "skip", params.Skip) + setIntIfPositive(query, "limit", params.Limit) + + var threads []MessageThread + if err := c.do(ctx, token, http.MethodGet, "/v1/message-threads", query, nil, &threads); err != nil { + return nil, err + } + return threads, nil +} + +// ListThreadMessages calls GET /v1/messages. +func (c *HTTPClient) ListThreadMessages(ctx context.Context, token string, params ListThreadMessagesParams) ([]Message, error) { + query := url.Values{} + setStringIfNotEmpty(query, "owner", params.Owner) + setStringIfNotEmpty(query, "contact", params.Contact) + setStringIfNotEmpty(query, "query", params.Query) + setIntIfPositive(query, "skip", params.Skip) + setIntIfPositive(query, "limit", params.Limit) + + var messages []Message + if err := c.do(ctx, token, http.MethodGet, "/v1/messages", query, nil, &messages); err != nil { + return nil, err + } + return messages, nil +} + +// ListIncomingMessages calls GET /v1/messages/incoming. +func (c *HTTPClient) ListIncomingMessages(ctx context.Context, token string, params ListIncomingMessagesParams) ([]Message, error) { + query := url.Values{} + setRepeated(query, "owners", params.Owners) + setRepeated(query, "statuses", params.Statuses) + setStringIfNotEmpty(query, "query", params.Query) + setStringIfNotEmpty(query, "sort_by", params.SortBy) + setBoolPointer(query, "sort_descending", params.SortDescending) + setIntIfPositive(query, "skip", params.Skip) + setIntIfPositive(query, "limit", params.Limit) + + var messages []Message + if err := c.do(ctx, token, http.MethodGet, "/v1/messages/incoming", query, nil, &messages); err != nil { + return nil, err + } + return messages, nil +} + +// phoneAPIKeyStoreRequest is the wire body for POST /v1/phone-api-keys. Its +// JSON field names are a contract with +// api/pkg/requests.PhoneAPIKeyStoreRequest and must not change +// independently of it. +type phoneAPIKeyStoreRequest struct { + Name string `json:"name"` +} + +// CreatePhoneAPIKey calls POST /v1/phone-api-keys. +func (c *HTTPClient) CreatePhoneAPIKey(ctx context.Context, token string, params CreatePhoneAPIKeyParams) (PhoneAPIKey, error) { + body := phoneAPIKeyStoreRequest{Name: params.Name} + + var key PhoneAPIKey + if err := c.do(ctx, token, http.MethodPost, "/v1/phone-api-keys", nil, body, &key); err != nil { + return PhoneAPIKey{}, err + } + return key, nil +} + +// RotateUserAPIKey calls DELETE /v1/users/{userID}/api-keys. userID is +// always the authenticated subject's own Firebase UID; callers must never +// accept it as untrusted tool input. +func (c *HTTPClient) RotateUserAPIKey(ctx context.Context, token string, userID string) (User, error) { + path := "/v1/users/" + url.PathEscape(userID) + "/api-keys" + + var user User + if err := c.do(ctx, token, http.MethodDelete, path, nil, nil, &user); err != nil { + return User{}, err + } + return user, nil +} + +// do issues a single, bounded HTTP request against the httpSMS API, +// authenticated with token, and decodes the response envelope's "data" +// field into output. +// +// input, when non-nil, is JSON-encoded as the request body and a +// "Content-Type: application/json" header is sent. output, when non-nil, +// receives the decoded "data" field of a successful response. +// +// do makes exactly one attempt: it never retries, so callers can safely use +// it for non-idempotent operations (sending an SMS, creating a phone API +// key, rotating the primary API key) without risking a duplicated side +// effect from a transport-level retry. +func (c *HTTPClient) do( + ctx context.Context, + token string, + method string, + path string, + query url.Values, + input any, + output any, +) error { + requestID := uuid.NewString() + + fullURL := c.baseURL + path + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + + var bodyReader io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("httpsms: cannot encode request body: %w", err) + } + bodyReader = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return fmt.Errorf("httpsms: cannot build request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set(requestIDHeader, requestID) + if input != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + // http.Client errors may wrap the request URL (never a secret: the + // bearer token is a header, not part of the URL) but never the + // request body or headers, so it is safe to wrap here. + return fmt.Errorf("httpsms: request [%s] failed: %w", requestID, err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return &APIError{StatusCode: resp.StatusCode, RequestID: requestID, Message: "cannot read httpSMS API response"} + } + if len(raw) > maxResponseBytes { + return &APIError{StatusCode: resp.StatusCode, RequestID: requestID, Message: "httpSMS API response exceeded the maximum allowed size"} + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return parseAPIError(resp.StatusCode, requestID, raw) + } + + if output == nil { + return nil + } + + var envelope Response[json.RawMessage] + if err := json.Unmarshal(raw, &envelope); err != nil { + return &APIError{StatusCode: resp.StatusCode, RequestID: requestID, Message: "cannot decode httpSMS API response"} + } + + if err := json.Unmarshal(envelope.Data, output); err != nil { + return &APIError{StatusCode: resp.StatusCode, RequestID: requestID, Message: "cannot decode httpSMS API response data"} + } + + return nil +} + +// parseAPIError decodes a non-2xx httpSMS API response body into an +// *APIError. It never fails: a malformed or unexpected body still yields an +// *APIError with a generic message rather than an opaque decode error, +// since the caller already knows the call failed from the status code. +func parseAPIError(statusCode int, requestID string, raw []byte) error { + apiErr := &APIError{StatusCode: statusCode, RequestID: requestID} + + var envelope struct { + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + apiErr.Message = "httpSMS API returned a malformed error response" + return apiErr + } + + apiErr.Message = envelope.Message + if apiErr.Message == "" { + apiErr.Message = "httpSMS API request failed" + } + + var fields map[string][]string + if len(envelope.Data) > 0 && json.Unmarshal(envelope.Data, &fields) == nil { + apiErr.Fields = fields + } + + return apiErr +} + +// setIntIfPositive sets key to value's decimal string form only when value +// is greater than zero, so a caller's zero value (indistinguishable from +// "not set") is omitted and the API applies its own default. +func setIntIfPositive(values url.Values, key string, value int) { + if value > 0 { + values.Set(key, strconv.Itoa(value)) + } +} + +// setStringIfNotEmpty sets key to value only when value is non-empty. +func setStringIfNotEmpty(values url.Values, key string, value string) { + if value != "" { + values.Set(key, value) + } +} + +// setBoolPointer sets key to value's string form only when value is +// non-nil, so an unset optional filter is omitted rather than sent as +// "false". +func setBoolPointer(values url.Values, key string, value *bool) { + if value != nil { + values.Set(key, strconv.FormatBool(*value)) + } +} + +// setBoolIfTrue sets key to "true" only when value is true, so a filter +// whose zero value already matches the API's default is omitted. +func setBoolIfTrue(values url.Values, key string, value bool) { + if value { + values.Set(key, "true") + } +} + +// setRepeated adds one query value per item in items under key, matching +// the repeated-key encoding the API's query binder expects for []string +// fields (for example "owners=a&owners=b"). +func setRepeated(values url.Values, key string, items []string) { + for _, item := range items { + values.Add(key, item) + } +} diff --git a/mcp/internal/httpsms/client_test.go b/mcp/internal/httpsms/client_test.go new file mode 100644 index 00000000..1a4ca3cd --- /dev/null +++ b/mcp/internal/httpsms/client_test.go @@ -0,0 +1,579 @@ +package httpsms_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +// newTestServer starts an httptest.Server that runs assert (given the +// decoded request) and writes response as the JSON body with status. +func newTestServer(t *testing.T, status int, response any, assertReq func(t *testing.T, r *http.Request)) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if assertReq != nil { + assertReq(t, r) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(response)) + })) + t.Cleanup(server.Close) + return server +} + +func requireBearer(t *testing.T, r *http.Request, token string) { + t.Helper() + assert.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) +} + +func requireRequestID(t *testing.T, r *http.Request) string { + t.Helper() + requestID := r.Header.Get("X-Request-Id") + assert.NotEmpty(t, requestID, "expected a non-empty X-Request-Id header") + return requestID +} + +func TestClient_ListPhones(t *testing.T) { + const token = "delegated-token-list-phones" + + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.Phone]{ + Status: "success", + Message: "fetched 1 phone", + Data: []httpsms.Phone{ + {ID: "phone-1", PhoneNumber: "+18005550199", SIM: "DEFAULT", MessagesPerMinute: 1, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/phones", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + requireBearer(t, r, token) + requireRequestID(t, r) + + query := r.URL.Query() + assert.Equal(t, "5", query.Get("skip")) + assert.Equal(t, "acme", query.Get("query")) + assert.Equal(t, "10", query.Get("limit")) + }) + + client := httpsms.NewClient(server.URL) + phones, err := client.ListPhones(t.Context(), token, httpsms.ListPhonesParams{Skip: 5, Query: "acme", Limit: 10}) + require.NoError(t, err) + require.Len(t, phones, 1) + assert.Equal(t, "phone-1", phones[0].ID) + assert.Equal(t, "+18005550199", phones[0].PhoneNumber) + assert.Equal(t, "DEFAULT", phones[0].SIM) +} + +func TestClient_UsesADistinctRequestIDPerCall(t *testing.T) { + seenRequestIDs := map[string]bool{} + + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.Phone]{Status: "success", Data: []httpsms.Phone{}}, func(t *testing.T, r *http.Request) { + seenRequestIDs[requireRequestID(t, r)] = true + }) + + client := httpsms.NewClient(server.URL) + _, err := client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{}) + require.NoError(t, err) + _, err = client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{}) + require.NoError(t, err) + + assert.Len(t, seenRequestIDs, 2, "expected a distinct request ID per call") +} + +func TestClient_ListPhones_OmitsZeroSkipAndLimit(t *testing.T) { + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.Phone]{Status: "success", Data: []httpsms.Phone{}}, func(t *testing.T, r *http.Request) { + query := r.URL.Query() + assert.False(t, query.Has("skip")) + assert.False(t, query.Has("limit")) + assert.False(t, query.Has("query")) + }) + + client := httpsms.NewClient(server.URL) + _, err := client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{}) + require.NoError(t, err) +} + +func TestClient_SendSMS(t *testing.T) { + const token = "delegated-token-send-sms" + sendAt := time.Date(2025, 12, 19, 16, 39, 57, 0, time.UTC) + + server := newTestServer(t, http.StatusOK, httpsms.Response[httpsms.Message]{ + Status: "success", + Message: "message added to queue", + Data: httpsms.Message{ + ID: "message-1", + Owner: "+18005550199", + Contact: "+18005550100", + Content: "hello", + Status: "pending", + Type: "mobile-terminated", + SIM: "DEFAULT", + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/messages/send", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + requireBearer(t, r, token) + requireRequestID(t, r) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "+18005550199", body["from"]) + assert.Equal(t, "+18005550100", body["to"]) + assert.Equal(t, "hello", body["content"]) + assert.Equal(t, true, body["encrypted"]) + assert.Equal(t, "req-1", body["request_id"]) + assert.Equal(t, []any{"https://example.com/image.jpg"}, body["attachments"]) + assert.Equal(t, "2025-12-19T16:39:57Z", body["send_at"]) + }) + + client := httpsms.NewClient(server.URL) + message, err := client.SendSMS(t.Context(), token, httpsms.SendSMSParams{ + From: "+18005550199", + To: "+18005550100", + Content: "hello", + Encrypted: true, + RequestID: "req-1", + Attachments: []string{"https://example.com/image.jpg"}, + SendAt: &sendAt, + }) + require.NoError(t, err) + assert.Equal(t, "message-1", message.ID) + assert.Equal(t, "pending", message.Status) +} + +func TestClient_ListMessageThreads(t *testing.T) { + const token = "delegated-token-list-threads" + archived := true + + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.MessageThread]{ + Status: "success", + Data: []httpsms.MessageThread{ + {ID: "thread-1", Owner: "+18005550199", Contact: "+18005550100", IsArchived: true, UnreadCount: 2}, + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/message-threads", r.URL.Path) + requireBearer(t, r, token) + requireRequestID(t, r) + + query := r.URL.Query() + assert.Equal(t, "+18005550199", query.Get("owner")) + assert.Equal(t, "true", query.Get("is_archived")) + assert.Equal(t, "true", query.Get("contacts")) + assert.Equal(t, "vip", query.Get("query")) + assert.Equal(t, "2", query.Get("skip")) + assert.Equal(t, "15", query.Get("limit")) + }) + + client := httpsms.NewClient(server.URL) + threads, err := client.ListMessageThreads(t.Context(), token, httpsms.ListMessageThreadsParams{ + Owner: "+18005550199", + IsArchived: &archived, + WithContacts: true, + Query: "vip", + Skip: 2, + Limit: 15, + }) + require.NoError(t, err) + require.Len(t, threads, 1) + assert.True(t, threads[0].IsArchived) + assert.EqualValues(t, 2, threads[0].UnreadCount) +} + +func TestClient_ListMessageThreads_OmitsUnsetArchiveFilter(t *testing.T) { + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.MessageThread]{Status: "success", Data: []httpsms.MessageThread{}}, func(t *testing.T, r *http.Request) { + query := r.URL.Query() + assert.False(t, query.Has("is_archived")) + assert.False(t, query.Has("contacts")) + }) + + client := httpsms.NewClient(server.URL) + _, err := client.ListMessageThreads(t.Context(), "token", httpsms.ListMessageThreadsParams{Owner: "+18005550199"}) + require.NoError(t, err) +} + +func TestClient_ListThreadMessages(t *testing.T) { + const token = "delegated-token-list-thread-messages" + + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.Message]{ + Status: "success", + Data: []httpsms.Message{ + {ID: "message-1", Owner: "+18005550199", Contact: "+18005550100", Content: "hi", Encrypted: true}, + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/messages", r.URL.Path) + requireBearer(t, r, token) + requireRequestID(t, r) + + query := r.URL.Query() + assert.Equal(t, "+18005550199", query.Get("owner")) + assert.Equal(t, "+18005550100", query.Get("contact")) + assert.Equal(t, "3", query.Get("skip")) + assert.Equal(t, "20", query.Get("limit")) + }) + + client := httpsms.NewClient(server.URL) + messages, err := client.ListThreadMessages(t.Context(), token, httpsms.ListThreadMessagesParams{ + Owner: "+18005550199", + Contact: "+18005550100", + Skip: 3, + Limit: 20, + }) + require.NoError(t, err) + require.Len(t, messages, 1) + assert.Equal(t, "hi", messages[0].Content) + assert.True(t, messages[0].Encrypted) +} + +func TestClient_ListIncomingMessages(t *testing.T) { + const token = "delegated-token-list-incoming" + descending := true + + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.Message]{ + Status: "success", + Data: []httpsms.Message{ + {ID: "message-2", Type: "mobile-originated", Status: "received"}, + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/messages/incoming", r.URL.Path) + requireBearer(t, r, token) + requireRequestID(t, r) + + query := r.URL.Query() + assert.ElementsMatch(t, []string{"+18005550199", "+18005550188"}, query["owners"]) + assert.ElementsMatch(t, []string{"received", "pending"}, query["statuses"]) + assert.Equal(t, "created_at", query.Get("sort_by")) + assert.Equal(t, "true", query.Get("sort_descending")) + assert.Equal(t, "search text", query.Get("query")) + }) + + client := httpsms.NewClient(server.URL) + messages, err := client.ListIncomingMessages(t.Context(), token, httpsms.ListIncomingMessagesParams{ + Owners: []string{"+18005550199", "+18005550188"}, + Statuses: []string{"received", "pending"}, + Query: "search text", + SortBy: "created_at", + SortDescending: &descending, + }) + require.NoError(t, err) + require.Len(t, messages, 1) + assert.Equal(t, "mobile-originated", messages[0].Type) +} + +func TestClient_CreatePhoneAPIKey(t *testing.T) { + const token = "delegated-token-create-key" + + server := newTestServer(t, http.StatusOK, httpsms.Response[httpsms.PhoneAPIKey]{ + Status: "success", + Data: httpsms.PhoneAPIKey{ + ID: "key-1", + Name: "My Phone API Key", + APIKey: "pk_secretvalue", + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v1/phone-api-keys", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + requireBearer(t, r, token) + requireRequestID(t, r) + + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "My Phone API Key", body["name"]) + }) + + client := httpsms.NewClient(server.URL) + key, err := client.CreatePhoneAPIKey(t.Context(), token, httpsms.CreatePhoneAPIKeyParams{Name: "My Phone API Key"}) + require.NoError(t, err) + assert.Equal(t, "key-1", key.ID) + assert.Equal(t, "pk_secretvalue", key.APIKey) +} + +func TestClient_RotateUserAPIKey(t *testing.T) { + const token = "delegated-token-rotate-key" + const userID = "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" + + server := newTestServer(t, http.StatusOK, httpsms.Response[httpsms.User]{ + Status: "success", + Data: httpsms.User{ + ID: userID, + Email: "user@example.com", + APIKey: "new-secret-api-key", + }, + }, func(t *testing.T, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/v1/users/"+userID+"/api-keys", r.URL.Path) + requireBearer(t, r, token) + requireRequestID(t, r) + assert.Empty(t, r.Header.Get("Content-Type"), "a bodyless request should not set Content-Type") + + body, err := readAll(r) + require.NoError(t, err) + assert.Empty(t, body) + }) + + client := httpsms.NewClient(server.URL) + user, err := client.RotateUserAPIKey(t.Context(), token, userID) + require.NoError(t, err) + assert.Equal(t, userID, user.ID) + assert.Equal(t, "new-secret-api-key", user.APIKey) +} + +func TestClient_DecodesFieldValidationErrors(t *testing.T) { + server := newTestServer(t, http.StatusUnprocessableEntity, map[string]any{ + "status": "error", + "message": "validation errors while sending message", + "data": map[string][]string{ + "to": {"The to field is required"}, + }, + }, nil) + + client := httpsms.NewClient(server.URL) + _, err := client.SendSMS(t.Context(), "token", httpsms.SendSMSParams{From: "+18005550199", Content: "hi"}) + require.Error(t, err) + + var apiErr *httpsms.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode) + assert.Equal(t, "validation errors while sending message", apiErr.Message) + assert.Equal(t, []string{"The to field is required"}, apiErr.Fields["to"]) + assert.NotEmpty(t, apiErr.RequestID) + assert.NotContains(t, apiErr.Error(), "token") +} + +func TestClient_DecodesStringDataErrorWithoutFields(t *testing.T) { + server := newTestServer(t, http.StatusUnauthorized, map[string]any{ + "status": "error", + "message": "You are not authorized to carry out this request.", + "data": "Make sure your API key is set in the [X-API-Key] header in the request", + }, nil) + + client := httpsms.NewClient(server.URL) + _, err := client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{}) + require.Error(t, err) + + var apiErr *httpsms.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode) + assert.Equal(t, "You are not authorized to carry out this request.", apiErr.Message) + assert.Nil(t, apiErr.Fields) +} + +func TestClient_RejectsOversizedResponseBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Write a response far larger than the client's 2 MiB cap. + _, _ = w.Write([]byte(`{"status":"success","message":"","data":[`)) + chunk := strings.Repeat("0", 1024) + for i := 0; i < 3*1024; i++ { // ~3 MiB of padding + _, _ = w.Write([]byte(chunk)) + } + _, _ = w.Write([]byte(`]}`)) + })) + t.Cleanup(server.Close) + + client := httpsms.NewClient(server.URL) + _, err := client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{}) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "size") +} + +func TestClient_MalformedResponseBodyIsAnError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{not valid json`)) + })) + t.Cleanup(server.Close) + + client := httpsms.NewClient(server.URL) + _, err := client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{}) + require.Error(t, err) +} + +func TestClient_PropagatesContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-time.After(5 * time.Second): + case <-r.Context().Done(): + } + })) + t.Cleanup(server.Close) + + client := httpsms.NewClient(server.URL) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := client.ListPhones(ctx, "token", httpsms.ListPhonesParams{}) + require.Error(t, err) +} + +// TestNewClient_ReturnsAnExportedConcreteType is a compile-time assertion +// that NewClient's declared return type is the exported *httpsms.HTTPClient +// (not an unexported type), while *HTTPClient still satisfies Client. An +// exported func returning an unexported type is a lint finding (the caller +// cannot name the type, e.g. to embed it or declare a variable of it); this +// would fail to compile if NewClient's signature regressed to an unexported +// return type. +func TestNewClient_ReturnsAnExportedConcreteType(t *testing.T) { + var typed *httpsms.HTTPClient = httpsms.NewClient("https://example.invalid") + var _ httpsms.Client = typed + + assert.NotNil(t, typed) +} + +// TestClient_RedactsQueryValuesFromOTelSpanAttributes is the regression +// test for the critical review finding: query string values (which can +// carry SMS content via the free-text "query" search filter, phone +// numbers, or other sensitive filter values) must never be recorded as +// OpenTelemetry span attributes, even though the real, unmodified query +// string must still reach the httpSMS API on the wire and trace-context +// propagation headers must still be injected. +// +// It uses an in-memory OTel span exporter to inspect every attribute of +// every recorded span for a unique marker value used only as the "query" +// filter, while independently capturing the raw query string the httptest +// server actually received on the wire. +func TestClient_RedactsQueryValuesFromOTelSpanAttributes(t *testing.T) { + const uniqueQueryValue = "otel-redaction-probe-4b9f9e6c-secret-sms-content" + + var ( + receivedRawQuery string + receivedTraceparent string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedRawQuery = r.URL.RawQuery + receivedTraceparent = r.Header.Get("Traceparent") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(httpsms.Response[[]httpsms.Phone]{Status: "success", Data: []httpsms.Phone{}}) + })) + t.Cleanup(server.Close) + + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + previousTracerProvider := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { otel.SetTracerProvider(previousTracerProvider) }) + + // The mcp binary's observability package registers a global W3C + // (tracecontext + baggage) propagator at startup (see + // internal/observability.New); replicate that here so this test + // exercises the same propagation path production traffic uses. + previousPropagator := otel.GetTextMapPropagator() + otel.SetTextMapPropagator(propagation.TraceContext{}) + t.Cleanup(func() { otel.SetTextMapPropagator(previousPropagator) }) + + client := httpsms.NewClient(server.URL) + _, err := client.ListPhones(t.Context(), "token", httpsms.ListPhonesParams{Query: uniqueQueryValue, Limit: 10}) + require.NoError(t, err) + + // The real network request must still carry the unredacted query and a + // propagated trace context: redaction must be a span-attribute-only + // concern, not a change to what is actually sent over the wire. + assert.Contains(t, receivedRawQuery, uniqueQueryValue, "the httptest server must still receive the real, unredacted query") + assert.NotEmpty(t, receivedTraceparent, "trace-context propagation must still work despite query redaction") + + spans := exporter.GetSpans() + require.Len(t, spans, 1, "expected exactly one span per call: redaction must not create a second otel span") + + for _, span := range spans { + for _, attr := range span.Attributes { + assert.NotContains(t, attr.Value.Emit(), uniqueQueryValue, + "span attribute %q must not contain the redacted query value", attr.Key) + } + } +} + +// TestClient_ResponseHeaderTimeoutFiresBeforeTheOverallRequestTimeout proves +// the response header timeout is wired into the client's transport (not +// just the overall http.Client.Timeout): a server that accepts the +// connection and the request body but never writes a response must fail +// well before the 15s overall request timeout, since the 10s response +// header timeout fires first. +func TestClient_ResponseHeaderTimeoutFiresBeforeTheOverallRequestTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-time.After(12 * time.Second): + case <-r.Context().Done(): + } + })) + t.Cleanup(server.Close) + + client := httpsms.NewClient(server.URL) + + start := time.Now() + _, err := client.ListPhones(context.Background(), "token", httpsms.ListPhonesParams{}) + elapsed := time.Since(start) + + require.Error(t, err) + assert.Less(t, elapsed, 13*time.Second, "expected the ~10s response header timeout to fire well before the 15s overall request timeout") +} + +func readAll(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + return io.ReadAll(r.Body) +} + +// TestWithTimeoutBoundsEveryCall asserts the configured HTTP timeout is +// actually applied to calls the client makes: a server that never responds +// must fail the call at roughly the configured timeout, not at the client's +// much longer built-in default. +func TestWithTimeoutBoundsEveryCall(t *testing.T) { + blocked := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + <-blocked + })) + defer server.Close() + defer close(blocked) + + client := httpsms.NewClient(server.URL, httpsms.WithTimeout(150*time.Millisecond)) + + start := time.Now() + _, err := client.ListPhones(context.Background(), "token", httpsms.ListPhonesParams{}) + elapsed := time.Since(start) + + require.Error(t, err) + assert.Less(t, elapsed, 5*time.Second, "the configured timeout was not applied") +} + +// TestWithTimeoutIgnoresNonPositiveValues asserts an option can never strip +// the client's bound: a zero or negative timeout keeps the built-in +// default, so a misconfigured environment cannot produce a client that +// hangs forever. +func TestWithTimeoutIgnoresNonPositiveValues(t *testing.T) { + for _, timeout := range []time.Duration{0, -time.Second} { + server := newTestServer(t, http.StatusOK, httpsms.Response[[]httpsms.Phone]{}, nil) + client := httpsms.NewClient(server.URL, httpsms.WithTimeout(timeout)) + + // The call still succeeds promptly against a responsive server: + // the option was ignored, not applied as "no timeout at all" or + // "already expired". + _, err := client.ListPhones(context.Background(), "token", httpsms.ListPhonesParams{}) + require.NoError(t, err) + } +} diff --git a/mcp/internal/httpsms/models.go b/mcp/internal/httpsms/models.go new file mode 100644 index 00000000..4fd4a667 --- /dev/null +++ b/mcp/internal/httpsms/models.go @@ -0,0 +1,187 @@ +// Package httpsms is a typed client for the httpSMS HTTP API +// (api.httpsms.com), used by every MCP tool that needs to call it. +// +// The client never mints, caches, or inspects the delegated API bearer +// token it is given: callers (the MCP tool handlers) mint a short-lived, +// scope- and operation-bound token per call with auth.KeySet and pass it in +// as a plain string. This package is deliberately isolated from the rest of +// the MCP server (auth, oauth, config) so it can be developed, tested, and +// reused independently of OAuth, token minting, and tool registration. +package httpsms + +import ( + "fmt" + "time" +) + +// Response is the standard httpSMS API success envelope every 2xx response +// is wrapped in. +type Response[T any] struct { + Status string `json:"status"` + Message string `json:"message"` + Data T `json:"data"` +} + +// APIError is a non-2xx httpSMS API response. It is always safe to log or +// include in a tool error: it never carries the request body, the bearer +// token, or SMS content, only the response status code, the API's own +// message, any field validation errors, and the request ID this client +// generated for the call. +type APIError struct { + // StatusCode is the HTTP status code the API responded with. + StatusCode int + + // Message is the API's own top-level "message" field. + Message string + + // Fields are per-field validation errors from a 422 response, if any. + Fields map[string][]string + + // RequestID is the value this client sent as the request's X-Request-Id + // header. The httpSMS API does not currently echo it back, but it is + // still useful for correlating a returned error with the client-side + // log line and trace span that issued the request. + RequestID string +} + +// Error implements the error interface. It never includes the request body +// or bearer token. +func (e *APIError) Error() string { + if e.RequestID != "" { + return fmt.Sprintf("httpsms: request [%s] failed with status %d: %s", e.RequestID, e.StatusCode, e.Message) + } + return fmt.Sprintf("httpsms: request failed with status %d: %s", e.StatusCode, e.Message) +} + +// Phone is one of the user's registered httpSMS sending phones. +type Phone struct { + ID string `json:"id"` + PhoneNumber string `json:"phone_number"` + SIM string `json:"sim"` + MessagesPerMinute uint `json:"messages_per_minute"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Message is a single SMS/MMS message sent or received through httpSMS, +// mobile-originated (incoming) or mobile-terminated (outgoing). +type Message struct { + ID string `json:"id"` + RequestID *string `json:"request_id"` + Owner string `json:"owner"` + Contact string `json:"contact"` + Content string `json:"content"` + Attachments []string `json:"attachments"` + Encrypted bool `json:"encrypted"` + Type string `json:"type"` + Status string `json:"status"` + SIM string `json:"sim"` + OrderTimestamp time.Time `json:"order_timestamp"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SentAt *time.Time `json:"sent_at"` + DeliveredAt *time.Time `json:"delivered_at"` + ReceivedAt *time.Time `json:"received_at"` + FailedAt *time.Time `json:"failed_at"` + FailureReason *string `json:"failure_reason"` +} + +// MessageThread is a conversation between one of the user's phones (Owner) +// and a Contact. +type MessageThread struct { + ID string `json:"id"` + Owner string `json:"owner"` + Contact string `json:"contact"` + IsArchived bool `json:"is_archived"` + UnreadCount uint `json:"unread_count"` + Status string `json:"status"` + LastMessageContent *string `json:"last_message_content"` + LastMessageID *string `json:"last_message_id"` + OrderTimestamp time.Time `json:"order_timestamp"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PhoneAPIKey authenticates the httpSMS Android app for a subset of the +// user's phones. APIKey is a secret, one-time display value: callers must +// never log, trace, or persist it beyond returning it to the user once. +type PhoneAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + PhoneNumbers []string `json:"phone_numbers"` + PhoneIDs []string `json:"phone_ids"` + APIKey string `json:"api_key"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// User is the authenticated httpSMS user. APIKey is a secret, one-time +// display value after rotation: callers must never log, trace, or persist +// it beyond returning it to the user once. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + APIKey string `json:"api_key"` +} + +// ListPhonesParams are the supported filters for GET /v1/phones. Skip and +// Limit of zero are omitted from the request so the API applies its own +// default. +type ListPhonesParams struct { + Skip int + Query string + Limit int +} + +// SendSMSParams is the payload for POST /v1/messages/send. +type SendSMSParams struct { + From string + To string + Content string + Attachments []string + Encrypted bool + RequestID string + SendAt *time.Time +} + +// ListMessageThreadsParams are the supported filters for +// GET /v1/message-threads. IsArchived is a pointer so "not set" (let the API +// default to false) is distinguishable from an explicit false. Skip and +// Limit of zero are omitted so the API applies its own default. +type ListMessageThreadsParams struct { + Owner string + IsArchived *bool + WithContacts bool + Query string + Skip int + Limit int +} + +// ListThreadMessagesParams are the supported filters for GET /v1/messages. +// Owner and Contact are required by the API. +type ListThreadMessagesParams struct { + Owner string + Contact string + Query string + Skip int + Limit int +} + +// ListIncomingMessagesParams are the supported filters for +// GET /v1/messages/incoming. SortDescending is a pointer so "not set" (let +// the API pick its own default sort order) is distinguishable from an +// explicit false. +type ListIncomingMessagesParams struct { + Owners []string + Statuses []string + Query string + SortBy string + SortDescending *bool + Skip int + Limit int +} + +// CreatePhoneAPIKeyParams is the payload for POST /v1/phone-api-keys. +type CreatePhoneAPIKeyParams struct { + Name string +} diff --git a/mcp/internal/httpsms/transport.go b/mcp/internal/httpsms/transport.go new file mode 100644 index 00000000..053b7ba8 --- /dev/null +++ b/mcp/internal/httpsms/transport.go @@ -0,0 +1,89 @@ +package httpsms + +import ( + "context" + "net/http" +) + +// rawQueryContextKey is the context key queryRedactingTransport uses to +// smuggle a request's real, unmodified RawQuery past otelhttp.Transport to +// queryRestoringTransport. It is unexported and unique to this package, so +// it can never collide with a context value set by a caller or by another +// package. +type rawQueryContextKey struct{} + +// queryRedactingTransport wraps an otelhttp-instrumented transport so that +// query string values (for example the free-text "query" search filter, +// which can contain SMS content, phone numbers, or other sensitive filter +// values) are never recorded as OpenTelemetry span attributes, while the +// real, unmodified query string is still sent to the httpSMS API on the +// wire and trace-context propagation headers are still injected as usual. +// +// otelhttp.Transport.RoundTrip derives every request span attribute +// (including the full request URL, via semconv.URLFull) from the exact +// *http.Request instance it is handed, and then forwards that same +// instance (after Clone-ing it to attach the span's context) one layer +// further down to its own configured base transport. There is therefore no +// exported option to give otelhttp one URL for its attributes and a +// different one for the real network call: the only seam available is +// between "what otelhttp is handed" and "what otelhttp's own base +// transport sends", which is exactly what this pair of transports uses. +// +// - queryRedactingTransport (this type) sits in front of otelhttp. +// It clones the incoming request, strips RawQuery from the clone's +// URL, stashes the real RawQuery on the clone's context, and hands +// that sanitized clone to otelhttp. otelhttp's span attributes are +// therefore built from a query-free URL. +// - queryRestoringTransport sits behind otelhttp, installed as the base +// transport passed to otelhttp.NewTransport. It reads the real +// RawQuery back out of the request's context and restores it onto the +// request's URL immediately before delegating to the real network +// transport (*http.Transport), so the httpSMS API still receives the +// original, unmodified query string. +// +// Only one otelhttp.Transport is ever involved, so exactly one span is +// created per call: queryRedactingTransport itself does not start a span. +// Neither transport mutates the *http.Request a caller passed to +// http.Client.Do: queryRedactingTransport clones before making any change, +// and queryRestoringTransport only ever sees clones (first otelhttp's own +// Clone of queryRedactingTransport's clone). +type queryRedactingTransport struct { + next http.RoundTripper // otelhttp.NewTransport(&queryRestoringTransport{...}) +} + +// RoundTrip implements http.RoundTripper. +func (t *queryRedactingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL == nil || req.URL.RawQuery == "" { + // Nothing to redact: forward unchanged so GET requests without a + // query string (and all POST/DELETE calls) skip the clone. + return t.next.RoundTrip(req) + } + + ctx := context.WithValue(req.Context(), rawQueryContextKey{}, req.URL.RawQuery) + sanitized := req.Clone(ctx) + + sanitizedURL := *req.URL + sanitizedURL.RawQuery = "" + sanitized.URL = &sanitizedURL + + return t.next.RoundTrip(sanitized) +} + +// queryRestoringTransport restores the real query string (stashed by +// queryRedactingTransport) onto the request's URL immediately before +// handing it to the real network transport, so the httpSMS API still +// receives the original, unmodified query even though otelhttp only ever +// saw a query-free URL. +type queryRestoringTransport struct { + base http.RoundTripper // the real network transport (*http.Transport) +} + +// RoundTrip implements http.RoundTripper. +func (t *queryRestoringTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if rawQuery, ok := req.Context().Value(rawQueryContextKey{}).(string); ok { + restoredURL := *req.URL + restoredURL.RawQuery = rawQuery + req.URL = &restoredURL + } + return t.base.RoundTrip(req) +} diff --git a/mcp/internal/oauth/authorize.go b/mcp/internal/oauth/authorize.go new file mode 100644 index 00000000..b2c51dcf --- /dev/null +++ b/mcp/internal/oauth/authorize.go @@ -0,0 +1,700 @@ +package oauth + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "mime" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +// Bounds and sizes used by the authorization endpoint and consent flow. +const ( + // authorizationTransactionTTL bounds how long a pending authorization + // request (created by HandleAuthorize, consumed by + // HandleFirebaseComplete) survives the browser round trip through + // Firebase login. It is intentionally longer than AuthorizationCodeTTL: + // interactive login can take longer than redeeming an already-issued + // code. + authorizationTransactionTTL = 10 * time.Minute + + // transactionIDBytes and authorizationCodeBytes are the amount of + // crypto/rand entropy (see newRandomToken) encoded into, respectively, + // an authorization transaction ID and a one-time authorization code. + transactionIDBytes = 32 + authorizationCodeBytes = 32 + + // maxFormBodyBytes bounds every form-encoded request body this package + // accepts (POST /oauth/firebase/complete and POST /oauth/token). An + // unbounded ParseForm would otherwise let an unauthenticated client + // stream an arbitrarily large body into server memory. + maxFormBodyBytes = 64 << 10 // 64 KiB + + // formMediaType is the only request media type either POST endpoint + // accepts, per RFC 6749 Section 4.1.3. + formMediaType = "application/x-www-form-urlencoded" +) + +// authorizationRequestParams are the GET /oauth/authorize query parameters +// that must appear at most once. A repeated parameter is rejected outright +// rather than resolved by "first wins" or "last wins", since a server and a +// client (or an intermediary) picking different occurrences is a +// parameter-smuggling primitive. +var authorizationRequestParams = []string{ + "client_id", + "redirect_uri", + "response_type", + "state", + "code_challenge", + "code_challenge_method", + "resource", + "scope", +} + +// firebaseCompleteParams are the POST /oauth/firebase/complete body +// parameters that must appear at most once ("approved_scopes" is +// deliberately excluded: it is legitimately repeated, once per scope). +var firebaseCompleteParams = []string{"transaction_id", "id_token", "denied"} + +// codeChallengePattern matches an RFC 7636 S256 code challenge: the +// base64url (no padding) encoding of a SHA-256 digest, i.e. exactly 43 +// characters drawn from the base64url alphabet. Any other length or +// character can never match a challenge this server computes, so it is +// rejected at the authorization endpoint rather than failing later. +var codeChallengePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + +//go:embed templates/authorize.html +var authorizeTemplateFS embed.FS + +// scopeDescriptions maps every OAuth scope this service issues (see +// Scopes) to the human-readable sentence shown on the consent page. +var scopeDescriptions = map[string]string{ + "phones:read": "View your registered phones and sending numbers", + "messages:read": "View your message threads and history", + "messages:send": "Send SMS messages on your behalf", + "phone-api-keys:write": "Create a phone API key", + "user-api-key:rotate": "Rotate your primary httpSMS API key", +} + +// oauthError is the RFC 6749 Section 5.2 / RFC 8414 JSON error response +// body shape, used by every direct (non-redirect) error response from +// this package's handlers. +type oauthError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// ServerConfig configures a Server. +type ServerConfig struct { + // Issuer is this authorization server's own issuer identifier (e.g. + // "https://mcp.httpsms.com", matching the "issuer" field this service + // publishes in its RFC 8414 metadata document). It is echoed back as + // the "iss" parameter on every authorization response redirect, per + // RFC 9207, so a client can detect a mix-up attack against another + // authorization server. + Issuer string + + // Resource is the exact resource value ("https://mcp.httpsms.com/mcp") + // every authorization and token request must specify (RFC 8707). + // Requests naming any other resource, or omitting it, are rejected. + Resource string + + // FirebaseAPIKey and FirebaseAuthDomain configure the client-side + // Firebase Authentication SDK embedded in the rendered login/consent + // page. Neither value is a secret: both are ordinarily public in a + // browser-side Firebase Web SDK configuration. + FirebaseAPIKey string + FirebaseAuthDomain string + + // AuthorizationCodeTTL, AccessTokenTTL, and RefreshTokenTTL bound the + // lifetime of, respectively, an issued authorization code, a minted + // MCP access token, and an issued (or rotated) refresh token. + AuthorizationCodeTTL time.Duration + AccessTokenTTL time.Duration + RefreshTokenTTL time.Duration +} + +// validate returns an error naming the first missing or invalid field. +func (c ServerConfig) validate() error { + switch { + case c.Issuer == "": + return errors.New("oauth: ServerConfig.Issuer must not be empty") + case c.Resource == "": + return errors.New("oauth: ServerConfig.Resource must not be empty") + case c.FirebaseAPIKey == "": + return errors.New("oauth: ServerConfig.FirebaseAPIKey must not be empty") + case c.FirebaseAuthDomain == "": + return errors.New("oauth: ServerConfig.FirebaseAuthDomain must not be empty") + case c.AuthorizationCodeTTL <= 0: + return errors.New("oauth: ServerConfig.AuthorizationCodeTTL must be positive") + case c.AccessTokenTTL <= 0: + return errors.New("oauth: ServerConfig.AccessTokenTTL must be positive") + case c.RefreshTokenTTL <= 0: + return errors.New("oauth: ServerConfig.RefreshTokenTTL must be positive") + default: + return nil + } +} + +// Server implements the httpSMS MCP OAuth 2.1 authorization server's +// interactive endpoints: GET /oauth/authorize, POST +// /oauth/firebase/complete, and POST /oauth/token. It never logs or +// returns, outside the exact responses each endpoint's contract requires, +// any bearer token, authorization code, refresh token, PKCE verifier, or +// Firebase ID token it handles. +type Server struct { + store Store + resolver *ClientResolver + keys *auth.KeySet + verifier auth.IdentityVerifier + config ServerConfig + templates *template.Template +} + +// NewServer returns a Server backed by store, resolver, keys, and verifier, +// configured by config. It returns an error if any argument is nil or +// config is incomplete, or if the embedded consent-page template fails to +// parse (a build-time invariant, not a runtime condition callers need to +// handle beyond checking the error once at startup). +func NewServer(store Store, resolver *ClientResolver, keys *auth.KeySet, verifier auth.IdentityVerifier, config ServerConfig) (*Server, error) { + if store == nil { + return nil, errors.New("oauth: Server requires a Store") + } + if resolver == nil { + return nil, errors.New("oauth: Server requires a ClientResolver") + } + if keys == nil { + return nil, errors.New("oauth: Server requires a KeySet") + } + if verifier == nil { + return nil, errors.New("oauth: Server requires an IdentityVerifier") + } + if err := config.validate(); err != nil { + return nil, err + } + + templates, err := template.ParseFS(authorizeTemplateFS, "templates/authorize.html") + if err != nil { + return nil, fmt.Errorf("oauth: cannot parse authorization templates: %w", err) + } + + return &Server{ + store: store, + resolver: resolver, + keys: keys, + verifier: verifier, + config: config, + templates: templates, + }, nil +} + +// HandleAuthorize implements GET /oauth/authorize. It validates the +// client, redirect URI, requested scopes, state, PKCE challenge, and +// resource, then stores a short-lived AuthorizationTransaction and renders +// the Firebase login/consent page. +// +// client_id and redirect_uri are validated first, and only against each +// other (an unresolved client_id, or a redirect_uri not registered for the +// resolved client) responds with a direct 400 rather than a redirect: an +// unvalidated redirect_uri must never be treated as a safe error-reporting +// target. Every failure after that point is reported to the client via +// redirect, carrying the RFC 9207 "iss" parameter. +func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + + if repeated := firstRepeatedParam(query, authorizationRequestParams); repeated != "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "each authorization request parameter must appear exactly once") + return + } + + clientID := query.Get("client_id") + if clientID == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_id is required") + return + } + + client, err := s.resolver.Resolve(r.Context(), clientID) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_client", "client_id could not be resolved") + return + } + + redirectURI := query.Get("redirect_uri") + if redirectURI == "" || !containsExact(client.RedirectURIs, redirectURI) { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "redirect_uri is missing or not registered for this client") + return + } + + state := query.Get("state") + if state == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "state is required") + return + } + + if responseType := query.Get("response_type"); responseType != "code" { + s.redirectError(w, r, redirectURI, state, "unsupported_response_type", "response_type must be \"code\"") + return + } + + codeChallenge := query.Get("code_challenge") + codeChallengeMethod := query.Get("code_challenge_method") + if codeChallengeMethod != "S256" || !codeChallengePattern.MatchString(codeChallenge) { + s.redirectError(w, r, redirectURI, state, "invalid_request", "a S256 code_challenge of 43 base64url characters is required") + return + } + + resource := query.Get("resource") + if resource == "" || resource != s.config.Resource { + s.redirectError(w, r, redirectURI, state, "invalid_target", "resource must equal the MCP resource URL") + return + } + + scopes, err := parseRequestedScopes(query.Get("scope")) + if err != nil { + s.redirectError(w, r, redirectURI, state, "invalid_scope", err.Error()) + return + } + + transactionID, err := newRandomToken(transactionIDBytes) + if err != nil { + s.redirectError(w, r, redirectURI, state, "server_error", "cannot start authorization") + return + } + + transaction := AuthorizationTransaction{ + ID: transactionID, + ClientID: clientID, + RedirectURI: redirectURI, + Scopes: scopes, + State: state, + Resource: resource, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + ResponseType: "code", + CreatedAt: time.Now().UTC(), + } + if err := s.store.PutAuthorizationTransaction(r.Context(), transaction, authorizationTransactionTTL); err != nil { + s.redirectError(w, r, redirectURI, state, "server_error", "cannot start authorization") + return + } + + s.renderAuthorizePage(w, transaction, client) +} + +// HandleFirebaseComplete implements POST /oauth/firebase/complete. It +// verifies the posted Firebase ID token, applies the user's scope +// approval/denial decision, and either redirects back to the client with a +// one-time authorization code or with an "access_denied" error. +// +// The Firebase ID token and approved scopes are read only from the POST +// body (never a query string), matching the requirement that a bearer +// identity token must never appear in a URL (logs, browser history, +// Referer headers). The body must be form-encoded and is bounded to +// maxFormBodyBytes. +// +// The authorization transaction is consumed atomically the moment the +// decision that ends it is made -- an approval whose identity token +// verified, or an explicit denial -- so a captured consent POST can never +// be replayed into a second authorization code. A failed identity +// verification deliberately leaves the transaction intact so the user can +// simply sign in again in the same browser tab. +func (s *Server) HandleFirebaseComplete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !parseFormRequest(w, r) { + return + } + + if repeated := firstRepeatedParam(r.PostForm, firebaseCompleteParams); repeated != "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "each request parameter must appear exactly once") + return + } + + transactionID := r.PostFormValue("transaction_id") + if transactionID == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "transaction_id is required") + return + } + + transaction, err := s.store.GetAuthorizationTransaction(r.Context(), transactionID) + if err != nil { + writeStoreError(w, err, "invalid_request", "authorization transaction not found or expired") + return + } + + if r.PostFormValue("denied") != "" { + if _, err := s.store.ConsumeAuthorizationTransaction(r.Context(), transactionID); err != nil { + writeStoreError(w, err, "invalid_request", "authorization transaction not found or expired") + return + } + s.redirectError(w, r, transaction.RedirectURI, transaction.State, "access_denied", "the user denied the request") + return + } + + idToken := r.PostFormValue("id_token") + if idToken == "" { + writeOAuthError(w, http.StatusUnauthorized, "access_denied", "a Firebase ID token is required") + return + } + + principal, err := s.verifier.Verify(r.Context(), idToken) + if err != nil || principal.UserID == "" { + // The transaction is intentionally *not* consumed here: a failed + // verification is not a completed authorization decision, so the + // user may retry. Nothing is issued, so nothing can be replayed. + writeOAuthError(w, http.StatusUnauthorized, "access_denied", "the identity token could not be verified") + return + } + + // The decision is final from here on: consume the transaction + // atomically so only this completion can ever issue a code for it. + transaction, err = s.store.ConsumeAuthorizationTransaction(r.Context(), transactionID) + if err != nil { + writeStoreError(w, err, "invalid_request", "authorization transaction not found or expired") + return + } + + approvedScopes := intersectApprovedScopes(transaction.Scopes, r.PostForm["approved_scopes"]) + if len(approvedScopes) == 0 { + s.redirectError(w, r, transaction.RedirectURI, transaction.State, "access_denied", "no requested scope was approved") + return + } + + code, err := newRandomToken(authorizationCodeBytes) + if err != nil { + s.redirectError(w, r, transaction.RedirectURI, transaction.State, "server_error", "cannot issue an authorization code") + return + } + + authorizationCode := AuthorizationCode{ + Code: code, + ClientID: transaction.ClientID, + RedirectURI: transaction.RedirectURI, + Scopes: approvedScopes, + UserID: principal.UserID, + Email: principal.Email, + Resource: transaction.Resource, + CodeChallenge: transaction.CodeChallenge, + CodeChallengeMethod: transaction.CodeChallengeMethod, + CreatedAt: time.Now().UTC(), + } + if err := s.store.PutAuthorizationCode(r.Context(), authorizationCode, s.config.AuthorizationCodeTTL); err != nil { + s.redirectError(w, r, transaction.RedirectURI, transaction.State, "server_error", "cannot issue an authorization code") + return + } + + target := buildRedirectURL(transaction.RedirectURI, map[string]string{ + "code": code, + "state": transaction.State, + "iss": s.config.Issuer, + }) + s.redirect(w, r, target) +} + +// renderAuthorizePage writes the Firebase login/consent page for +// transaction and client. +func (s *Server) renderAuthorizePage(w http.ResponseWriter, transaction AuthorizationTransaction, client Client) { + nonce, err := newRandomToken(scriptNonceBytes) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "cannot render the consent page") + return + } + + data := struct { + FirebaseAPIKey string + FirebaseAuthDomain string + TransactionID string + ClientName string + ScriptNonce string + Scopes []struct{ Value, Description string } + }{ + FirebaseAPIKey: s.config.FirebaseAPIKey, + FirebaseAuthDomain: s.config.FirebaseAuthDomain, + TransactionID: transaction.ID, + ClientName: client.Name, + ScriptNonce: nonce, + } + for _, scope := range transaction.Scopes { + description := scopeDescriptions[scope] + if description == "" { + description = scope + } + data.Scopes = append(data.Scopes, struct{ Value, Description string }{Value: scope, Description: description}) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The consent page carries an in-flight authorization transaction and + // is about to hold a Firebase ID token in the DOM: it must never be + // cached, framed (clickjacked into an invisible "Allow"), or leak its + // URL -- which carries the client's redirect URI and state -- through a + // Referer header. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", s.consentPageCSP(nonce)) + w.Header().Set("Referrer-Policy", "no-referrer") + w.WriteHeader(http.StatusOK) + _ = s.templates.ExecuteTemplate(w, "authorize.html", data) +} + +// scriptNonceBytes is the amount of crypto/rand entropy encoded into the +// per-render CSP script nonce. +const scriptNonceBytes = 16 + +// firebaseScriptOrigin is where the consent page loads the Firebase Web SDK +// from, and firebaseAPIOrigins are the endpoints that SDK calls to sign a +// user in and mint an ID token. They are listed explicitly in the consent +// page's CSP so no other origin can be scripted from, or exfiltrated to, if +// the page's markup were ever influenced by attacker-controlled data. +const firebaseScriptOrigin = "https://www.gstatic.com" + +var firebaseAPIOrigins = []string{ + "https://identitytoolkit.googleapis.com", + "https://securetoken.googleapis.com", + "https://www.googleapis.com", +} + +// firebaseProviderOrigins are the origins Firebase's signInWithPopup flow +// loads its provider handoff UI from (Google's and GitHub's sign-in pages +// are reached through the project's own auth domain, which is added +// separately). +var firebaseProviderOrigins = []string{ + "https://apis.google.com", + "https://accounts.google.com", +} + +// safeAuthDomain matches the only shape of FirebaseAuthDomain that may be +// interpolated into a CSP source list: a bare hostname. Anything else +// (a scheme, a path, a space, a semicolon) could terminate one directive +// and inject another, turning a misconfigured environment variable into a +// CSP bypass. +var safeAuthDomain = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$`) + +// consentPageCSP returns the consent page's Content-Security-Policy. +// +// The page is the one place in this service that runs script in a browser +// and holds a Firebase ID token in its DOM, so its policy denies everything +// by default and then re-admits exactly what the Firebase Web SDK needs: +// the SDK bundles from gstatic, this render's own inline script (by nonce, +// never 'unsafe-inline', so injected markup cannot execute), the Firebase +// identity endpoints it calls, and the provider frames its sign-in popup +// uses. form-action 'self' keeps the ID-token form from being retargeted at +// another origin, and frame-ancestors 'none' preserves the previous +// policy's clickjacking protection. +func (s *Server) consentPageCSP(nonce string) string { + scriptSrc := []string{"'nonce-" + nonce + "'", firebaseScriptOrigin} + scriptSrc = append(scriptSrc, firebaseProviderOrigins...) + + connectSrc := append([]string{"'self'"}, firebaseAPIOrigins...) + frameSrc := append([]string{}, firebaseProviderOrigins...) + + if domain := s.config.FirebaseAuthDomain; safeAuthDomain.MatchString(domain) { + connectSrc = append(connectSrc, "https://"+domain) + frameSrc = append(frameSrc, "https://"+domain) + } + + directives := []string{ + "default-src 'none'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "img-src 'self' data:", + "style-src 'unsafe-inline'", + "script-src " + strings.Join(scriptSrc, " "), + "connect-src " + strings.Join(connectSrc, " "), + "frame-src " + strings.Join(frameSrc, " "), + } + + return strings.Join(directives, "; ") +} + +// redirect sends an authorization response (success or error) back to the +// client's redirect URI. Authorization responses carry a one-time code or +// an error plus the client's state, so they must never be cached. +func (s *Server) redirect(w http.ResponseWriter, r *http.Request, target string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + http.Redirect(w, r, target, http.StatusFound) +} + +// redirectError redirects to redirectURI with the given OAuth error code +// and human-readable description, plus state (when non-empty) and the RFC +// 9207 "iss" parameter. +func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, redirectURI, state, code, description string) { + target := buildRedirectURL(redirectURI, map[string]string{ + "error": code, + "error_description": description, + "state": state, + "iss": s.config.Issuer, + }) + s.redirect(w, r, target) +} + +// parseFormRequest enforces the form-encoding contract shared by POST +// /oauth/firebase/complete and POST /oauth/token: the request must declare +// "application/x-www-form-urlencoded" and its body must fit within +// maxFormBodyBytes. It writes the OAuth "invalid_request" error and +// reports false when either bound is violated, so callers can simply +// return. +func parseFormRequest(w http.ResponseWriter, r *http.Request) bool { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, formMediaType) { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Content-Type must be application/x-www-form-urlencoded") + return false + } + + r.Body = http.MaxBytesReader(w, r.Body, maxFormBodyBytes) + if err := r.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("the request body could not be parsed or exceeds the %d byte limit", maxFormBodyBytes)) + return false + } + + return true +} + +// firstRepeatedParam returns the first entry of params that appears more +// than once in values, or "" when none does. +func firstRepeatedParam(values url.Values, params []string) string { + for _, name := range params { + if len(values[name]) > 1 { + return name + } + } + return "" +} + +// writeStoreError maps a Store failure onto an OAuth response: a missing, +// expired, or already-consumed record is the requester's problem (400 with +// the caller's error code), while any other failure is an infrastructure +// failure that must not be reported as a client error (500 "server_error"). +func writeStoreError(w http.ResponseWriter, err error, code, description string) { + if errors.Is(err, ErrNotFound) { + writeOAuthError(w, http.StatusBadRequest, code, description) + return + } + writeOAuthError(w, http.StatusInternalServerError, "server_error", "the request could not be completed") +} + +// buildRedirectURL appends params (skipping empty values) to redirectURI's +// query string. +func buildRedirectURL(redirectURI string, params map[string]string) string { + parsed, err := url.Parse(redirectURI) + if err != nil { + // redirectURI has already been validated by the caller against a + // resolved client's registered redirect_uris; this should be + // unreachable, but fail closed rather than panic. + return redirectURI + } + + query := parsed.Query() + for key, value := range params { + if value == "" { + continue + } + query.Set(key, value) + } + parsed.RawQuery = query.Encode() + return parsed.String() +} + +// containsExact reports whether value is exactly present in list. +func containsExact(list []string, value string) bool { + for _, candidate := range list { + if candidate == value { + return true + } + } + return false +} + +// parseRequestedScopes splits raw (an OAuth "scope" parameter) on +// whitespace, validates every entry against the fixed Scopes list, and +// deduplicates the result while preserving the order the client asked in, +// requiring at least one scope. +func parseRequestedScopes(raw string) ([]string, error) { + fields := strings.Fields(raw) + if len(fields) == 0 { + return nil, errors.New("scope is required") + } + + known := make(map[string]bool, len(Scopes)) + for _, scope := range Scopes { + known[scope] = true + } + + seen := make(map[string]bool, len(fields)) + scopes := make([]string, 0, len(fields)) + for _, field := range fields { + if !known[field] { + return nil, fmt.Errorf("unsupported scope %q", field) + } + if seen[field] { + continue + } + seen[field] = true + scopes = append(scopes, field) + } + return scopes, nil +} + +// intersectApprovedScopes returns the entries of approved that were also +// present in requested, deduplicated and in requested's order. This is the +// only place scopes are narrowed during consent: a user can approve fewer +// than the client requested, but the client can never end up with a scope +// it did not request (approved values outside requested are silently +// dropped, not treated as an expansion). +func intersectApprovedScopes(requested []string, approved []string) []string { + approvedSet := make(map[string]bool, len(approved)) + for _, scope := range approved { + approvedSet[scope] = true + } + + var result []string + for _, scope := range requested { + if approvedSet[scope] { + result = append(result, scope) + } + } + return result +} + +// writeOAuthError writes an RFC 6749 Section 5.2-shaped JSON error +// response with Cache-Control: no-store, as required of every +// authorization-server error response that might carry sensitive +// information. +func writeOAuthError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(oauthError{Error: code, ErrorDescription: description}) +} + +// writeJSON writes body as a "200 OK"-or-given-status JSON response with +// Cache-Control: no-store. +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/mcp/internal/oauth/authorize_test.go b/mcp/internal/oauth/authorize_test.go new file mode 100644 index 00000000..bb5efee0 --- /dev/null +++ b/mcp/internal/oauth/authorize_test.go @@ -0,0 +1,1138 @@ +package oauth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +const ( + testResource = "https://mcp.httpsms.com/mcp" + testAPIAud = "https://api.httpsms.com" + testIssuer = "https://mcp.httpsms.com" + testClientID = "test-client-id" + testRedirect = "https://client.example/callback" + testFirebaseID = "firebase-uid" + testUserEmail = "user@example.com" +) + +// stubVerifier is a test double for auth.IdentityVerifier. +type stubVerifier struct { + principal auth.Principal + err error +} + +func (v stubVerifier) Verify(context.Context, string) (auth.Principal, error) { + return v.principal, v.err +} + +// failThenSucceedVerifier fails the first Verify call and succeeds on every +// later one, modelling a user who mistypes a password (or whose ID token +// has just expired) and then signs in successfully in the same browser tab. +type failThenSucceedVerifier struct { + calls int +} + +func (v *failThenSucceedVerifier) Verify(context.Context, string) (auth.Principal, error) { + v.calls++ + if v.calls == 1 { + return auth.Principal{}, auth.ErrInvalidIdentityToken + } + return auth.Principal{UserID: testFirebaseID, Email: testUserEmail}, nil +} + +// errStoreFailure is the stand-in for a Redis/infrastructure failure -- +// deliberately not ErrNotFound, so it must never be reported to a client as +// an invalid grant or an invalid request. +var errStoreFailure = errors.New("oauth: redis unavailable") + +// errorStore wraps a Store and forces selected methods to fail with +// errStoreFailure, so tests can distinguish "record is gone" (a client +// error) from "the store is broken" (a server error). +type errorStore struct { + Store + failGetTransaction bool + failConsumeTransaction bool + failConsumeCode bool + failGetRefreshToken bool + failRotateRefreshToken bool +} + +func (s *errorStore) GetAuthorizationTransaction(ctx context.Context, id string) (AuthorizationTransaction, error) { + if s.failGetTransaction { + return AuthorizationTransaction{}, errStoreFailure + } + return s.Store.GetAuthorizationTransaction(ctx, id) +} + +func (s *errorStore) ConsumeAuthorizationTransaction(ctx context.Context, id string) (AuthorizationTransaction, error) { + if s.failConsumeTransaction { + return AuthorizationTransaction{}, errStoreFailure + } + return s.Store.ConsumeAuthorizationTransaction(ctx, id) +} + +func (s *errorStore) ConsumeAuthorizationCode(ctx context.Context, code string) (AuthorizationCode, error) { + if s.failConsumeCode { + return AuthorizationCode{}, errStoreFailure + } + return s.Store.ConsumeAuthorizationCode(ctx, code) +} + +func (s *errorStore) GetRefreshToken(ctx context.Context, token string) (RefreshGrant, error) { + if s.failGetRefreshToken { + return RefreshGrant{}, errStoreFailure + } + return s.Store.GetRefreshToken(ctx, token) +} + +func (s *errorStore) RotateRefreshToken(ctx context.Context, oldToken string, grant RefreshGrant, ttl time.Duration) error { + if s.failRotateRefreshToken { + return errStoreFailure + } + return s.Store.RotateRefreshToken(ctx, oldToken, grant, ttl) +} + +// newTestServerConfig returns a valid ServerConfig for tests. +func newTestServerConfig() ServerConfig { + return ServerConfig{ + Issuer: testIssuer, + Resource: testResource, + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: time.Minute, + AccessTokenTTL: 15 * time.Minute, + RefreshTokenTTL: time.Hour, + } +} + +// newTestKeySet returns a KeySet configured for signing test MCP access +// tokens, independent of any other package's test key material. +func newTestKeySet(t *testing.T) *auth.KeySet { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + + keys, err := auth.NewKeySet(keyPEM, "test-key-1") + require.NoError(t, err) + require.NoError(t, keys.Configure(testIssuer, testResource, testAPIAud)) + + return keys +} + +// newTestOAuthServer builds a Server wired to store, a ClientResolver over +// the same store (so a client registered through +// store.PutDynamicClient/registerTestClient resolves correctly), a fresh +// KeySet, and verifier. +func newTestOAuthServer(t *testing.T, store Store, verifier auth.IdentityVerifier) *Server { + t.Helper() + + resolver := NewClientResolver(http.DefaultClient, store) + keys := newTestKeySet(t) + + server, err := NewServer(store, resolver, keys, verifier, newTestServerConfig()) + require.NoError(t, err) + + return server +} + +// approvingVerifier returns an auth.IdentityVerifier that always succeeds +// with a fixed test principal. +func approvingVerifier() stubVerifier { + return stubVerifier{principal: auth.Principal{UserID: testFirebaseID, Email: testUserEmail}} +} + +// registerTestClient stores a valid DCR-style Client record under clientID +// with the given redirect URIs. +func registerTestClient(t *testing.T, store Store, clientID string, redirectURIs []string) { + t.Helper() + + require.NoError(t, store.PutDynamicClient(context.Background(), validTestClient(clientID, redirectURIs), dynamicClientTTL)) +} + +// pkceChallengeFor returns the RFC 7636 S256 code_challenge for verifier. +func pkceChallengeFor(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// validAuthorizeQuery returns a fully valid GET /oauth/authorize query +// string for testClientID/testRedirect, overridable per test via extra. +func validAuthorizeQuery(extra url.Values) string { + query := url.Values{ + "client_id": {testClientID}, + "redirect_uri": {testRedirect}, + "response_type": {"code"}, + "state": {"state-value"}, + "code_challenge": {pkceChallengeFor("test-verifier")}, + "code_challenge_method": {"S256"}, + "resource": {testResource}, + "scope": {"phones:read messages:send"}, + } + for key, values := range extra { + query[key] = values + } + return query.Encode() +} + +var transactionIDPattern = regexp.MustCompile(`name="transaction_id" value="([^"]+)"`) + +// extractTransactionID pulls the transaction_id hidden field out of a +// rendered authorize.html response body. +func extractTransactionID(t *testing.T, body string) string { + t.Helper() + + matches := transactionIDPattern.FindStringSubmatch(body) + require.Len(t, matches, 2, "response body must contain a transaction_id hidden field: %s", body) + + return matches[1] +} + +func TestNewServerRequiresStore(t *testing.T) { + store := newClientsTestStore(t) + resolver := NewClientResolver(http.DefaultClient, store) + keys := newTestKeySet(t) + + _, err := NewServer(nil, resolver, keys, approvingVerifier(), newTestServerConfig()) + require.Error(t, err) +} + +func TestNewServerRequiresResolver(t *testing.T) { + store := newClientsTestStore(t) + keys := newTestKeySet(t) + + _, err := NewServer(store, nil, keys, approvingVerifier(), newTestServerConfig()) + require.Error(t, err) +} + +func TestNewServerRequiresKeySet(t *testing.T) { + store := newClientsTestStore(t) + resolver := NewClientResolver(http.DefaultClient, store) + + _, err := NewServer(store, resolver, nil, approvingVerifier(), newTestServerConfig()) + require.Error(t, err) +} + +func TestNewServerRequiresVerifier(t *testing.T) { + store := newClientsTestStore(t) + resolver := NewClientResolver(http.DefaultClient, store) + keys := newTestKeySet(t) + + _, err := NewServer(store, resolver, keys, nil, newTestServerConfig()) + require.Error(t, err) +} + +func TestNewServerRejectsIncompleteConfig(t *testing.T) { + store := newClientsTestStore(t) + resolver := NewClientResolver(http.DefaultClient, store) + keys := newTestKeySet(t) + + testCases := map[string]func(*ServerConfig){ + "missing issuer": func(c *ServerConfig) { c.Issuer = "" }, + "missing resource": func(c *ServerConfig) { c.Resource = "" }, + "missing firebase api key": func(c *ServerConfig) { c.FirebaseAPIKey = "" }, + "missing firebase auth domain": func(c *ServerConfig) { c.FirebaseAuthDomain = "" }, + "non-positive authz code ttl": func(c *ServerConfig) { c.AuthorizationCodeTTL = 0 }, + "non-positive access token ttl": func(c *ServerConfig) { c.AccessTokenTTL = 0 }, + "non-positive refresh token ttl": func(c *ServerConfig) { c.RefreshTokenTTL = 0 }, + } + + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + config := newTestServerConfig() + mutate(&config) + + _, err := NewServer(store, resolver, keys, approvingVerifier(), config) + require.Error(t, err) + }) + } +} + +func TestHandleAuthorizeRejectsMissingClientID(t *testing.T) { + store := newClientsTestStore(t) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid_request") +} + +func TestHandleAuthorizeRejectsUnresolvableClientID(t *testing.T) { + store := newClientsTestStore(t) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?client_id=never-registered", nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid_client") +} + +func TestHandleAuthorizeRejectsMissingRedirectURI(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?client_id="+testClientID, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestHandleAuthorizeRejectsUnregisteredRedirectURI(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := url.Values{"client_id": {testClientID}, "redirect_uri": {"https://evil.example/callback"}} + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query.Encode(), nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +// TestHandleAuthorizeRejectsMissingState asserts a request that has +// already been validated to have a known client and a registered +// redirect_uri, but is missing state, is rejected directly (not by +// redirecting -- there is no state to safely echo back). +func TestHandleAuthorizeRejectsMissingState(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := validAuthorizeQuery(url.Values{"state": {""}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid_request") +} + +// TestHandleAuthorizeRedirectsWithRFC9207IssuerOnMissingPKCE asserts a +// missing PKCE challenge is reported by redirecting back to the client +// (client_id/redirect_uri are already validated by this point) with +// error=invalid_request, the original state, and the RFC 9207 "iss" +// parameter. +func TestHandleAuthorizeRedirectsWithRFC9207IssuerOnMissingPKCE(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := validAuthorizeQuery(url.Values{"code_challenge": {""}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + location := parseLocation(t, rec) + assert.Equal(t, "invalid_request", location.Query().Get("error")) + assert.Equal(t, "state-value", location.Query().Get("state")) + assert.Equal(t, testIssuer, location.Query().Get("iss")) +} + +func TestHandleAuthorizeRedirectsInvalidTargetOnMissingResource(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := validAuthorizeQuery(url.Values{"resource": {""}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + location := parseLocation(t, rec) + assert.Equal(t, "invalid_target", location.Query().Get("error")) + assert.Equal(t, testIssuer, location.Query().Get("iss")) +} + +func TestHandleAuthorizeRedirectsInvalidTargetOnWrongResource(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := validAuthorizeQuery(url.Values{"resource": {"https://not-mcp.example/mcp"}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "invalid_target", parseLocation(t, rec).Query().Get("error")) +} + +func TestHandleAuthorizeRedirectsInvalidScopeOnUnknownScope(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := validAuthorizeQuery(url.Values{"scope": {"not-a-real-scope"}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "invalid_scope", parseLocation(t, rec).Query().Get("error")) +} + +// TestHandleAuthorizeCreatesTransactionAndRendersFirebaseLoginPage covers +// the happy path: a fully valid request creates a persisted +// AuthorizationTransaction and renders the Firebase login page with the +// client name, requested scopes, and Firebase configuration -- and never +// puts anything sensitive in the page except the (non-secret) Firebase Web +// API key. +func TestHandleAuthorizeCreatesTransactionAndRendersFirebaseLoginPage(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+validAuthorizeQuery(nil), nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") + + body := rec.Body.String() + assert.Contains(t, body, "Test Client") + assert.Contains(t, body, "test-firebase-api-key") + assert.Contains(t, body, "httpsms-test.firebaseapp.com") + assert.Contains(t, body, "Send SMS messages on your behalf") + + transactionID := extractTransactionID(t, body) + transaction, err := store.GetAuthorizationTransaction(context.Background(), transactionID) + require.NoError(t, err) + assert.Equal(t, testClientID, transaction.ClientID) + assert.Equal(t, testRedirect, transaction.RedirectURI) + assert.Equal(t, testResource, transaction.Resource) + assert.Equal(t, "state-value", transaction.State) + assert.Equal(t, []string{"phones:read", "messages:send"}, transaction.Scopes) + assert.Equal(t, "S256", transaction.CodeChallengeMethod) +} + +// parseLocation parses the Location header of a redirect response. +func parseLocation(t *testing.T, rec *httptest.ResponseRecorder) *url.URL { + t.Helper() + + location, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + + return location +} + +// startAuthorization drives a full GET /oauth/authorize happy path and +// returns the created transaction ID, for tests of +// HandleFirebaseComplete/HandleToken that need a real, store-backed +// transaction. +func startAuthorization(t *testing.T, server *Server, extra url.Values) string { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+validAuthorizeQuery(extra), nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + return extractTransactionID(t, rec.Body.String()) +} + +func TestHandleFirebaseCompleteRejectsMissingTransactionID(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodPost, "/oauth/firebase/complete", nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + server.HandleFirebaseComplete(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestHandleFirebaseCompleteRejectsUnknownTransaction(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {"never-issued"}, + "id_token": {"some-token"}, + }) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestHandleFirebaseCompleteRedirectsAccessDeniedOnDenial(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "denied": {"1"}, + }) + + require.Equal(t, http.StatusFound, rec.Code) + location := parseLocation(t, rec) + assert.Equal(t, "access_denied", location.Query().Get("error")) + assert.Equal(t, "state-value", location.Query().Get("state")) + assert.Equal(t, testIssuer, location.Query().Get("iss")) +} + +// TestHandleFirebaseCompleteRejectsBadIdentityToken covers Step 3 of the +// brief: a bad identity token must be rejected, and rejected directly (not +// via a client redirect) since the transaction's authenticity has not yet +// been established. +func TestHandleFirebaseCompleteRejectsBadIdentityToken(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, stubVerifier{err: auth.ErrInvalidIdentityToken}) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"bad-token"}, + "approved_scopes": {"phones:read", "messages:send"}, + }) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestHandleFirebaseCompleteRejectsMissingIdentityToken(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + }) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +// TestHandleFirebaseCompleteIssuesOneTimeCodeAndRedirects covers a valid +// token and approved scopes issuing a one-time code redirect, carrying +// state and the RFC 9207 "iss" parameter, and the code being redeemable +// exactly once against the Store. +func TestHandleFirebaseCompleteIssuesOneTimeCodeAndRedirects(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read", "messages:send"}, + }) + + require.Equal(t, http.StatusFound, rec.Code) + location := parseLocation(t, rec) + assert.Equal(t, "state-value", location.Query().Get("state")) + assert.Equal(t, testIssuer, location.Query().Get("iss")) + code := location.Query().Get("code") + require.NotEmpty(t, code) + + record, err := store.ConsumeAuthorizationCode(context.Background(), code) + require.NoError(t, err) + assert.Equal(t, testFirebaseID, record.UserID) + assert.Equal(t, testUserEmail, record.Email) + assert.Equal(t, []string{"phones:read", "messages:send"}, record.Scopes) + assert.Equal(t, testResource, record.Resource) + + _, err = store.ConsumeAuthorizationCode(context.Background(), code) + require.ErrorIs(t, err, ErrNotFound) +} + +// TestHandleFirebaseCompleteNarrowsToApprovedScopesOnly asserts a user +// approving fewer scopes than requested results in a code bound to only +// the approved subset -- and that approving a scope outside what was +// requested cannot expand it. +func TestHandleFirebaseCompleteNarrowsToApprovedScopesOnly(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read", "user-api-key:rotate"}, // "user-api-key:rotate" was never requested + }) + + require.Equal(t, http.StatusFound, rec.Code) + code := parseLocation(t, rec).Query().Get("code") + + record, err := store.ConsumeAuthorizationCode(context.Background(), code) + require.NoError(t, err) + assert.Equal(t, []string{"phones:read"}, record.Scopes) +} + +func TestHandleFirebaseCompleteRedirectsAccessDeniedWhenNoScopeApproved(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + }) + + require.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "access_denied", parseLocation(t, rec).Query().Get("error")) +} + +// TestHandleAuthorizeRejectsDuplicateParameters asserts a repeated +// authorization parameter is rejected outright rather than resolved by +// "first wins": two different consumers of the same URL picking different +// occurrences is a parameter-smuggling primitive. +func TestHandleAuthorizeRejectsDuplicateParameters(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + for _, name := range []string{"client_id", "redirect_uri", "state", "scope", "resource", "code_challenge"} { + t.Run(name, func(t *testing.T) { + query := validAuthorizeQuery(nil) + "&" + url.Values{name: {"duplicate-value"}}.Encode() + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid_request") + assert.Empty(t, rec.Header().Get("Location"), "a duplicated parameter must never produce a redirect") + }) + } +} + +// TestHandleAuthorizeRejectsMalformedCodeChallenge asserts only a +// syntactically valid S256 challenge (43 base64url characters) is accepted. +func TestHandleAuthorizeRejectsMalformedCodeChallenge(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + valid := pkceChallengeFor("test-verifier") + require.Len(t, valid, 43) + + testCases := map[string]string{ + "empty": "", + "too short": valid[:42], + "too long": valid + "A", + "invalid alphabet": valid[:42] + "+", + "padded base64": valid[:42] + "=", + } + + for name, challenge := range testCases { + t.Run(name, func(t *testing.T) { + query := validAuthorizeQuery(url.Values{"code_challenge": {challenge}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "invalid_request", parseLocation(t, rec).Query().Get("error")) + }) + } +} + +// TestHandleAuthorizeDeduplicatesRequestedScopes asserts a repeated scope +// inside the single "scope" parameter is collapsed once, preserving the +// order the client asked in. +func TestHandleAuthorizeDeduplicatesRequestedScopes(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + query := validAuthorizeQuery(url.Values{"scope": {"messages:send phones:read messages:send phones:read"}}) + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+query, nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + transaction, err := store.GetAuthorizationTransaction(context.Background(), extractTransactionID(t, rec.Body.String())) + require.NoError(t, err) + assert.Equal(t, []string{"messages:send", "phones:read"}, transaction.Scopes) +} + +// TestHandleAuthorizeSetsConsentPageProtections asserts the rendered +// consent page cannot be cached, framed, or leak its URL through a Referer +// header. +func TestHandleAuthorizeSetsConsentPageProtections(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+validAuthorizeQuery(nil), nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", rec.Header().Get("Pragma")) + assert.Equal(t, "DENY", rec.Header().Get("X-Frame-Options")) + assert.Equal(t, "no-referrer", rec.Header().Get("Referrer-Policy")) + + csp := rec.Header().Get("Content-Security-Policy") + assert.Contains(t, csp, "frame-ancestors 'none'") + assert.Contains(t, csp, "default-src 'none'") + assert.Contains(t, csp, "form-action 'self'") + assert.Contains(t, csp, "base-uri 'none'") + assert.NotContains(t, csp, "'unsafe-eval'") + + // Injected markup must never be able to execute: the page's own + // scripts are admitted by nonce, never by 'unsafe-inline'. + scriptSrc := cspDirective(t, csp, "script-src") + assert.Contains(t, scriptSrc, "'nonce-") + assert.NotContains(t, scriptSrc, "'unsafe-inline'") +} + +// cspDirective returns the source list of the named directive in policy. +func cspDirective(t *testing.T, policy string, name string) string { + t.Helper() + + for _, directive := range strings.Split(policy, ";") { + directive = strings.TrimSpace(directive) + if after, found := strings.CutPrefix(directive, name+" "); found { + return after + } + } + + t.Fatalf("policy %q has no %q directive", policy, name) + return "" +} + +// TestHandleAuthorizeConsentPageCSPAdmitsEveryFirebaseDependency asserts +// the consent page's CSP still allows every origin the Firebase Web SDK +// needs (its bundles, its identity endpoints, and its sign-in popup +// origins) and admits this render's own inline script by the exact nonce +// the rendered markup carries -- a policy that blocked any of these would +// break sign-in entirely. +func TestHandleAuthorizeConsentPageCSPAdmitsEveryFirebaseDependency(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+validAuthorizeQuery(nil), nil) + rec := httptest.NewRecorder() + + server.HandleAuthorize(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + csp := rec.Header().Get("Content-Security-Policy") + for _, source := range []string{ + "https://www.gstatic.com", + "https://identitytoolkit.googleapis.com", + "https://securetoken.googleapis.com", + "https://apis.google.com", + "https://accounts.google.com", + "https://httpsms-test.firebaseapp.com", + } { + assert.Containsf(t, csp, source, "CSP must admit %s", source) + } + + nonceMatch := regexp.MustCompile(`'nonce-([A-Za-z0-9_-]+)'`).FindStringSubmatch(csp) + require.Lenf(t, nonceMatch, 2, "CSP must carry a script nonce: %s", csp) + + body := rec.Body.String() + assert.Contains(t, body, ` + + + +

{{.ClientName}} wants to access your httpSMS account

+

Signing in will let {{.ClientName}} do the following:

+
    + {{range .Scopes}}
  • {{.Description}}
  • {{end}} +
+ + +
+ + + {{range .Scopes}}{{end}} +
+ +
+ + + +
+ + + + + +
+
+ + + +
+ + + +
+ + + + diff --git a/mcp/internal/oauth/token.go b/mcp/internal/oauth/token.go new file mode 100644 index 00000000..8961de7c --- /dev/null +++ b/mcp/internal/oauth/token.go @@ -0,0 +1,257 @@ +package oauth + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "net/http" + "strings" + "time" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +// Bounds and sizes used by the token endpoint. +const ( + // refreshTokenBytes and refreshTokenFamilyIDBytes are the amount of + // crypto/rand entropy (see newRandomToken) encoded into, + // respectively, an opaque refresh token and a refresh-token family ID. + refreshTokenBytes = 32 + refreshTokenFamilyIDBytes = 16 +) + +// tokenResponse is the success response body of POST /oauth/token, for +// both the authorization_code and refresh_token grants. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +// tokenRequestParams are the POST /oauth/token body parameters that must +// appear at most once; a repeated parameter is a smuggling primitive, not +// a request this server will guess the intent of. +var tokenRequestParams = []string{ + "grant_type", + "code", + "code_verifier", + "client_id", + "redirect_uri", + "resource", + "refresh_token", + "scope", +} + +// HandleToken implements POST /oauth/token: the authorization_code grant +// (exchanging a one-time, PKCE-bound code for tokens) and the +// refresh_token grant (rotating a previously issued refresh token for a +// new access/refresh token pair). The request body must be form-encoded +// and is bounded to maxFormBodyBytes. Every error response is an +// OAuth-compliant JSON body (RFC 6749 Section 5.2) with +// "Cache-Control: no-store". +func (s *Server) HandleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !parseFormRequest(w, r) { + return + } + + if repeated := firstRepeatedParam(r.PostForm, tokenRequestParams); repeated != "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "each token request parameter must appear exactly once") + return + } + + switch r.PostFormValue("grant_type") { + case "authorization_code": + s.handleAuthorizationCodeGrant(w, r) + case "refresh_token": + s.handleRefreshTokenGrant(w, r) + case "": + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "grant_type is required") + default: + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "grant_type must be \"authorization_code\" or \"refresh_token\"") + } +} + +// handleAuthorizationCodeGrant redeems a one-time authorization code for +// an access/refresh token pair. The code, and the client_id, redirect_uri, +// and resource it was bound to at authorization time, and its PKCE +// challenge, must all match exactly; the code is consumed (one-time use) +// before any of those checks run, so even a code rejected for a mismatch +// can never be redeemed again. +func (s *Server) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { + code := r.PostFormValue("code") + verifier := r.PostFormValue("code_verifier") + clientID := r.PostFormValue("client_id") + redirectURI := r.PostFormValue("redirect_uri") + resource := r.PostFormValue("resource") + + if code == "" || verifier == "" || clientID == "" || redirectURI == "" || resource == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "code, code_verifier, client_id, redirect_uri, and resource are all required") + return + } + + record, err := s.store.ConsumeAuthorizationCode(r.Context(), code) + if err != nil { + writeStoreError(w, err, "invalid_grant", "the authorization code is invalid, expired, or already used") + return + } + + if record.ClientID != clientID || record.RedirectURI != redirectURI || record.Resource != resource { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "the authorization code does not match the supplied client_id, redirect_uri, or resource") + return + } + + if !verifyPKCE(record.CodeChallenge, record.CodeChallengeMethod, verifier) { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "code_verifier does not match the authorization request") + return + } + + principal := auth.Principal{UserID: record.UserID, Email: record.Email} + s.issueTokens(w, r.Context(), principal, record.ClientID, record.Scopes, record.Resource, "", "") +} + +// handleRefreshTokenGrant rotates refreshToken for a new access/refresh +// token pair. The new refresh token replaces the old one atomically +// (Store.RotateRefreshToken): a replayed old refresh token always fails +// with "invalid_grant", even if a legitimate rotation already consumed it +// moments earlier. +func (s *Server) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + refreshToken := r.PostFormValue("refresh_token") + clientID := r.PostFormValue("client_id") + + if refreshToken == "" || clientID == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "refresh_token and client_id are required") + return + } + + grant, err := s.store.GetRefreshToken(r.Context(), refreshToken) + if err != nil { + writeStoreError(w, err, "invalid_grant", "the refresh token is invalid, expired, or already used") + return + } + + if grant.ClientID != clientID { + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "the refresh token was not issued to this client") + return + } + + resource := grant.Resource + if requested := r.PostFormValue("resource"); requested != "" && requested != grant.Resource { + writeOAuthError(w, http.StatusBadRequest, "invalid_target", "resource does not match the original grant") + return + } + + scopes := grant.Scopes + if rawScope := r.PostFormValue("scope"); rawScope != "" { + requested := strings.Fields(rawScope) + if !isSubsetOfScopes(requested, grant.Scopes) { + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", "requested scope exceeds the originally granted scope") + return + } + scopes = requested + } + + principal := auth.Principal{UserID: grant.UserID, Email: grant.Email} + s.issueTokens(w, r.Context(), principal, grant.ClientID, scopes, resource, refreshToken, grant.FamilyID) +} + +// issueTokens mints an MCP access token for principal/clientID/scopes and +// either creates (rotateOldToken == "") or atomically rotates +// (rotateOldToken != "") an opaque refresh token, then writes the RFC +// 6749-shaped success response. familyID is reused across rotations of +// the same refresh-token lineage and is freshly generated on first issue. +func (s *Server) issueTokens(w http.ResponseWriter, ctx context.Context, principal auth.Principal, clientID string, scopes []string, resource string, rotateOldToken string, familyID string) { + accessToken, err := s.keys.SignMCPAccessToken(principal, clientID, scopes, s.config.AccessTokenTTL) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "cannot mint an access token") + return + } + + newRefreshToken, err := newRandomToken(refreshTokenBytes) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "cannot issue a refresh token") + return + } + + if familyID == "" { + familyID, err = newRandomToken(refreshTokenFamilyIDBytes) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "cannot issue a refresh token") + return + } + } + + newGrant := RefreshGrant{ + Token: newRefreshToken, + UserID: principal.UserID, + Email: principal.Email, + ClientID: clientID, + Scopes: scopes, + Resource: resource, + FamilyID: familyID, + CreatedAt: time.Now().UTC(), + } + + var storeErr error + if rotateOldToken == "" { + storeErr = s.store.PutRefreshToken(ctx, newGrant, s.config.RefreshTokenTTL) + } else { + storeErr = s.store.RotateRefreshToken(ctx, rotateOldToken, newGrant, s.config.RefreshTokenTTL) + } + if storeErr != nil { + // Only a lost rotation race (the old token was already consumed) + // is the client's problem; a Redis or serialization failure is + // ours and must not be reported as an invalid grant. + writeStoreError(w, storeErr, "invalid_grant", "the refresh token is invalid, expired, or already used") + return + } + + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: int64(s.config.AccessTokenTTL.Seconds()), + RefreshToken: newRefreshToken, + Scope: strings.Join(scopes, " "), + }) +} + +// verifyPKCE reports whether verifier is the correct RFC 7636 S256 PKCE +// code verifier for challenge. Only the "S256" method is supported; any +// other (or missing) method fails closed. +func verifyPKCE(challenge, method, verifier string) bool { + if method != "S256" || challenge == "" || verifier == "" { + return false + } + + sum := sha256.Sum256([]byte(verifier)) + computed := base64.RawURLEncoding.EncodeToString(sum[:]) + + return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1 +} + +// isSubsetOfScopes reports whether every entry of subset is present in +// superset, and subset is non-empty. +func isSubsetOfScopes(subset []string, superset []string) bool { + if len(subset) == 0 { + return false + } + + supersetSet := make(map[string]bool, len(superset)) + for _, scope := range superset { + supersetSet[scope] = true + } + for _, scope := range subset { + if !supersetSet[scope] { + return false + } + } + return true +} diff --git a/mcp/internal/oauth/token_test.go b/mcp/internal/oauth/token_test.go new file mode 100644 index 00000000..e9012467 --- /dev/null +++ b/mcp/internal/oauth/token_test.go @@ -0,0 +1,516 @@ +package oauth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" +) + +// issueTestAuthorizationCode drives a full authorize -> Firebase-complete +// round trip and returns the resulting one-time authorization code, PKCE +// -bound to verifier and requesting/approving scopes (defaulting to +// "phones:read messages:send" when scopes is nil). +func issueTestAuthorizationCode(t *testing.T, server *Server, verifier string, scopes []string) string { + t.Helper() + + if scopes == nil { + scopes = []string{"phones:read", "messages:send"} + } + + extra := url.Values{"code_challenge": {pkceChallengeFor(verifier)}} + extra.Set("scope", strings.Join(scopes, " ")) + transactionID := startAuthorization(t, server, extra) + + values := url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": scopes, + } + rec := postForm(t, server.HandleFirebaseComplete, values) + require.Equal(t, http.StatusFound, rec.Code, "firebase complete must redirect with a code: %s", rec.Body.String()) + + return parseLocation(t, rec).Query().Get("code") +} + +// postToken posts values to server.HandleToken as an +// application/x-www-form-urlencoded request. +func postToken(t *testing.T, server *Server, values url.Values) *httptest.ResponseRecorder { + t.Helper() + + return postForm(t, server.HandleToken, values) +} + +// authorizationCodeGrantValues builds a valid POST /oauth/token +// authorization_code grant request body for code/verifier. +func authorizationCodeGrantValues(code, verifier string) url.Values { + return url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "code_verifier": {verifier}, + "client_id": {testClientID}, + "redirect_uri": {testRedirect}, + "resource": {testResource}, + } +} + +func decodeTokenResponse(t *testing.T, rec *httptest.ResponseRecorder) tokenResponse { + t.Helper() + + var body tokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + return body +} + +func decodeOAuthError(t *testing.T, rec *httptest.ResponseRecorder) oauthError { + t.Helper() + + var body oauthError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + return body +} + +// TestTokenEndpointConsumesCodeAndChecksPKCE is the literal scenario from +// the brief: a valid exchange succeeds exactly once, and replaying the +// same request afterward fails. +func TestTokenEndpointConsumesCodeAndChecksPKCE(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + values := authorizationCodeGrantValues(code, "verifier") + + response := postToken(t, server, values) + require.Equal(t, http.StatusOK, response.Code) + + body := decodeTokenResponse(t, response) + assert.NotEmpty(t, body.AccessToken) + assert.Equal(t, "Bearer", body.TokenType) + assert.NotEmpty(t, body.RefreshToken) + assert.Equal(t, "phones:read messages:send", body.Scope) + assert.Equal(t, int64(15*60), body.ExpiresIn) + + postAgain := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, postAgain.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, postAgain).Error) +} + +func TestTokenEndpointRejectsWrongVerifier(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "correct-verifier", nil) + + response := postToken(t, server, authorizationCodeGrantValues(code, "wrong-verifier")) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsWrongClientID(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + + values := authorizationCodeGrantValues(code, "verifier") + values.Set("client_id", "some-other-client") + + response := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsWrongRedirectURI(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + + values := authorizationCodeGrantValues(code, "verifier") + values.Set("redirect_uri", "https://client.example/other-callback") + + response := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsWrongResource(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + + values := authorizationCodeGrantValues(code, "verifier") + values.Set("resource", "https://not-mcp.example/mcp") + + response := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsMissingResource(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + + values := authorizationCodeGrantValues(code, "verifier") + values.Set("resource", "") + + response := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsMissingGrantType(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + response := postToken(t, server, url.Values{}) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsUnsupportedGrantType(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + response := postToken(t, server, url.Values{"grant_type": {"client_credentials"}}) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "unsupported_grant_type", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRejectsNonPOST(t *testing.T) { + store := newClientsTestStore(t) + server := newTestOAuthServer(t, store, approvingVerifier()) + + req := httptest.NewRequest(http.MethodGet, "/oauth/token", nil) + rec := httptest.NewRecorder() + + server.HandleToken(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +// TestTokenEndpointMintsAudienceBoundAccessTokenWithGrantedScopes verifies +// the minted access token is a real, verifiable JWT audience-bound to the +// configured MCP resource and carrying exactly the granted scopes and +// subject. +func TestTokenEndpointMintsAudienceBoundAccessTokenWithGrantedScopes(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", []string{"phones:read"}) + response := postToken(t, server, authorizationCodeGrantValues(code, "verifier")) + require.Equal(t, http.StatusOK, response.Code) + + body := decodeTokenResponse(t, response) + + claims := new(auth.AccessClaims) + token, err := jwt.ParseWithClaims(body.AccessToken, claims, func(*jwt.Token) (any, error) { + return server.keys.PublicKey(), nil + }) + require.NoError(t, err) + require.True(t, token.Valid) + + assert.Equal(t, testFirebaseID, claims.Subject) + assert.Equal(t, []string{testResource}, []string(claims.Audience)) + assert.Equal(t, testIssuer, claims.Issuer) + assert.Equal(t, []string{"phones:read"}, claims.Scopes) + assert.Equal(t, testClientID, claims.ClientID) +} + +func TestTokenEndpointRefreshRotatesTokenAndRejectsReplayOfOldToken(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + first := postToken(t, server, authorizationCodeGrantValues(code, "verifier")) + require.Equal(t, http.StatusOK, first.Code) + firstBody := decodeTokenResponse(t, first) + + refreshValues := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {firstBody.RefreshToken}, + "client_id": {testClientID}, + } + + second := postToken(t, server, refreshValues) + require.Equal(t, http.StatusOK, second.Code) + secondBody := decodeTokenResponse(t, second) + assert.NotEqual(t, firstBody.RefreshToken, secondBody.RefreshToken) + assert.NotEmpty(t, secondBody.AccessToken) + + // The old refresh token must not be usable again. + replay := postToken(t, server, refreshValues) + require.Equal(t, http.StatusBadRequest, replay.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, replay).Error) + + // The newly rotated refresh token, however, must still work. + rotatedAgain := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {secondBody.RefreshToken}, + "client_id": {testClientID}, + }) + require.Equal(t, http.StatusOK, rotatedAgain.Code) +} + +func TestTokenEndpointRefreshRejectsWrongClientID(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + first := decodeTokenResponse(t, postToken(t, server, authorizationCodeGrantValues(code, "verifier"))) + + response := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {first.RefreshToken}, + "client_id": {"some-other-client"}, + }) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRefreshRejectsUnknownToken(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + response := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {"never-issued"}, + "client_id": {testClientID}, + }) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRefreshRejectsWrongResource(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + first := decodeTokenResponse(t, postToken(t, server, authorizationCodeGrantValues(code, "verifier"))) + + response := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {first.RefreshToken}, + "client_id": {testClientID}, + "resource": {"https://not-mcp.example/mcp"}, + }) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_target", decodeOAuthError(t, response).Error) +} + +// TestTokenEndpointRefreshAllowsScopeNarrowing asserts a refresh request +// may ask for a strict subset of the originally granted scopes. +func TestTokenEndpointRefreshAllowsScopeNarrowing(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", []string{"phones:read", "messages:send"}) + first := decodeTokenResponse(t, postToken(t, server, authorizationCodeGrantValues(code, "verifier"))) + + response := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {first.RefreshToken}, + "client_id": {testClientID}, + "scope": {"phones:read"}, + }) + require.Equal(t, http.StatusOK, response.Code) + assert.Equal(t, "phones:read", decodeTokenResponse(t, response).Scope) +} + +// TestTokenEndpointRefreshRejectsScopeExpansion asserts a refresh request +// can never be granted a scope beyond what was originally issued. +func TestTokenEndpointRefreshRejectsScopeExpansion(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", []string{"phones:read"}) + first := decodeTokenResponse(t, postToken(t, server, authorizationCodeGrantValues(code, "verifier"))) + + response := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {first.RefreshToken}, + "client_id": {testClientID}, + "scope": {"phones:read messages:send"}, + }) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_scope", decodeOAuthError(t, response).Error) +} + +func TestTokenEndpointRefreshRejectsMissingClientID(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + first := decodeTokenResponse(t, postToken(t, server, authorizationCodeGrantValues(code, "verifier"))) + + response := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {first.RefreshToken}, + }) + require.Equal(t, http.StatusBadRequest, response.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, response).Error) +} + +// TestVerifyPKCERejectsNonS256Method documents that only "S256" is ever +// accepted, never "plain". +func TestVerifyPKCERejectsNonS256Method(t *testing.T) { + assert.False(t, verifyPKCE("challenge", "plain", "challenge")) +} + +// TestTokenEndpointRejectsUnsupportedContentType asserts the token +// endpoint only accepts form-encoded bodies (RFC 6749 Section 4.1.3), and +// reports anything else as "invalid_request". +func TestTokenEndpointRejectsUnsupportedContentType(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + body := authorizationCodeGrantValues(code, "verifier").Encode() + + for _, contentType := range []string{"application/json", "text/plain", "multipart/form-data; boundary=x"} { + t.Run(contentType, func(t *testing.T) { + rec := postBody(t, server.HandleToken, contentType, body) + + require.Equal(t, http.StatusBadRequest, rec.Code) + failure := decodeOAuthError(t, rec) + assert.Equal(t, "invalid_request", failure.Error) + assert.Contains(t, failure.ErrorDescription, "Content-Type must be application/x-www-form-urlencoded") + }) + } + + // The rejected requests must not have consumed the code: the same body + // still succeeds once it is correctly labelled. + success := postBody(t, server.HandleToken, "application/x-www-form-urlencoded; charset=UTF-8", body) + require.Equal(t, http.StatusOK, success.Code) +} + +func TestTokenEndpointRejectsMissingContentType(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + rec := postBody(t, server.HandleToken, "", "grant_type=authorization_code") + require.Equal(t, http.StatusBadRequest, rec.Code) + failure := decodeOAuthError(t, rec) + assert.Equal(t, "invalid_request", failure.Error) + assert.Contains(t, failure.ErrorDescription, "Content-Type must be application/x-www-form-urlencoded") +} + +// TestTokenEndpointRejectsOversizeBody asserts a body larger than the +// 64 KiB bound is refused rather than buffered. +func TestTokenEndpointRejectsOversizeBody(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + values := authorizationCodeGrantValues(code, "verifier") + values.Set("padding", strings.Repeat("a", maxFormBodyBytes+1)) + + rec := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, rec).Error) +} + +// TestTokenEndpointRejectsDuplicateParameters asserts a repeated token +// parameter is refused instead of resolved by "first wins". +func TestTokenEndpointRejectsDuplicateParameters(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + values := authorizationCodeGrantValues(code, "verifier") + values["client_id"] = []string{testClientID, "some-other-client"} + + rec := postToken(t, server, values) + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, rec).Error) +} + +// TestTokenEndpointReturnsServerErrorWhenCodeLookupFails asserts a Redis +// failure while consuming an authorization code is a 500 "server_error", +// not an "invalid_grant" that would make a client discard a valid code. +func TestTokenEndpointReturnsServerErrorWhenCodeLookupFails(t *testing.T) { + base := newClientsTestStore(t) + registerTestClient(t, base, testClientID, []string{testRedirect}) + failing := &errorStore{Store: base} + server := newTestOAuthServer(t, failing, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + failing.failConsumeCode = true + + rec := postToken(t, server, authorizationCodeGrantValues(code, "verifier")) + require.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "server_error", decodeOAuthError(t, rec).Error) +} + +// TestTokenEndpointRefreshDistinguishesMissingTokenFromStoreFailure +// asserts an unknown/expired refresh token is "invalid_grant" (400), while +// a Redis failure looking one up is "server_error" (500). +func TestTokenEndpointRefreshDistinguishesMissingTokenFromStoreFailure(t *testing.T) { + base := newClientsTestStore(t) + registerTestClient(t, base, testClientID, []string{testRedirect}) + failing := &errorStore{Store: base} + server := newTestOAuthServer(t, failing, approvingVerifier()) + + code := issueTestAuthorizationCode(t, server, "verifier", nil) + first := decodeTokenResponse(t, postToken(t, server, authorizationCodeGrantValues(code, "verifier"))) + + refreshValues := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {first.RefreshToken}, + "client_id": {testClientID}, + } + + failing.failGetRefreshToken = true + lookupFailure := postToken(t, server, refreshValues) + require.Equal(t, http.StatusInternalServerError, lookupFailure.Code) + assert.Equal(t, "server_error", decodeOAuthError(t, lookupFailure).Error) + + failing.failGetRefreshToken = false + failing.failRotateRefreshToken = true + rotateFailure := postToken(t, server, refreshValues) + require.Equal(t, http.StatusInternalServerError, rotateFailure.Code) + assert.Equal(t, "server_error", decodeOAuthError(t, rotateFailure).Error) + + // A genuinely unknown token remains a client error. + failing.failRotateRefreshToken = false + unknown := postToken(t, server, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {"never-issued"}, + "client_id": {testClientID}, + }) + require.Equal(t, http.StatusBadRequest, unknown.Code) + assert.Equal(t, "invalid_grant", decodeOAuthError(t, unknown).Error) +} diff --git a/mcp/internal/observability/observability.go b/mcp/internal/observability/observability.go new file mode 100644 index 00000000..854a7f4c --- /dev/null +++ b/mcp/internal/observability/observability.go @@ -0,0 +1,100 @@ +// Package observability bootstraps the httpSMS MCP service's structured +// logging and distributed tracing, following the same conventions as the +// httpSMS API: JSON logs enriched with service/version fields, W3C trace +// context propagation, and an OpenTelemetry tracer provider that exports to +// whichever backend is configured through the environment (or exports +// nowhere, in local development, when none is configured). +package observability + +import ( + "context" + "fmt" + "os" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +// Environment variables that select an OTLP exporter destination for traces. +// These are the standard OpenTelemetry SDK variable names; otlptracehttp +// reads OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_TRACES_ENDPOINT +// itself, but New checks for their presence up front so it can fall back to +// a no-exporter local mode instead of constructing an exporter that would +// otherwise silently point nowhere. +const ( + otlpEndpointEnv = "OTEL_EXPORTER_OTLP_ENDPOINT" + otlpTracesEndpointEnv = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" +) + +// New configures JSON logging and OpenTelemetry tracing for serviceName at +// version. It registers a global W3C (tracecontext + baggage) propagator and +// a global TracerProvider, then returns a logger, a shutdown function that +// flushes and stops the tracer provider, and any setup error. +// +// When neither OTEL_EXPORTER_OTLP_ENDPOINT nor OTEL_EXPORTER_OTLP_TRACES_ENDPOINT +// is set, New registers a TracerProvider with no span processor: spans are +// still created (so context propagation and span-derived log fields keep +// working) but nothing is exported over the network. This is the local +// development / test mode. +func New(ctx context.Context, serviceName string, version string) (zerolog.Logger, func(context.Context) error, error) { + logger := newLogger(serviceName, version) + + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + res, err := resource.Merge( + resource.Default(), + resource.NewSchemaless( + semconv.ServiceName(serviceName), + semconv.ServiceVersion(version), + ), + ) + if err != nil { + return logger, noopShutdown, fmt.Errorf("observability: cannot build resource: %w", err) + } + + options := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)} + + if hasOTLPExporterConfig() { + exporter, err := otlptracehttp.New(ctx) + if err != nil { + return logger, noopShutdown, fmt.Errorf("observability: cannot create OTLP trace exporter: %w", err) + } + options = append(options, sdktrace.WithBatcher(exporter)) + } + + provider := sdktrace.NewTracerProvider(options...) + otel.SetTracerProvider(provider) + + return logger, provider.Shutdown, nil +} + +// hasOTLPExporterConfig reports whether an OTLP trace exporter destination is +// configured through the environment. It never inspects endpoint values (no +// secrets are logged), only whether they are present. +func hasOTLPExporterConfig() bool { + return os.Getenv(otlpEndpointEnv) != "" || os.Getenv(otlpTracesEndpointEnv) != "" +} + +// newLogger builds a JSON zerolog.Logger writing to stdout, enriched with +// timestamp, service, and version fields. +func newLogger(serviceName string, version string) zerolog.Logger { + return zerolog.New(os.Stdout).With(). + Timestamp(). + Str("service", serviceName). + Str("version", version). + Logger() +} + +// noopShutdown is returned alongside a non-nil error from New, so callers can +// always defer the returned shutdown function unconditionally. +func noopShutdown(context.Context) error { + return nil +} diff --git a/mcp/internal/server/rate_limit.go b/mcp/internal/server/rate_limit.go new file mode 100644 index 00000000..8c710fa2 --- /dev/null +++ b/mcp/internal/server/rate_limit.go @@ -0,0 +1,215 @@ +// Package server assembles the httpSMS MCP service's HTTP surface: OAuth +// discovery/authorization/token endpoints, the stateless MCP Streamable +// HTTP handler, and the middleware chain (request ID, panic recovery, +// secure headers, tracing, redacted logging, bearer authentication, and +// per-user/per-tool rate limiting) around them. +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// ErrRateLimited is returned (wrapped) by ToolRateLimiter.Allow when userID +// has already exhausted its budget for tool in the current window. Callers +// should use errors.Is against this sentinel rather than matching error +// strings. +var ErrRateLimited = errors.New("server: rate limit exceeded") + +// Limits configures the per-user rate-limit budgets ToolRateLimiter +// enforces for each MCP tool bucket, mirroring config.Config's +// ReadToolsPerMinute, SendToolsPerMinute, KeyCreatesPerHour, and +// KeyRotationsPerHour fields. +type Limits struct { + // ReadPerMinute bounds list_phones, list_message_threads, + // list_thread_messages, and list_incoming_messages, per user, per + // rolling one-minute window. + ReadPerMinute int + + // SendPerMinute bounds send_sms, per user, per rolling one-minute + // window. + SendPerMinute int + + // KeyCreatesPerHour bounds create_phone_api_key, per user, per rolling + // one-hour window. + KeyCreatesPerHour int + + // KeyRotationsPerHour bounds rotate_user_api_key *executions* (a call + // presenting MRTR confirmation state or a legacy confirmation_handle), + // per user, per rolling one-hour window. The confirmation-prompt + // budget (rotateUserAPIKeyConfirmBucket) is derived from this field -- + // KeyRotationsPerHour * confirmationPromptMultiplier -- rather than + // being a separate configuration field, so there is nothing new to + // wire through config.Config or an environment variable. + KeyRotationsPerHour int +} + +// validate returns an error naming the first non-positive budget in l. A +// zero or negative budget is never a valid configuration: Allow treats it +// as "this tool is not rate limited at all", so a mis-wired or forgotten +// budget would silently remove the limit on exactly the tools (sending +// SMS, minting API keys, rotating the primary key) that most need one. +func (l Limits) validate() error { + switch { + case l.ReadPerMinute <= 0: + return errors.New("server: Limits.ReadPerMinute must be positive") + case l.SendPerMinute <= 0: + return errors.New("server: Limits.SendPerMinute must be positive") + case l.KeyCreatesPerHour <= 0: + return errors.New("server: Limits.KeyCreatesPerHour must be positive") + case l.KeyRotationsPerHour <= 0: + return errors.New("server: Limits.KeyRotationsPerHour must be positive") + default: + return nil + } +} + +// bucket is one rate-limit budget: how many calls tool may receive from a +// single user within window. +type bucket struct { + limit int + window time.Duration +} + +// rotateUserAPIKeyConfirmBucket is the distinct rate-limit bucket for +// confirmation-only rotate_user_api_key calls (an MRTR or legacy first +// call that only mints a confirmation handle and can never itself rotate +// anything). It is deliberately not a real MCP tool name -- no registered +// tool name contains ":" -- so it can never collide with, or be spent +// from, "rotate_user_api_key"'s own execution budget, and a Redis key +// derived from it (see rateLimitKey) is always a distinct counter. +const rotateUserAPIKeyConfirmBucket = "rotate_user_api_key:confirm" + +// confirmationPromptMultiplier sets the confirmation-prompt bucket's +// hourly budget as a multiple of KeyRotationsPerHour. Before this, a +// confirmation-only call was charged nothing at all, so a caller could +// mint unlimited confirmation handles per hour; charging it against the +// execution bucket instead would let a client burn the whole hourly +// rotation budget on prompts alone and leave the user unable to complete a +// rotation they had just been asked to confirm. A separate, wider budget +// bounds prompt spam while still leaving room for a user to hesitate, +// retry after a crash, or explore the flow more than once per hour. +const confirmationPromptMultiplier = 5 + +// buckets maps every rate-limited MCP tool bucket -- the seven real MCP +// tool names plus the synthetic rotateUserAPIKeyConfirmBucket -- to the +// budget that bounds it. A bucket absent from this map (there are none +// today) is never rate limited. +func (l Limits) buckets() map[string]bucket { + return map[string]bucket{ + "list_phones": {limit: l.ReadPerMinute, window: time.Minute}, + "list_message_threads": {limit: l.ReadPerMinute, window: time.Minute}, + "list_thread_messages": {limit: l.ReadPerMinute, window: time.Minute}, + "list_incoming_messages": {limit: l.ReadPerMinute, window: time.Minute}, + "send_sms": {limit: l.SendPerMinute, window: time.Minute}, + "create_phone_api_key": {limit: l.KeyCreatesPerHour, window: time.Hour}, + "rotate_user_api_key": {limit: l.KeyRotationsPerHour, window: time.Hour}, + rotateUserAPIKeyConfirmBucket: {limit: l.KeyRotationsPerHour * confirmationPromptMultiplier, window: time.Hour}, + } +} + +// keyPrefixRateLimit namespaces every rate-limit counter key. +const keyPrefixRateLimit = "httpsms:mcp:ratelimit:" + +// rateLimitScript atomically increments the counter for a rate-limit +// window and, only on the first increment (count == 1), sets its expiry. +// A Lua script run through EVAL is the only way to make "increment" and +// "set the window's expiry" a single indivisible operation: running INCR +// and EXPIRE as two separate commands (even inside a MULTI/EXEC +// transaction, which cannot branch) would leave a window without a TTL if +// the process crashed between them, or would reset another caller's +// window if two requests raced to set it. +var rateLimitScript = redis.NewScript(` +local count = redis.call("INCR", KEYS[1]) +if count == 1 then + redis.call("PEXPIRE", KEYS[1], ARGV[1]) +end +return count +`) + +// RateLimitError reports that a caller has exceeded its rate-limit budget. +// It wraps ErrRateLimited (so errors.Is(err, ErrRateLimited) reports true) +// while also carrying the RetryAfter duration a client should wait before +// trying again. +type RateLimitError struct { + // Tool is the MCP tool name the caller was rate limited on. + Tool string + // RetryAfter is how long the caller should wait before its next + // attempt to this same tool is likely to succeed. + RetryAfter time.Duration +} + +func (e *RateLimitError) Error() string { + return fmt.Sprintf("server: rate limit exceeded for tool %q, retry after %s", e.Tool, e.RetryAfter) +} + +// Unwrap allows errors.Is(err, ErrRateLimited) to succeed for a +// *RateLimitError. +func (e *RateLimitError) Unwrap() error { return ErrRateLimited } + +// ToolRateLimiter enforces the per-user, per-tool Redis-backed rate limits +// configured by Limits before every MCP tool call executes. +// +// It fails closed: a Redis error from Allow is returned as its own error +// (never ErrRateLimited, and never silently treated as "the call is +// allowed"). A caller must treat any non-nil error from Allow as "do not +// execute the tool call". +type ToolRateLimiter struct { + client redis.UniversalClient + limits Limits +} + +// NewToolRateLimiter returns a ToolRateLimiter enforcing limits, using +// client to store per-user/per-tool counters. client must be a standalone +// Redis client (never a cluster or ring client), matching every other +// Redis-backed component in this service. +func NewToolRateLimiter(client redis.UniversalClient, limits Limits) *ToolRateLimiter { + return &ToolRateLimiter{client: client, limits: limits} +} + +// Allow reports whether userID may call tool right now, atomically +// incrementing its counter for the current window as a side effect. It +// returns a *RateLimitError (unwrapping to ErrRateLimited) once userID has +// already made bucket.limit calls to tool within the current window. +// +// Tools with no configured bucket, or a non-positive limit, are never rate +// limited: Allow returns nil immediately without touching Redis. +// +// A Redis failure is returned as its own error (fmt.Errorf-wrapped, never +// ErrRateLimited): this rate limiter fails closed rather than allowing a +// call through when it cannot verify the caller's budget. +func (l *ToolRateLimiter) Allow(ctx context.Context, userID string, tool string) error { + b, limited := l.limits.buckets()[tool] + if !limited || b.limit <= 0 { + return nil + } + + windowStart := time.Now().UTC().Truncate(b.window) + key := rateLimitKey(userID, tool, windowStart) + + count, err := rateLimitScript.Run(ctx, l.client, []string{key}, b.window.Milliseconds()).Int() + if err != nil { + return fmt.Errorf("server: cannot check rate limit for tool %q: %w", tool, err) + } + + if count > b.limit { + return &RateLimitError{Tool: tool, RetryAfter: windowStart.Add(b.window).Sub(time.Now().UTC())} + } + + return nil +} + +// rateLimitKey returns the Redis key for userID's counter for tool during +// the window starting at windowStart. The key names userID only as the +// hex-encoded SHA-256 hash of the raw Firebase UID, never the raw value +// itself, matching every other Redis key namespace in this service. +func rateLimitKey(userID string, tool string, windowStart time.Time) string { + sum := sha256.Sum256([]byte(userID)) + return fmt.Sprintf("%s%s:%s:%d", keyPrefixRateLimit, hex.EncodeToString(sum[:]), tool, windowStart.Unix()) +} diff --git a/mcp/internal/server/rate_limit_test.go b/mcp/internal/server/rate_limit_test.go new file mode 100644 index 00000000..44c14df6 --- /dev/null +++ b/mcp/internal/server/rate_limit_test.go @@ -0,0 +1,161 @@ +package server_test + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/server" +) + +// newRateLimitTestRedis starts an in-process miniredis instance and returns +// a standalone *redis.Client pointed at it, matching the standalone-client +// requirement every Redis-backed component in this service shares. +func newRateLimitTestRedis(t *testing.T) *redis.Client { + t.Helper() + + mr := miniredis.RunT(t) + return redis.NewClient(&redis.Options{Addr: mr.Addr()}) +} + +func TestToolRateLimiterSeparatesUsersAndTools(t *testing.T) { + ctx := context.Background() + redisClient := newRateLimitTestRedis(t) + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{ReadPerMinute: 2}) + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + require.ErrorIs(t, limiter.Allow(ctx, "user-a", "list_phones"), server.ErrRateLimited) + require.NoError(t, limiter.Allow(ctx, "user-b", "list_phones")) +} + +func TestToolRateLimiterSeparatesToolBuckets(t *testing.T) { + ctx := context.Background() + redisClient := newRateLimitTestRedis(t) + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{ReadPerMinute: 1, SendPerMinute: 1}) + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + require.ErrorIs(t, limiter.Allow(ctx, "user-a", "list_phones"), server.ErrRateLimited) + // send_sms has its own, independent budget from list_phones. + require.NoError(t, limiter.Allow(ctx, "user-a", "send_sms")) + require.ErrorIs(t, limiter.Allow(ctx, "user-a", "send_sms"), server.ErrRateLimited) +} + +func TestToolRateLimiterAppliesReadSendKeyCreateAndKeyRotateBudgets(t *testing.T) { + ctx := context.Background() + redisClient := newRateLimitTestRedis(t) + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{ + ReadPerMinute: 1, + SendPerMinute: 1, + KeyCreatesPerHour: 1, + KeyRotationsPerHour: 1, + }) + + for _, tool := range []string{ + "list_phones", "list_message_threads", "list_thread_messages", "list_incoming_messages", + "send_sms", "create_phone_api_key", "rotate_user_api_key", + } { + require.NoError(t, limiter.Allow(ctx, "user-a", tool), "first call to %q", tool) + require.ErrorIs(t, limiter.Allow(ctx, "user-a", tool), server.ErrRateLimited, "second call to %q", tool) + } +} + +func TestToolRateLimiterAllowsUnlimitedTools(t *testing.T) { + ctx := context.Background() + redisClient := newRateLimitTestRedis(t) + + // A tool with no configured bucket (or a non-positive limit) is never + // rate limited, and must never touch Redis. + limiter := server.NewToolRateLimiter(redisClient, server.Limits{}) + for i := 0; i < 5; i++ { + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + } +} + +func TestToolRateLimiterErrorUnwrapsToErrRateLimitedAndCarriesRetryAfter(t *testing.T) { + ctx := context.Background() + redisClient := newRateLimitTestRedis(t) + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{ReadPerMinute: 1}) + require.NoError(t, limiter.Allow(ctx, "user-a", "list_phones")) + + err := limiter.Allow(ctx, "user-a", "list_phones") + require.Error(t, err) + require.ErrorIs(t, err, server.ErrRateLimited) + + var rateLimitErr *server.RateLimitError + require.ErrorAs(t, err, &rateLimitErr) + require.Equal(t, "list_phones", rateLimitErr.Tool) + require.Greater(t, rateLimitErr.RetryAfter, time.Duration(0)) + require.LessOrEqual(t, rateLimitErr.RetryAfter, time.Minute) +} + +func TestToolRateLimiterFailsClosedOnRedisError(t *testing.T) { + ctx := context.Background() + + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + mr.Close() // force every subsequent command to fail + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{ReadPerMinute: 5}) + err := limiter.Allow(ctx, "user-a", "list_phones") + require.Error(t, err) + require.NotErrorIs(t, err, server.ErrRateLimited) +} + +// TestToolRateLimiterRotationConfirmationBucketIsIndependentAndDerived +// asserts the confirmation-prompt bucket ("rotate_user_api_key:confirm") +// has its own budget -- KeyRotationsPerHour * 5 -- entirely independent of +// the execution bucket ("rotate_user_api_key"): exhausting one never +// affects the other, in either direction. +func TestToolRateLimiterRotationConfirmationBucketIsIndependentAndDerived(t *testing.T) { + ctx := context.Background() + redisClient := newRateLimitTestRedis(t) + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{ + ReadPerMinute: 1, SendPerMinute: 1, KeyCreatesPerHour: 1, KeyRotationsPerHour: 1, + }) + + // The confirmation-prompt bucket's budget is derived as + // KeyRotationsPerHour * 5 = 5, independent of the execution bucket's + // budget of 1. + for i := 0; i < 5; i++ { + require.NoErrorf(t, limiter.Allow(ctx, "user-a", "rotate_user_api_key:confirm"), "confirmation call %d", i+1) + } + require.ErrorIs(t, limiter.Allow(ctx, "user-a", "rotate_user_api_key:confirm"), server.ErrRateLimited) + + // Exhausting the confirmation-prompt bucket left the execution bucket + // untouched: it still has its full budget of 1. + require.NoError(t, limiter.Allow(ctx, "user-a", "rotate_user_api_key")) + require.ErrorIs(t, limiter.Allow(ctx, "user-a", "rotate_user_api_key"), server.ErrRateLimited) + + // Conversely, exhausting the execution bucket does not touch the + // confirmation-prompt bucket for a different user. + require.NoError(t, limiter.Allow(ctx, "user-b", "rotate_user_api_key")) + for i := 0; i < 5; i++ { + require.NoErrorf(t, limiter.Allow(ctx, "user-b", "rotate_user_api_key:confirm"), "confirmation call %d", i+1) + } + require.ErrorIs(t, limiter.Allow(ctx, "user-b", "rotate_user_api_key:confirm"), server.ErrRateLimited) +} + +// TestToolRateLimiterRotationConfirmationBucketFailsClosedOnRedisError +// asserts a Redis failure on the confirmation-prompt bucket is reported as +// its own error (never ErrRateLimited, and never silently "allowed"), the +// same fail-closed guarantee the execution bucket already has. +func TestToolRateLimiterRotationConfirmationBucketFailsClosedOnRedisError(t *testing.T) { + ctx := context.Background() + + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + mr.Close() // force every subsequent command to fail + + limiter := server.NewToolRateLimiter(redisClient, server.Limits{KeyRotationsPerHour: 5}) + err := limiter.Allow(ctx, "user-a", "rotate_user_api_key:confirm") + require.Error(t, err) + require.NotErrorIs(t, err, server.ErrRateLimited) +} diff --git a/mcp/internal/server/server.go b/mcp/internal/server/server.go new file mode 100644 index 00000000..04285eea --- /dev/null +++ b/mcp/internal/server/server.go @@ -0,0 +1,789 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "runtime/debug" + "strings" + "time" + + "github.com/google/uuid" + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog" + otelhttp "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/config" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" + "github.com/NdoleStudio/httpsms/mcp/internal/tools" +) + +// implementationName is this MCP server's own name, published in its MCP +// Implementation and in every discover/initialize response. +const implementationName = "httpSMS" + +// maxMCPRequestBodyBytes bounds every request body the Streamable HTTP +// handler reads, per the approved design. +const maxMCPRequestBodyBytes = 1 << 20 // 1 MiB + +// protectedResourceMetadataPath and authorizationServerMetadataPath are the +// well-known discovery routes RFC 9728 and RFC 8414 require. +const ( + protectedResourceMetadataPath = "/.well-known/oauth-protected-resource" + // protectedResourceMetadataMCPPath is the path-suffixed alias RFC 9728 + // Section 3.1 actually prescribes for a protected resource whose + // identifier carries a path component ("/mcp"): the well-known segment + // is inserted between the host and that path, giving + // "/.well-known/oauth-protected-resource/mcp". Clients differ in which + // of the two they request (some, including the MCP SDKs, probe the + // root form first), so both are mounted and serve the identical + // document. + protectedResourceMetadataMCPPath = protectedResourceMetadataPath + mcpPath + authorizationServerMetadataPath = "/.well-known/oauth-authorization-server" + jwksPath = "/.well-known/jwks.json" + mcpPath = "/mcp" + registerPath = "/oauth/register" + authorizePath = "/oauth/authorize" + firebaseCompletePath = "/oauth/firebase/complete" + tokenPath = "/oauth/token" + healthzPath = "/healthz" + healthPath = "/health" + requestIDHeaderName = "X-Request-Id" +) + +// maxRequestIDLength bounds how long an inbound X-Request-Id header may be +// before this service replaces it with one of its own. An unbounded, +// unvalidated upstream value would be echoed into every response header and +// every structured log line for the request, which is a log-injection and +// header-smuggling vector as well as an unbounded memory cost per request. +const maxRequestIDLength = 128 + +// requestIDExtraChars are the non-alphanumeric characters an inbound +// X-Request-Id may contain. The set covers the shapes real proxies emit +// (UUIDs, W3C trace IDs, base64url tokens) while excluding every control +// character, whitespace character, and non-ASCII byte. +const requestIDExtraChars = "-_.:+=/" + +// Dependencies are the already-constructed components New wires into the +// httpSMS MCP service's HTTP surface. Every dependency is built and owned +// by the caller (see cmd/server/main.go's build function); New only +// assembles routes and middleware around them and never constructs, +// configures, or closes any of them itself. +type Dependencies struct { + // Logger is used for structured request logging. It must never log a + // bearer token, request body, cookie, or other secret. + Logger zerolog.Logger + + // Keys signs and verifies every JWT this service mints, and publishes + // this service's JWKS document. It must already be configured (see + // auth.KeySet.Configure) before being passed here. + Keys *auth.KeySet + + // OAuthServer implements the interactive OAuth endpoints: GET + // /oauth/authorize, POST /oauth/firebase/complete, and POST + // /oauth/token. + OAuthServer *oauth.Server + + // OAuthServerConfig is the exact ServerConfig OAuthServer was built + // with. New re-checks OAuthServerConfig.Resource against + // config.Config.MCPAudience at assembly time (see the Task 5 ruling + // this guards against): a mismatch here means the OAuth authorization + // server would validate a "resource" value the MCP access-token + // audience does not match, which would let a client obtain a token + // this service's own bearer verifier can never accept, or worse, mint + // tokens whose audience silently drifts from what was configured. + OAuthServerConfig oauth.ServerConfig + + // OAuthStore backs Dynamic Client Registration (POST /oauth/register). + OAuthStore oauth.Store + + // APIClient is the typed httpSMS API client every MCP tool calls + // through a per-call delegation token. + APIClient httpsms.Client + + // RedisClient backs the per-user/per-tool rate limiter. It must be a + // standalone Redis client (redis.NewClient), never a cluster or ring + // client. + RedisClient redis.UniversalClient + + // APIDelegationTokenTTL bounds the lifetime of every delegation token + // minted for a downstream httpSMS API call. + APIDelegationTokenTTL time.Duration + + // ConfirmationTTL bounds the lifetime of a rotate_user_api_key + // confirmation handle. + ConfirmationTTL time.Duration + + // RateLimits configures the per-user/per-tool budgets enforced before + // every tool call executes. + RateLimits Limits + + // Version is this service's own build version, published in the MCP + // Implementation. + Version string +} + +// validate returns an error naming the first missing or invalid field in +// deps, given cfg. +func (deps Dependencies) validate(cfg config.Config) error { + switch { + case cfg.BaseURL == nil: + return errors.New("server: config.Config.BaseURL must not be nil") + case deps.Keys == nil: + return errors.New("server: Dependencies.Keys must not be nil") + case deps.OAuthServer == nil: + return errors.New("server: Dependencies.OAuthServer must not be nil") + case deps.OAuthStore == nil: + return errors.New("server: Dependencies.OAuthStore must not be nil") + case deps.APIClient == nil: + return errors.New("server: Dependencies.APIClient must not be nil") + case deps.RedisClient == nil: + return errors.New("server: Dependencies.RedisClient must not be nil") + case deps.APIDelegationTokenTTL <= 0: + return errors.New("server: Dependencies.APIDelegationTokenTTL must be positive") + case deps.ConfirmationTTL <= 0: + return errors.New("server: Dependencies.ConfirmationTTL must be positive") + case deps.Version == "": + return errors.New("server: Dependencies.Version must not be empty") + case cfg.MCPAudience != canonicalMCPAudience(cfg): + // The MCP audience is not a free-form label: it is the RFC 8707 + // resource identifier of this exact endpoint, it is what + // RFC 9728 protected-resource metadata publishes as "resource", + // and it is what every client sends as the "resource" parameter. + // If a deployment overrode MCP_AUDIENCE to anything other than + // this service's own /mcp URL, discovery would advertise one + // value while tokens were minted for another, and no client + // could ever obtain a token this service accepts. Fail at + // startup rather than serve an unusable endpoint. + return fmt.Errorf( + "server: Config.MCPAudience %q must equal the canonical MCP endpoint URL %q (BaseURL + %q)", + cfg.MCPAudience, canonicalMCPAudience(cfg), mcpPath, + ) + case deps.OAuthServerConfig.Resource != cfg.MCPAudience: + // Task 5's ruling: a wiring mismatch here must never mint + // wrong-audience tokens. Fail fast at assembly time rather than + // let it surface later as a confusing client-side "invalid_token" + // rejection. + return fmt.Errorf( + "server: OAuth ServerConfig.Resource %q does not match Config.MCPAudience %q", + deps.OAuthServerConfig.Resource, cfg.MCPAudience, + ) + default: + return deps.RateLimits.validate() + } +} + +// canonicalMCPAudience returns the only audience value this service's MCP +// endpoint may ever be configured with: its own public base URL plus the +// "/mcp" path it actually serves the endpoint on. +func canonicalMCPAudience(cfg config.Config) string { + return strings.TrimRight(cfg.BaseURL.String(), "/") + mcpPath +} + +// New assembles the httpSMS MCP service's complete HTTP surface: OAuth +// discovery/authorization/token endpoints, the stateless MCP Streamable +// HTTP handler (bearer-authenticated and rate limited), and a health +// check, wrapped in a middleware chain of request ID, panic recovery, +// secure response headers, OpenTelemetry tracing, and redacted structured +// request logging. +func New(cfg config.Config, deps Dependencies) (http.Handler, error) { + if err := deps.validate(cfg); err != nil { + return nil, err + } + + baseURL := strings.TrimRight(cfg.BaseURL.String(), "/") + + mux := http.NewServeMux() + + mux.HandleFunc("GET "+healthzPath, handleHealth) + mux.HandleFunc("GET "+healthPath, handleHealth) + + // Every public metadata route is registered without a method pattern + // so the CORS preflight (OPTIONS) actually reaches withPublicCORS: a + // "GET /path" pattern matches only GET and HEAD, so an OPTIONS + // preflight against it would be answered by the mux itself with a bare + // 405 carrying no Access-Control-* headers, which a browser-based + // client reads as "cross-origin fetch denied". withPublicCORS performs + // the method dispatch (GET/HEAD, OPTIONS, everything else) itself. + protectedResourceMetadata := withPublicCORS(oauth.NewProtectedResourceMetadataHandler(baseURL)) + mux.Handle(protectedResourceMetadataPath, protectedResourceMetadata) + mux.Handle(protectedResourceMetadataMCPPath, protectedResourceMetadata) + mux.Handle(authorizationServerMetadataPath, withPublicCORS(oauth.NewAuthorizationServerMetadataHandler(baseURL))) + mux.Handle(jwksPath, withPublicCORS(jwksHandler(deps.Keys))) + + mux.Handle("POST "+registerPath, oauth.NewRegistrationHandler(deps.OAuthStore)) + mux.HandleFunc("GET "+authorizePath, deps.OAuthServer.HandleAuthorize) + mux.HandleFunc("POST "+firebaseCompletePath, deps.OAuthServer.HandleFirebaseComplete) + mux.HandleFunc("POST "+tokenPath, deps.OAuthServer.HandleToken) + + mux.Handle(mcpPath, withNoStore(protectedMCPHandler(deps))) + + return withBaseMiddleware(deps.Logger, cfg.Environment == config.EnvironmentProduction, mux), nil +} + +// withBaseMiddleware wraps next in this service's standard middleware +// chain, outermost first: request ID, panic recovery, secure response +// headers, OpenTelemetry tracing, and redacted structured request logging. +// It is the single definition of that ordering, so a test can exercise the +// exact chain production traffic passes through. +func withBaseMiddleware(logger zerolog.Logger, production bool, next http.Handler) http.Handler { + return requestIDMiddleware( + recoveryMiddleware(logger)( + secureHeadersMiddleware(production)( + otelhttp.NewHandler( + loggingMiddleware(logger)(next), + "httpsms-mcp", + ), + ), + ), + ) +} + +// protectedMCPHandler builds the /mcp handler: the official bearer-auth +// middleware wraps the stateless MCP Streamable HTTP handler, which in +// turn enforces per-user/per-tool rate limits on every tool call through +// an MCP receiving middleware (see rateLimitMiddleware). This ordering +// (auth, then rate limit, then dispatch) means an unauthenticated caller +// never consumes rate-limit budget, and a caller's identity for rate +// limiting always comes from a token this service has already verified. +func protectedMCPHandler(deps Dependencies) http.Handler { + mcpServer := mcp.NewServer(&mcp.Implementation{Name: implementationName, Version: deps.Version}, &mcp.ServerOptions{}) + tools.Register(mcpServer, deps.Keys, deps.APIClient, deps.APIDelegationTokenTTL, deps.OAuthStore, deps.ConfirmationTTL) + + limiter := NewToolRateLimiter(deps.RedisClient, deps.RateLimits) + // AddReceivingMiddleware(m1, m2) composes as m1(m2(handler)), so panic + // recovery is the outermost receiving middleware: it covers the rate + // limiter, every SDK middleware installed beneath it (including the + // MRTR shim), and every tool handler. Without it a single panic + // anywhere below would unwind through the SDK's JSON-RPC dispatch and + // kill the whole process, dropping every other in-flight request. + mcpServer.AddReceivingMiddleware( + recoverPanicMiddleware(deps.Logger), + rateLimitMiddleware(limiter), + ) + + mcpHandler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return mcpServer }, + &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + PropagateRequestCancellation: true, + MaxRequestBodyBytes: maxMCPRequestBodyBytes, + Logger: mcpTransportLogger(), + }, + ) + + verifier := auth.NewVerifier(deps.Keys) + resourceMetadataURL := strings.TrimRight(deps.OAuthServerConfig.Issuer, "/") + protectedResourceMetadataPath + + bearer := mcpauth.RequireBearerToken(verifier.VerifyMCPToken, &mcpauth.RequireBearerTokenOptions{ + ResourceMetadataURL: resourceMetadataURL, + }) + + return bearer(mcpHandler) +} + +// mcpTransportLogger returns the *slog.Logger passed to the Streamable HTTP +// handler for its own internal transport diagnostics (connection setup +// failures, and similar). It is deliberately independent of this service's +// zerolog request logger and is bounded to level Warn, since the SDK's +// transport logger is not designed to redact request content the way this +// service's own request logging middleware is. +func mcpTransportLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) +} + +// rateLimitMiddleware returns an mcp.Middleware enforcing limiter's +// per-user/per-tool budgets before every "tools/call" request reaches its +// tool handler. Every other MCP method (tools/list, server/discover, +// initialize, ...) passes through untouched. +// +// The caller's identity comes from the MCP access token this request's +// bearer-auth middleware has already verified (auth.PrincipalFromContext), +// never from tool input, so a caller can never spend another user's +// budget or evade its own by claiming a different identity. +// +// Every call is charged against exactly one bucket. A rotate_user_api_key +// call that cannot execute a rotation (see executesRotation) is charged +// against rotateUserAPIKeyConfirmBucket -- its own, separate, +// KeyRotationsPerHour*confirmationPromptMultiplier budget -- instead of +// being charged nothing (which would let a client mint unlimited +// confirmation handles per hour) or charged the execution bucket (which +// would let a client burn the whole hourly rotation budget on prompts +// alone). A call that presents confirmation state is charged only against +// the execution bucket, never both, so a confirmed retry is never double +// charged. +func rateLimitMiddleware(limiter *ToolRateLimiter) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method != "tools/call" { + return next(ctx, method, req) + } + + callReq, ok := req.(*mcp.CallToolRequest) + if !ok || callReq.Params == nil { + return next(ctx, method, req) + } + + principal, ok := auth.PrincipalFromContext(ctx) + if !ok { + // No verified principal: the bearer-auth middleware + // already rejected this request before it could reach + // here, or this is a call made directly against the MCP + // server without going through HTTP (e.g. an in-process + // test). Either way, there is no user to rate limit. + return next(ctx, method, req) + } + + bucketName := callReq.Params.Name + if !executesRotation(callReq) { + bucketName = rotateUserAPIKeyConfirmBucket + } + + if err := limiter.Allow(ctx, principal.UserID, bucketName); err != nil { + var rateLimitErr *RateLimitError + if errors.As(err, &rateLimitErr) { + return nil, rateLimitJSONRPCError(rateLimitErr) + } + return nil, fmt.Errorf("server: cannot check rate limit: %w", err) + } + + return next(ctx, method, req) + } + } +} + +// rotateUserAPIKeyToolName is the one tool whose first call is, by design, +// never an execution: it only mints a confirmation handle and asks the +// caller to confirm. +const rotateUserAPIKeyToolName = "rotate_user_api_key" + +// executesRotation reports whether callReq may actually execute a +// rotate_user_api_key rotation, and therefore must be charged against the +// execution bucket ("rotate_user_api_key") rather than the +// confirmation-prompt bucket (rotateUserAPIKeyConfirmBucket). +// +// Every tool but rotate_user_api_key always executes on every call, so +// this always reports true for them. A rotate_user_api_key call that +// presents no confirmation at all cannot rotate anything: it can only mint +// a confirmation handle and return an MRTR elicitation. Only a call that +// presents MRTR confirmation state (RequestState or InputResponses) or a +// legacy confirmation_handle argument -- that is, a call that may actually +// execute the rotation -- reports true here. +// +// Note that a client which supports neither MRTR nor elicitation can never +// reach the execution path at all (the SDK's server-side MRTR shim needs a +// live client to elicit from), so this can never be used to rotate a key +// for free: every confirmation-only call it makes is still charged against +// rotateUserAPIKeyConfirmBucket. +func executesRotation(callReq *mcp.CallToolRequest) bool { + if callReq.Params.Name != rotateUserAPIKeyToolName { + return true + } + if callReq.Params.RequestState != "" || len(callReq.Params.InputResponses) > 0 { + return true + } + + var arguments struct { + ConfirmationHandle string `json:"confirmation_handle"` + } + if len(callReq.Params.Arguments) > 0 { + // A malformed arguments payload is charged: it is rejected below + // the middleware, and refusing to charge unparseable input would + // hand a caller a free, unmetered path into the tool dispatcher. + if err := json.Unmarshal(callReq.Params.Arguments, &arguments); err != nil { + return true + } + } + + return arguments.ConfirmationHandle != "" +} + +// codeInternalError is the JSON-RPC 2.0 reserved code for an internal +// server error, returned in place of a panic that recoverPanicMiddleware +// caught. +const codeInternalError = -32603 + +// recoverPanicMiddleware returns an mcp.Middleware that recovers a panic +// raised anywhere beneath it -- the rate limiter, the SDK's own +// middleware, or a tool handler -- converts it into a generic JSON-RPC +// internal error, and logs it. +// +// The log line carries only the JSON-RPC method, the tool name when the +// request is a tools/call (tool names are a fixed, public set, never user +// data), the panic value, and the stack. It never carries tool arguments, +// the request body, or any header, so a panic can never leak a bearer +// token, a phone number, message content, or a freshly minted API key into +// the logs. The client is told nothing beyond "internal error". +func recoverPanicMiddleware(logger zerolog.Logger) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) { + defer func() { + rec := recover() + if rec == nil { + return + } + + event := logger.Error(). + Str("mcp_method", method). + Interface("panic", rec). + Str("stack", string(debug.Stack())) + if callReq, ok := req.(*mcp.CallToolRequest); ok && callReq.Params != nil { + event = event.Str("tool", callReq.Params.Name) + } + event.Msg("recovered from panic in MCP method handler") + + result = nil + err = &jsonrpc.Error{Code: codeInternalError, Message: "internal error"} + }() + + return next(ctx, method, req) + } + } +} + +// codeRateLimited is this service's JSON-RPC error code for a rate-limit +// rejection, drawn from the "-32000 to -32099" range JSON-RPC 2.0 reserves +// for implementation-defined server errors. +const codeRateLimited = -32029 + +// rateLimitErrorData is the structured "data" payload of a rate-limit +// JSON-RPC error, carrying enough for a well-behaved client to back off +// and retry automatically. +type rateLimitErrorData struct { + Tool string `json:"tool"` + RetryAfterSeconds int `json:"retry_after_seconds"` +} + +// rateLimitJSONRPCError converts err into a structured MCP/JSON-RPC error +// carrying a retry-after duration, per the approved design. +func rateLimitJSONRPCError(err *RateLimitError) error { + retryAfterSeconds := int(err.RetryAfter.Round(time.Second) / time.Second) + if retryAfterSeconds < 1 { + retryAfterSeconds = 1 + } + + data, marshalErr := json.Marshal(rateLimitErrorData{Tool: err.Tool, RetryAfterSeconds: retryAfterSeconds}) + if marshalErr != nil { + data = nil + } + + return &jsonrpc.Error{ + Code: codeRateLimited, + Message: err.Error(), + Data: data, + } +} + +// jwksHandler returns an http.HandlerFunc serving keys' JSON Web Key Set. +func jwksHandler(keys *auth.KeySet) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(keys.JWKS()) + } +} + +// handleHealth is this service's liveness/readiness check: a stateless MCP +// service has no per-instance state to report on, so "the process is +// serving HTTP" is a sufficient readiness signal for Cloud Run. +func handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +// requestIDMiddleware assigns every request a random request ID (reusing +// one already set by an upstream proxy, if it is well-formed and bounded), +// publishes it on the response and request context, so every later +// middleware and handler can correlate its own log lines to the same +// request. +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := sanitizeRequestID(r.Header.Get(requestIDHeaderName)) + if requestID == "" { + requestID = uuid.NewString() + } + w.Header().Set(requestIDHeaderName, requestID) + ctx := context.WithValue(r.Context(), requestIDContextKey{}, requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sanitizeRequestID returns value when it is a safe, bounded request +// identifier this service is willing to echo back and log, or "" when the +// caller must be given a freshly generated one instead. +// +// An inbound X-Request-Id is entirely attacker-controlled: it is written +// verbatim into a response header and into every structured log line for +// the request. An oversized value is an unbounded per-request cost, and a +// value carrying control characters (CR/LF in particular) is a +// header-injection and log-injection vector. Rather than truncate or strip +// -- which would silently corrupt a legitimate upstream correlation ID -- +// anything outside the accepted shape is replaced wholesale. +func sanitizeRequestID(value string) string { + if value == "" || len(value) > maxRequestIDLength { + return "" + } + + for i := 0; i < len(value); i++ { + c := value[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case strings.IndexByte(requestIDExtraChars, c) >= 0: + default: + return "" + } + } + + return value +} + +// requestIDContextKey is the context key requestIDMiddleware publishes the +// per-request ID under. +type requestIDContextKey struct{} + +// requestIDFromContext returns the request ID requestIDMiddleware +// published on ctx, or "" if none. +func requestIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(requestIDContextKey{}).(string) + return id +} + +// recoveryMiddleware returns middleware that recovers a panic from any +// later handler, logs it (never including the request body or any +// header), and responds 500. Without this, a single handler panic would +// crash the whole process and drop every other in-flight request. +func recoveryMiddleware(logger zerolog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + logger.Error(). + Str("request_id", requestIDFromContext(r.Context())). + Interface("panic", rec). + Str("method", r.Method). + Str("path", r.URL.Path). + Msg("recovered from panic") + w.Header().Set("Cache-Control", "no-store") + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) + } +} + +// hstsMaxAgeSeconds is the HTTP Strict-Transport-Security max-age this +// service advertises in production: two years, the value the HSTS preload +// list requires and long enough that a returning client is never exposed +// to a downgrade window. +const hstsMaxAgeSeconds = 63072000 + +// secureHeadersMiddleware returns middleware setting a baseline of +// defensive HTTP response headers on every response, regardless of route. +// +// In production it additionally sets Strict-Transport-Security: this +// service mints and accepts bearer tokens, so a single plaintext request +// (a user typing the host without a scheme, a stale http:// link in a +// client's configuration) is enough to expose one. HSTS is deliberately +// not sent outside production, where the service is reached over plain +// http on localhost and a cached HSTS entry for "localhost" would break +// every other local service on that host. +func secureHeadersMiddleware(production bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + if production { + w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d; includeSubDomains", hstsMaxAgeSeconds)) + } + next.ServeHTTP(w, r) + }) + } +} + +// loggingMiddleware returns middleware that logs one structured line per +// request: method, path, status, duration, and request ID. It never logs +// a request/response body, query string, or any header (in particular, +// never Authorization), so it can never leak a bearer token, authorization +// code, refresh token, or PKCE verifier. +func loggingMiddleware(logger zerolog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusCapturingWriter{ResponseWriter: w, status: http.StatusOK} + + next.ServeHTTP(sw, r) + + logger.Info(). + Str("request_id", requestIDFromContext(r.Context())). + Str("method", r.Method). + Str("path", r.URL.Path). + Int("status", sw.status). + Dur("duration", time.Since(start)). + Msg("http request") + }) + } +} + +// statusCapturingWriter wraps an http.ResponseWriter to record the status +// code written, for logging. +type statusCapturingWriter struct { + http.ResponseWriter + status int +} + +func (w *statusCapturingWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +// Unwrap returns the wrapped http.ResponseWriter so +// http.NewResponseController (which the MCP SDK uses to flush every SSE +// event) can reach the real writer's optional interfaces -- +// Flush/FlushError, SetReadDeadline, SetWriteDeadline, Hijack -- through +// this wrapper instead of failing with http.ErrNotSupported. +func (w *statusCapturingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +// Flush implements http.Flusher so a streaming (SSE) response through this +// wrapper is delivered to the client as each event is written, rather than +// buffered until the whole response completes. +func (w *statusCapturingWriter) Flush() { + if w.status == 0 { + w.status = http.StatusOK + } + //nolint:errcheck // flushing is best-effort, matching the MCP SDK. + _ = http.NewResponseController(w.ResponseWriter).Flush() +} + +// withPublicCORS wraps next with a permissive but non-credentialed CORS +// policy suitable only for public discovery metadata (OAuth protected +// resource/authorization server metadata, JWKS): these documents carry no +// per-caller secret, and a client-side OAuth/MCP SDK must be able to fetch +// them cross-origin from a browser. It never sets +// Access-Control-Allow-Credentials, so this must never be applied to any +// route that reads a cookie or returns caller-specific data. +// +// It performs its own method dispatch rather than relying on the mux's +// method patterns, because a "GET /path" pattern matches only GET and HEAD: +// an OPTIONS preflight would never reach this wrapper and would be answered +// with a bare 405 carrying no Access-Control-* headers at all. +func withPublicCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writePublicCORSHeaders(w) + + switch r.Method { + case http.MethodGet, http.MethodHead: + next.ServeHTTP(w, r) + case http.MethodOptions: + w.Header().Set("Access-Control-Max-Age", publicCORSMaxAgeSeconds) + w.WriteHeader(http.StatusNoContent) + default: + w.Header().Set("Allow", publicCORSAllowedMethods) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) +} + +const ( + // publicCORSAllowedMethods are the only methods a public metadata + // document supports: it is a static, read-only JSON document. + publicCORSAllowedMethods = "GET, HEAD, OPTIONS" + + // publicCORSAllowedHeaders are the request headers a browser-based + // client may send when fetching public metadata. Content-Type and + // MCP-Protocol-Version are both non-safelisted (or, for Content-Type, + // safelisted only for a narrow set of values), so a fetch that sets + // either triggers a preflight that must see them echoed here or the + // browser blocks the request outright. Authorization is included + // because MCP clients commonly reuse one configured fetch wrapper for + // every request to the server, including discovery -- the metadata + // handlers themselves ignore it entirely. + publicCORSAllowedHeaders = "Authorization, Content-Type, MCP-Protocol-Version" + + // publicCORSMaxAgeSeconds lets a browser cache the preflight result + // for these immutable documents for 10 minutes. + publicCORSMaxAgeSeconds = "600" +) + +// writePublicCORSHeaders sets the non-credentialed CORS headers every +// public metadata response (preflight or not) carries. +func writePublicCORSHeaders(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", publicCORSAllowedMethods) + w.Header().Set("Access-Control-Allow-Headers", publicCORSAllowedHeaders) + w.Header().Set("Access-Control-Expose-Headers", "Content-Type, MCP-Protocol-Version") + w.Header().Add("Vary", "Origin") + w.Header().Add("Vary", "Access-Control-Request-Headers") +} + +// withNoStore wraps next so every response (success or error) carries +// Cache-Control: no-store, overriding any Cache-Control value next itself +// sets (the MCP Streamable HTTP handler sets its own "no-cache, +// no-transform" value, which is not strict enough for a response that may +// carry a one-time secret such as a freshly minted phone API key or +// rotated user API key). noStoreWriter enforces this by rewriting the +// header immediately before the response is actually flushed, which is the +// only point by which every handler (including one that sets +// Cache-Control late, right before writing) has had its say. +func withNoStore(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(&noStoreWriter{ResponseWriter: w}, r) + }) +} + +// noStoreWriter forces the Cache-Control/Pragma no-store headers right +// before the response's headers are actually sent, so it always wins over +// any value an inner handler set earlier. +type noStoreWriter struct { + http.ResponseWriter + wroteHeader bool +} + +func (w *noStoreWriter) setNoStore() { + if w.wroteHeader { + return + } + w.wroteHeader = true + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") +} + +func (w *noStoreWriter) WriteHeader(status int) { + w.setNoStore() + w.ResponseWriter.WriteHeader(status) +} + +func (w *noStoreWriter) Write(b []byte) (int, error) { + w.setNoStore() + return w.ResponseWriter.Write(b) +} + +// Flush implements http.Flusher so streaming (SSE) responses through the +// MCP handler keep working when wrapped by withNoStore. It resolves the +// flusher through http.NewResponseController rather than a direct +// http.Flusher type assertion, so it keeps working when the writer beneath +// it is itself a wrapper that only exposes Unwrap. +func (w *noStoreWriter) Flush() { + w.setNoStore() + //nolint:errcheck // flushing is best-effort, matching the MCP SDK. + _ = http.NewResponseController(w.ResponseWriter).Flush() +} + +// Unwrap returns the wrapped http.ResponseWriter so +// http.NewResponseController can reach the real writer's optional +// interfaces through this wrapper. Note that the MCP SDK's own SSE writes +// go through this wrapper's Flush (a *noStoreWriter is an http.Flusher, so +// the controller stops here), which is what guarantees the no-store header +// is set before the first event is flushed. +func (w *noStoreWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } diff --git a/mcp/internal/server/server_test.go b/mcp/internal/server/server_test.go new file mode 100644 index 00000000..6ef6100f --- /dev/null +++ b/mcp/internal/server/server_test.go @@ -0,0 +1,1241 @@ +package server_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/config" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" + "github.com/NdoleStudio/httpsms/mcp/internal/server" +) + +const ( + testIssuer = "https://mcp.httpsms.test" + testAPIAud = "https://api.httpsms.test" + testFirebaseUID = "firebase-uid-1" +) + +// approvingVerifier is a fixed-response auth.IdentityVerifier test double. +type approvingVerifier struct{} + +func (approvingVerifier) Verify(context.Context, string) (auth.Principal, error) { + return auth.Principal{UserID: testFirebaseUID, Email: "user@example.com"}, nil +} + +// stubAPIClient is a no-op httpsms.Client test double. Every server_test.go +// test exercises protocol-level behavior (metadata, auth, protocol +// negotiation, tools/list) that never actually invokes a tool handler, so +// every method here is unreachable in practice; they exist only to satisfy +// the httpsms.Client interface. +type stubAPIClient struct{} + +func (stubAPIClient) ListPhones(context.Context, string, httpsms.ListPhonesParams) ([]httpsms.Phone, error) { + return nil, nil +} + +func (stubAPIClient) SendSMS(context.Context, string, httpsms.SendSMSParams) (httpsms.Message, error) { + return httpsms.Message{}, nil +} + +func (stubAPIClient) ListMessageThreads(context.Context, string, httpsms.ListMessageThreadsParams) ([]httpsms.MessageThread, error) { + return nil, nil +} + +func (stubAPIClient) ListThreadMessages(context.Context, string, httpsms.ListThreadMessagesParams) ([]httpsms.Message, error) { + return nil, nil +} + +func (stubAPIClient) ListIncomingMessages(context.Context, string, httpsms.ListIncomingMessagesParams) ([]httpsms.Message, error) { + return nil, nil +} + +func (stubAPIClient) CreatePhoneAPIKey(context.Context, string, httpsms.CreatePhoneAPIKeyParams) (httpsms.PhoneAPIKey, error) { + return httpsms.PhoneAPIKey{}, nil +} + +func (stubAPIClient) RotateUserAPIKey(context.Context, string, string) (httpsms.User, error) { + return httpsms.User{}, nil +} + +var _ httpsms.Client = stubAPIClient{} + +// newTestConfig returns a valid config.Config for tests, backed by mr's +// address as its Redis URL. +func newTestConfig(t *testing.T, mr *miniredis.Miniredis) config.Config { + t.Helper() + + baseURL, err := url.Parse(testIssuer) + require.NoError(t, err) + apiURL, err := url.Parse(testAPIAud) + require.NoError(t, err) + + return config.Config{ + Environment: "test", + Port: "0", + BaseURL: baseURL, + APIURL: apiURL, + RedisURL: "redis://" + mr.Addr(), + MCPAudience: testIssuer + "/mcp", + APIAudience: testAPIAud, + AccessTokenTTL: 15 * time.Minute, + APIDelegationTokenTTL: 2 * time.Minute, + AuthorizationCodeTTL: 2 * time.Minute, + RefreshTokenTTL: 30 * 24 * time.Hour, + ConfirmationTTL: 5 * time.Minute, + ReadToolsPerMinute: 120, + SendToolsPerMinute: 30, + KeyCreatesPerHour: 10, + KeyRotationsPerHour: 3, + } +} + +// newTestKeys returns a KeySet configured against cfg's issuer and +// audiences. +func newTestKeys(t *testing.T, cfg config.Config) *auth.KeySet { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + + keys, err := auth.NewKeySet(keyPEM, "test-key-1") + require.NoError(t, err) + require.NoError(t, keys.Configure(strings.TrimRight(cfg.BaseURL.String(), "/"), cfg.MCPAudience, cfg.APIAudience)) + + return keys +} + +// testHarness bundles every dependency server.New needs plus a running +// httptest.Server exposing the assembled handler. +type testHarness struct { + httpServer *httptest.Server + keys *auth.KeySet + cfg config.Config +} + +// newTestHarness assembles server.New's dependencies against a fresh +// miniredis instance and starts an httptest.Server serving the result. +func newTestHarness(t *testing.T, mutate ...func(*config.Config)) *testHarness { + t.Helper() + + return newTestHarnessWithAPIClient(t, stubAPIClient{}, mutate...) +} + +// newTestHarnessWithAPIClient is newTestHarness with an explicit +// httpsms.Client, for tests that need the client to misbehave (see the +// panic-recovery tests). +func newTestHarnessWithAPIClient(t *testing.T, apiClient httpsms.Client, mutate ...func(*config.Config)) *testHarness { + t.Helper() + + mr := miniredis.RunT(t) + cfg := newTestConfig(t, mr) + for _, m := range mutate { + m(&cfg) + } + keys := newTestKeys(t, cfg) + + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = redisClient.Close() }) + + store := oauth.NewRedisStore(redisClient) + resolver := oauth.NewClientResolver(http.DefaultClient, store) + + issuer := strings.TrimRight(cfg.BaseURL.String(), "/") + oauthServerConfig := oauth.ServerConfig{ + Issuer: issuer, + Resource: cfg.MCPAudience, + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + + oauthServer, err := oauth.NewServer(store, resolver, keys, approvingVerifier{}, oauthServerConfig) + require.NoError(t, err) + + handler, err := server.New(cfg, server.Dependencies{ + Logger: zerolog.Nop(), + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: oauthServerConfig, + OAuthStore: store, + APIClient: apiClient, + RedisClient: redisClient, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: server.Limits{ + ReadPerMinute: cfg.ReadToolsPerMinute, + SendPerMinute: cfg.SendToolsPerMinute, + KeyCreatesPerHour: cfg.KeyCreatesPerHour, + KeyRotationsPerHour: cfg.KeyRotationsPerHour, + }, + Version: "test", + }) + require.NoError(t, err) + + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + + return &testHarness{httpServer: httpServer, keys: keys, cfg: cfg} +} + +// mintToken mints a fixed-scope MCP access token for the harness's test +// principal. +func (h *testHarness) mintToken(t *testing.T, scopes ...string) string { + t.Helper() + + token, err := h.keys.SignMCPAccessToken(auth.Principal{UserID: testFirebaseUID, Email: "user@example.com"}, "test-client", scopes, 15*time.Minute) + require.NoError(t, err) + return token +} + +var allScopes = []string{ + auth.ScopePhonesRead, + auth.ScopeMessagesRead, + auth.ScopeMessagesSend, + auth.ScopePhoneAPIKeysWrite, + auth.ScopeUserAPIKeyRotate, +} + +// --- Step 1: route and protocol tests ------------------------------------- + +func TestHealthRoutesReturn200(t *testing.T) { + h := newTestHarness(t) + + for _, path := range []string{"/healthz", "/health"} { + resp, err := http.Get(h.httpServer.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + require.Equalf(t, http.StatusOK, resp.StatusCode, "GET %s", path) + } +} + +func TestMetadataJWKSAndRegistrationRoutesAreMounted(t *testing.T) { + h := newTestHarness(t) + + resp, err := http.Get(h.httpServer.URL + "/.well-known/oauth-protected-resource") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var prm struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&prm)) + require.Equal(t, testIssuer+"/mcp", prm.Resource) + + resp2, err := http.Get(h.httpServer.URL + "/.well-known/oauth-authorization-server") + require.NoError(t, err) + defer resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) + var asm struct { + Issuer string `json:"issuer"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + } + require.NoError(t, json.NewDecoder(resp2.Body).Decode(&asm)) + require.Equal(t, testIssuer, asm.Issuer) + require.Equal(t, testIssuer+"/oauth/token", asm.TokenEndpoint) + require.Equal(t, testIssuer+"/oauth/register", asm.RegistrationEndpoint) + + resp3, err := http.Get(h.httpServer.URL + "/.well-known/jwks.json") + require.NoError(t, err) + defer resp3.Body.Close() + require.Equal(t, http.StatusOK, resp3.StatusCode) + var jwks struct { + Keys []map[string]any `json:"keys"` + } + require.NoError(t, json.NewDecoder(resp3.Body).Decode(&jwks)) + require.Len(t, jwks.Keys, 1) + + registrationBody := `{ + "client_name": "test-client", + "redirect_uris": ["https://client.example/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + }` + resp4, err := http.Post(h.httpServer.URL+"/oauth/register", "application/json", strings.NewReader(registrationBody)) + require.NoError(t, err) + defer resp4.Body.Close() + require.Equal(t, http.StatusCreated, resp4.StatusCode) +} + +func TestAuthorizeTokenAndFirebaseCompleteRoutesAreMounted(t *testing.T) { + h := newTestHarness(t) + + // A minimal (invalid) authorize request is still routed to + // oauth.Server.HandleAuthorize -- a 404 here would mean the route is + // not mounted at all, which is what this test guards against. + resp, err := http.Get(h.httpServer.URL + "/oauth/authorize") + require.NoError(t, err) + defer resp.Body.Close() + require.NotEqual(t, http.StatusNotFound, resp.StatusCode) + + resp2, err := http.PostForm(h.httpServer.URL+"/oauth/token", url.Values{}) + require.NoError(t, err) + defer resp2.Body.Close() + require.NotEqual(t, http.StatusNotFound, resp2.StatusCode) + require.Equal(t, "no-store", resp2.Header.Get("Cache-Control")) + + // POST /oauth/firebase/complete is mounted too; a 404 here would mean + // the route itself is missing (a malformed/empty form body still + // reaches oauth.Server.HandleFirebaseComplete and is rejected with a + // client error, never a 404). + resp3, err := http.PostForm(h.httpServer.URL+"/oauth/firebase/complete", url.Values{}) + require.NoError(t, err) + defer resp3.Body.Close() + require.NotEqual(t, http.StatusNotFound, resp3.StatusCode) +} + +func TestUnauthenticatedMCPReturns401AndProtectedResourceMetadata(t *testing.T) { + h := newTestHarness(t) + + req, err := http.NewRequest(http.MethodPost, h.httpServer.URL+"/mcp", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + wwwAuthenticate := resp.Header.Get("WWW-Authenticate") + require.Contains(t, wwwAuthenticate, "Bearer") + require.Contains(t, wwwAuthenticate, "resource_metadata=") + require.Contains(t, wwwAuthenticate, "/.well-known/oauth-protected-resource") + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) +} + +// postMCP issues an authenticated POST /mcp request carrying body, with the +// given extra headers set in addition to Content-Type and Accept. +func postMCP(t *testing.T, h *testHarness, token string, body string, headers map[string]string) *http.Response { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, h.httpServer.URL+"/mcp", bytes.NewReader([]byte(body))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+token) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func TestAuthenticatedServerDiscoverNegotiatesLatestProtocolVersion(t *testing.T) { + h := newTestHarness(t) + token := h.mintToken(t, allScopes...) + + body := `{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{` + + `"io.modelcontextprotocol/protocolVersion":"2026-07-28",` + + `"io.modelcontextprotocol/clientCapabilities":{}` + + `}}}` + + resp := postMCP(t, h, token, body, map[string]string{ + "Mcp-Protocol-Version": "2026-07-28", + "Mcp-Method": "server/discover", + }) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", respBody) + + var decoded struct { + Result struct { + SupportedVersions []string `json:"supportedVersions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(respBody, &decoded)) + require.NotEmpty(t, decoded.Result.SupportedVersions) + require.Equal(t, "2026-07-28", decoded.Result.SupportedVersions[0]) + require.Contains(t, decoded.Result.SupportedVersions, "2025-11-25") +} + +func TestLegacyInitializeNegotiates20251125(t *testing.T) { + h := newTestHarness(t) + token := h.mintToken(t, allScopes...) + + body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` + + `"protocolVersion":"2025-11-25",` + + `"capabilities":{},` + + `"clientInfo":{"name":"legacy-test-client","version":"1.0"}` + + `}}` + + resp := postMCP(t, h, token, body, nil) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", respBody) + + var decoded struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(respBody, &decoded)) + require.Equal(t, "2025-11-25", decoded.Result.ProtocolVersion) +} + +func TestToolsListOrderIsDeterministic(t *testing.T) { + h := newTestHarness(t) + token := h.mintToken(t, allScopes...) + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` + + resp := postMCP(t, h, token, body, nil) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", respBody) + + var decoded struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(respBody, &decoded)) + + names := make([]string, len(decoded.Result.Tools)) + for i, tool := range decoded.Result.Tools { + names[i] = tool.Name + } + + // The SDK's tool set iterates in sorted-by-name order (see + // mcp.featureSet); asserting the exact order here means a future SDK + // upgrade that changed this iteration order would be caught here + // rather than surfacing as a confusing client-side ordering bug. + require.Equal(t, []string{ + "create_phone_api_key", + "list_incoming_messages", + "list_message_threads", + "list_phones", + "list_thread_messages", + "rotate_user_api_key", + "send_sms", + }, names) +} + +func TestGetAndDeleteMCPAreRejectedInStatelessMode(t *testing.T) { + h := newTestHarness(t) + token := h.mintToken(t, allScopes...) + + for _, method := range []string{http.MethodGet, http.MethodDelete} { + req, err := http.NewRequest(method, h.httpServer.URL+"/mcp", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equalf(t, http.StatusMethodNotAllowed, resp.StatusCode, "method %s", method) + } +} + +func TestPublicMetadataRoutesSetPermissiveNonCredentialedCORS(t *testing.T) { + h := newTestHarness(t) + + for _, path := range []string{ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-authorization-server", + "/.well-known/jwks.json", + } { + resp, err := http.Get(h.httpServer.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + require.Equalf(t, "*", resp.Header.Get("Access-Control-Allow-Origin"), "path %s", path) + require.Emptyf(t, resp.Header.Get("Access-Control-Allow-Credentials"), "path %s", path) + } +} + +func TestSecretResultAndErrorResponsesOnMCPAreNeverCached(t *testing.T) { + h := newTestHarness(t) + + // Unauthenticated (error) response. + req, err := http.NewRequest(http.MethodPost, h.httpServer.URL+"/mcp", strings.NewReader(`{}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + + // Authenticated (success) response. + token := h.mintToken(t, allScopes...) + resp2 := postMCP(t, h, token, `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`, nil) + defer resp2.Body.Close() + require.Equal(t, "no-store", resp2.Header.Get("Cache-Control")) +} + +func TestToolRateLimitIsEnforcedBeforeToolExecution(t *testing.T) { + h := newTestHarness(t, func(cfg *config.Config) { + cfg.ReadToolsPerMinute = 1 + }) + token := h.mintToken(t, allScopes...) + + callListPhones := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_phones","arguments":{}}}` + + resp1 := postMCP(t, h, token, callListPhones, nil) + defer resp1.Body.Close() + body1, err := io.ReadAll(resp1.Body) + require.NoError(t, err) + require.Equalf(t, http.StatusOK, resp1.StatusCode, "body: %s", body1) + + resp2 := postMCP(t, h, token, callListPhones, nil) + defer resp2.Body.Close() + body2, err := io.ReadAll(resp2.Body) + require.NoError(t, err) + + var decoded struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body2, &decoded)) + require.NotNilf(t, decoded.Error, "expected the second call to be rate limited, body: %s", body2) + require.Contains(t, strings.ToLower(decoded.Error.Message), "rate limit") + + var data struct { + Tool string `json:"tool"` + RetryAfterSeconds int `json:"retry_after_seconds"` + } + require.NoError(t, json.Unmarshal(decoded.Error.Data, &data)) + require.Equal(t, "list_phones", data.Tool) + require.GreaterOrEqual(t, data.RetryAfterSeconds, 1) +} + +// --- Dependency validation / Task 5 audience-consistency ruling ---------- + +func TestNewRejectsMismatchedOAuthResourceAndConfigAudience(t *testing.T) { + mr := miniredis.RunT(t) + cfg := newTestConfig(t, mr) + keys := newTestKeys(t, cfg) + + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer redisClient.Close() + + store := oauth.NewRedisStore(redisClient) + resolver := oauth.NewClientResolver(http.DefaultClient, store) + + mismatchedConfig := oauth.ServerConfig{ + Issuer: testIssuer, + Resource: "https://mcp.httpsms.test/wrong-resource", + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + oauthServer, err := oauth.NewServer(store, resolver, keys, approvingVerifier{}, mismatchedConfig) + require.NoError(t, err) + + _, err = server.New(cfg, server.Dependencies{ + Logger: zerolog.Nop(), + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: mismatchedConfig, + OAuthStore: store, + APIClient: stubAPIClient{}, + RedisClient: redisClient, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: newTestLimits(cfg), + Version: "test", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "Resource") + require.Contains(t, err.Error(), "MCPAudience") +} + +// newTestLimits returns the server.Limits matching cfg's configured +// budgets. +func newTestLimits(cfg config.Config) server.Limits { + return server.Limits{ + ReadPerMinute: cfg.ReadToolsPerMinute, + SendPerMinute: cfg.SendToolsPerMinute, + KeyCreatesPerHour: cfg.KeyCreatesPerHour, + KeyRotationsPerHour: cfg.KeyRotationsPerHour, + } +} + +func TestNewRejectsIncompleteDependencies(t *testing.T) { + mr := miniredis.RunT(t) + cfg := newTestConfig(t, mr) + keys := newTestKeys(t, cfg) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer redisClient.Close() + store := oauth.NewRedisStore(redisClient) + resolver := oauth.NewClientResolver(http.DefaultClient, store) + oauthServerConfig := oauth.ServerConfig{ + Issuer: testIssuer, + Resource: cfg.MCPAudience, + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + oauthServer, err := oauth.NewServer(store, resolver, keys, approvingVerifier{}, oauthServerConfig) + require.NoError(t, err) + + complete := server.Dependencies{ + Logger: zerolog.Nop(), + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: oauthServerConfig, + OAuthStore: store, + APIClient: stubAPIClient{}, + RedisClient: redisClient, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: newTestLimits(cfg), + Version: "test", + } + + // The complete set must itself be accepted, or every case below would + // pass for the wrong reason. + _, err = server.New(cfg, complete) + require.NoError(t, err) + + tests := []struct { + name string + mutate func(*server.Dependencies) + }{ + {"nil Keys", func(d *server.Dependencies) { d.Keys = nil }}, + {"nil OAuthServer", func(d *server.Dependencies) { d.OAuthServer = nil }}, + {"nil OAuthStore", func(d *server.Dependencies) { d.OAuthStore = nil }}, + {"nil APIClient", func(d *server.Dependencies) { d.APIClient = nil }}, + {"nil RedisClient", func(d *server.Dependencies) { d.RedisClient = nil }}, + {"zero APIDelegationTokenTTL", func(d *server.Dependencies) { d.APIDelegationTokenTTL = 0 }}, + {"zero ConfirmationTTL", func(d *server.Dependencies) { d.ConfirmationTTL = 0 }}, + {"empty Version", func(d *server.Dependencies) { d.Version = "" }}, + // Every rate-limit budget must be positive: ToolRateLimiter treats + // a non-positive budget as "this tool is not rate limited at all", + // so a forgotten or mis-wired budget would silently remove the + // limit instead of failing. + {"zero ReadPerMinute", func(d *server.Dependencies) { d.RateLimits.ReadPerMinute = 0 }}, + {"negative ReadPerMinute", func(d *server.Dependencies) { d.RateLimits.ReadPerMinute = -1 }}, + {"zero SendPerMinute", func(d *server.Dependencies) { d.RateLimits.SendPerMinute = 0 }}, + {"zero KeyCreatesPerHour", func(d *server.Dependencies) { d.RateLimits.KeyCreatesPerHour = 0 }}, + {"zero KeyRotationsPerHour", func(d *server.Dependencies) { d.RateLimits.KeyRotationsPerHour = 0 }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deps := complete + test.mutate(&deps) + _, err := server.New(cfg, deps) + require.Error(t, err) + }) + } +} + +// --- Fix round 1: hardened assembly --------------------------------------- + +// TestPublicMetadataRoutesAnswerCORSPreflight asserts a browser CORS +// preflight against every public discovery document is actually answered by +// the CORS wrapper -- not by the mux with a bare 405 -- and is permissive +// enough for the request headers an MCP/OAuth client sends. +func TestPublicMetadataRoutesAnswerCORSPreflight(t *testing.T) { + h := newTestHarness(t) + + for _, path := range []string{ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-authorization-server", + "/.well-known/jwks.json", + } { + req, err := http.NewRequest(http.MethodOptions, h.httpServer.URL+path, nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://client.example") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + req.Header.Set("Access-Control-Request-Headers", "content-type, mcp-protocol-version") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equalf(t, http.StatusNoContent, resp.StatusCode, "OPTIONS %s", path) + require.Equalf(t, "*", resp.Header.Get("Access-Control-Allow-Origin"), "OPTIONS %s", path) + require.Emptyf(t, resp.Header.Get("Access-Control-Allow-Credentials"), "OPTIONS %s", path) + + allowedMethods := resp.Header.Get("Access-Control-Allow-Methods") + require.Containsf(t, allowedMethods, http.MethodGet, "OPTIONS %s", path) + require.Containsf(t, allowedMethods, http.MethodOptions, "OPTIONS %s", path) + + allowedHeaders := strings.ToLower(resp.Header.Get("Access-Control-Allow-Headers")) + require.Containsf(t, allowedHeaders, "content-type", "OPTIONS %s", path) + require.Containsf(t, allowedHeaders, "mcp-protocol-version", "OPTIONS %s", path) + } +} + +// TestPublicMetadataRoutesStillServeGETAndHEAD asserts adding the preflight +// path did not change the documents themselves: GET still serves the JSON, +// and HEAD still succeeds with the same headers and no body. +func TestPublicMetadataRoutesStillServeGETAndHEAD(t *testing.T) { + h := newTestHarness(t) + + for _, path := range []string{ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-authorization-server", + "/.well-known/jwks.json", + } { + resp, err := http.Get(h.httpServer.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + require.Equalf(t, http.StatusOK, resp.StatusCode, "GET %s", path) + require.Equalf(t, "application/json", resp.Header.Get("Content-Type"), "GET %s", path) + require.Equalf(t, "*", resp.Header.Get("Access-Control-Allow-Origin"), "GET %s", path) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NotEmptyf(t, body, "GET %s", path) + + headResp, err := http.Head(h.httpServer.URL + path) + require.NoError(t, err) + defer headResp.Body.Close() + require.Equalf(t, http.StatusOK, headResp.StatusCode, "HEAD %s", path) + require.Equalf(t, "*", headResp.Header.Get("Access-Control-Allow-Origin"), "HEAD %s", path) + } +} + +// TestPublicMetadataRoutesRejectUnsupportedMethods asserts a write method +// against a read-only document is refused with an Allow header rather than +// reaching the document handler. +func TestPublicMetadataRoutesRejectUnsupportedMethods(t *testing.T) { + h := newTestHarness(t) + + req, err := http.NewRequest(http.MethodPost, h.httpServer.URL+"/.well-known/oauth-protected-resource", strings.NewReader("{}")) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) + require.Contains(t, resp.Header.Get("Allow"), http.MethodGet) +} + +// TestProtectedResourceMetadataIsServedAtBothRFC9728Paths asserts the +// path-suffixed alias RFC 9728 prescribes for a resource with a path +// component ("/.well-known/oauth-protected-resource/mcp") serves the exact +// same document as the root well-known path. +func TestProtectedResourceMetadataIsServedAtBothRFC9728Paths(t *testing.T) { + h := newTestHarness(t) + + read := func(path string) map[string]any { + resp, err := http.Get(h.httpServer.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + require.Equalf(t, http.StatusOK, resp.StatusCode, "GET %s", path) + + var document map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&document)) + return document + } + + root := read("/.well-known/oauth-protected-resource") + suffixed := read("/.well-known/oauth-protected-resource/mcp") + + require.Equal(t, testIssuer+"/mcp", root["resource"]) + require.Equal(t, root, suffixed) +} + +// TestHSTSIsSetOnlyInProduction asserts Strict-Transport-Security is sent +// in production (where every request is HTTPS and a downgraded request +// could expose a bearer token) and never outside it (where a cached HSTS +// entry for "localhost" would break unrelated local services). +func TestHSTSIsSetOnlyInProduction(t *testing.T) { + local := newTestHarness(t) + resp, err := http.Get(local.httpServer.URL + "/healthz") + require.NoError(t, err) + defer resp.Body.Close() + require.Empty(t, resp.Header.Get("Strict-Transport-Security")) + + production := newTestHarness(t, func(cfg *config.Config) { + cfg.Environment = config.EnvironmentProduction + }) + resp2, err := http.Get(production.httpServer.URL + "/healthz") + require.NoError(t, err) + defer resp2.Body.Close() + + hsts := resp2.Header.Get("Strict-Transport-Security") + require.Contains(t, hsts, "max-age=") + require.Contains(t, hsts, "includeSubDomains") + require.NotContains(t, hsts, "max-age=0") +} + +// TestInboundRequestIDIsBoundedAndValidated asserts a well-formed upstream +// correlation ID is preserved, while an oversized or malformed one (which +// would otherwise be echoed into a response header and every log line) is +// replaced with a freshly generated ID. +func TestInboundRequestIDIsBoundedAndValidated(t *testing.T) { + h := newTestHarness(t) + + tests := []struct { + name string + requestID string + preserved bool + }{ + {"uuid is preserved", "6f1b7f3e-6d2a-4f0f-9f39-0f6c4c6f39ab", true}, + {"trace-id shape is preserved", "trace-id:abc123/def-456_78=", true}, + {"empty is generated", "", false}, + {"oversized is replaced", strings.Repeat("a", 129), false}, + {"control character is replaced", "abc\tdef", false}, + {"whitespace is replaced", "abc def", false}, + {"non-ascii is replaced", "abc\u00e9", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, h.httpServer.URL+"/healthz", nil) + require.NoError(t, err) + if test.requestID != "" { + // Set the raw value directly, bypassing the client's own + // header validation: the point of the test is what this + // service does with a hostile value, not what net/http + // refuses to send. + req.Header["X-Request-Id"] = []string{test.requestID} + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + echoed := resp.Header.Get("X-Request-Id") + require.NotEmpty(t, echoed) + + if test.preserved { + require.Equal(t, test.requestID, echoed) + return + } + + require.NotEqual(t, test.requestID, echoed) + require.LessOrEqual(t, len(echoed), 128) + }) + } +} + +// panickingAPIClient is an httpsms.Client whose every call panics, standing +// in for a latent nil-dereference or index-out-of-range bug anywhere below +// the MCP dispatcher. +type panickingAPIClient struct{ stubAPIClient } + +func (panickingAPIClient) ListPhones(context.Context, string, httpsms.ListPhonesParams) ([]httpsms.Phone, error) { + panic("boom: simulated tool handler panic") +} + +// TestToolHandlerPanicIsRecoveredAsJSONRPCInternalError asserts a panic +// inside a tool handler is converted into a JSON-RPC internal error rather +// than killing the process, and that the server keeps serving afterwards. +func TestToolHandlerPanicIsRecoveredAsJSONRPCInternalError(t *testing.T) { + h := newTestHarnessWithAPIClient(t, panickingAPIClient{}) + token := h.mintToken(t, allScopes...) + + resp := postMCP(t, h, token, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_phones","arguments":{}}}`, nil) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var decoded struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &decoded)) + require.NotNilf(t, decoded.Error, "expected a JSON-RPC error, body: %s", body) + require.Equal(t, -32603, decoded.Error.Code) + // The client is told nothing about the panic itself. + require.NotContains(t, strings.ToLower(decoded.Error.Message), "boom") + require.NotContains(t, strings.ToLower(decoded.Error.Message), "panic") + + // The process survived and the server still serves other requests. + resp2 := postMCP(t, h, token, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, nil) + defer resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) +} + +// TestRotateUserAPIKeyConfirmationPromptIsNotRateLimited asserts a +// rotate_user_api_key call that presents no confirmation -- which can only +// mint a confirmation handle, never rotate anything -- does not consume the +// hourly rotation budget, while a call presenting a confirmation handle +// (which may execute) does. +func TestRotateUserAPIKeyConfirmationPromptIsNotRateLimited(t *testing.T) { + h := newTestHarness(t, func(cfg *config.Config) { + cfg.KeyRotationsPerHour = 1 + }) + token := h.mintToken(t, allScopes...) + + callError := func(t *testing.T, body string) string { + t.Helper() + + resp := postMCP(t, h, token, body, nil) + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var decoded struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + if decoded.Error == nil { + return "" + } + return strings.ToLower(decoded.Error.Message) + } + + unconfirmed := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rotate_user_api_key","arguments":{}}}` + withHandle := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rotate_user_api_key","arguments":{"confirmation_handle":"not-a-real-handle"}}}` + + // Several confirmation-only calls in a row must never exhaust a budget + // of one rotation per hour. + for i := 0; i < 4; i++ { + require.NotContainsf(t, callError(t, unconfirmed), "rate limit", "confirmation-only call %d", i+1) + } + + // The first call that presents a confirmation handle spends the single + // available rotation... + require.NotContains(t, callError(t, withHandle), "rate limit") + // ... so the next one is rejected by the limiter. + require.Contains(t, callError(t, withHandle), "rate limit") + + // A confirmation-only call is still refused once the budget is spent + // only because it is charged -- it is not, so it still gets through. + require.NotContains(t, callError(t, unconfirmed), "rate limit") +} + +// TestRotateUserAPIKeyConfirmationPromptBudgetIsExhausted asserts the +// confirmation-prompt bucket -- distinct from the execution bucket -- has +// its own finite budget (KeyRotationsPerHour * 5) rather than being +// unlimited: a client that mints confirmation handles without ever +// confirming eventually gets rate limited too, and the structured error it +// receives names the confirmation bucket rather than the execution one. +func TestRotateUserAPIKeyConfirmationPromptBudgetIsExhausted(t *testing.T) { + h := newTestHarness(t, func(cfg *config.Config) { + cfg.KeyRotationsPerHour = 1 + }) + token := h.mintToken(t, allScopes...) + + unconfirmed := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rotate_user_api_key","arguments":{}}}` + + // The confirmation-prompt budget is KeyRotationsPerHour * 5 = 5: the + // first five confirmation-only calls must all succeed. + for i := 0; i < 5; i++ { + resp := postMCP(t, h, token, unconfirmed, nil) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + require.NotContainsf(t, strings.ToLower(string(body)), "rate limit", "confirmation-only call %d, body: %s", i+1, body) + } + + // The sixth confirmation-only call in the same hour is rejected. + resp := postMCP(t, h, token, unconfirmed, nil) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var decoded struct { + Error *struct { + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &decoded)) + require.NotNilf(t, decoded.Error, "expected the sixth confirmation-only call to be rate limited, body: %s", body) + require.Contains(t, strings.ToLower(decoded.Error.Message), "rate limit") + + var data struct { + Tool string `json:"tool"` + } + require.NoError(t, json.Unmarshal(decoded.Error.Data, &data)) + // The error names the confirmation bucket, not the execution tool + // name, so a client (and an operator reading logs) can tell the two + // budgets apart. + require.Equal(t, "rotate_user_api_key:confirm", data.Tool) +} + +// TestRotateUserAPIKeyConfirmationAndExecutionBucketsAreIndependentOverHTTP +// asserts, through the fully assembled HTTP handler, that exhausting the +// confirmation-prompt bucket never spends any of the execution bucket's +// budget, and vice versa -- the same independent-buckets guarantee +// TestToolRateLimiterRotationConfirmationBucketIsIndependentAndDerived +// asserts at the unit level, confirmed here to hold end to end. +func TestRotateUserAPIKeyConfirmationAndExecutionBucketsAreIndependentOverHTTP(t *testing.T) { + h := newTestHarness(t, func(cfg *config.Config) { + cfg.KeyRotationsPerHour = 1 + }) + token := h.mintToken(t, allScopes...) + + unconfirmed := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rotate_user_api_key","arguments":{}}}` + withHandle := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rotate_user_api_key","arguments":{"confirmation_handle":"not-a-real-handle"}}}` + + callBody := func(t *testing.T, body string) string { + t.Helper() + resp := postMCP(t, h, token, body, nil) + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return strings.ToLower(string(raw)) + } + + // Exhaust the confirmation-prompt bucket (budget 5). + for i := 0; i < 5; i++ { + require.NotContainsf(t, callBody(t, unconfirmed), "rate limit", "confirmation-only call %d", i+1) + } + require.Contains(t, callBody(t, unconfirmed), "rate limit") + + // The execution bucket (budget 1) is untouched: the single + // confirmation-handle call still succeeds. + require.NotContains(t, callBody(t, withHandle), "rate limit") +} + +// TestNewRejectsMCPAudienceThatIsNotTheCanonicalEndpointURL asserts an +// MCP_AUDIENCE override that does not equal BaseURL + "/mcp" fails startup: +// discovery metadata always publishes the canonical value, so any other +// audience yields tokens no client could ever successfully present. +func TestNewRejectsMCPAudienceThatIsNotTheCanonicalEndpointURL(t *testing.T) { + mr := miniredis.RunT(t) + cfg := newTestConfig(t, mr) + cfg.MCPAudience = testIssuer + "/not-mcp" + + keys := newTestKeys(t, cfg) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer redisClient.Close() + + store := oauth.NewRedisStore(redisClient) + resolver := oauth.NewClientResolver(http.DefaultClient, store) + + // The OAuth server agrees with the (wrong) audience, so the only thing + // that can catch this is the canonical-URL check itself. + oauthServerConfig := oauth.ServerConfig{ + Issuer: testIssuer, + Resource: cfg.MCPAudience, + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + oauthServer, err := oauth.NewServer(store, resolver, keys, approvingVerifier{}, oauthServerConfig) + require.NoError(t, err) + + _, err = server.New(cfg, server.Dependencies{ + Logger: zerolog.Nop(), + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: oauthServerConfig, + OAuthStore: store, + APIClient: stubAPIClient{}, + RedisClient: redisClient, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: newTestLimits(cfg), + Version: "test", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "MCPAudience") + require.Contains(t, err.Error(), testIssuer+"/mcp") +} + +// panickingRedisClient is a redis.UniversalClient whose embedded interface +// is nil: every command it does not override panics with a nil-pointer +// dereference. It stands in for a Redis client that panics inside the rate +// limiter, below the MCP dispatcher but above every tool handler. +type panickingRedisClient struct{ redis.UniversalClient } + +// TestRateLimiterPanicIsRecoveredAsJSONRPCInternalError asserts a panic +// raised inside the rate limiter -- before any tool handler runs -- is +// recovered by the MCP receiving middleware and returned as a JSON-RPC +// internal error, rather than unwinding through the SDK's dispatch and +// killing the process. +func TestRateLimiterPanicIsRecoveredAsJSONRPCInternalError(t *testing.T) { + mr := miniredis.RunT(t) + cfg := newTestConfig(t, mr) + keys := newTestKeys(t, cfg) + + stateRedis := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = stateRedis.Close() }) + + store := oauth.NewRedisStore(stateRedis) + resolver := oauth.NewClientResolver(http.DefaultClient, store) + + oauthServerConfig := oauth.ServerConfig{ + Issuer: testIssuer, + Resource: cfg.MCPAudience, + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + oauthServer, err := oauth.NewServer(store, resolver, keys, approvingVerifier{}, oauthServerConfig) + require.NoError(t, err) + + handler, err := server.New(cfg, server.Dependencies{ + Logger: zerolog.Nop(), + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: oauthServerConfig, + OAuthStore: store, + APIClient: stubAPIClient{}, + RedisClient: panickingRedisClient{}, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: newTestLimits(cfg), + Version: "test", + }) + require.NoError(t, err) + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + h := &testHarness{httpServer: httpServer, keys: keys, cfg: cfg} + token := h.mintToken(t, allScopes...) + + resp := postMCP(t, h, token, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_phones","arguments":{}}}`, nil) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var decoded struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &decoded)) + require.NotNilf(t, decoded.Error, "expected a JSON-RPC error, body: %s", body) + require.Equal(t, -32603, decoded.Error.Code) + + // The process survived: a method that never touches the limiter still + // works. + resp2 := postMCP(t, h, token, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, nil) + defer resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) +} + +// TestRotateUserAPIKeyConfirmationPromptFailsClosedOnRedisError asserts a +// confirmation-only rotate_user_api_key call -- which, before this fix, +// bypassed the rate limiter (and therefore Redis) entirely -- fails closed +// when the rate limiter's Redis client errors, exactly like every other +// rate-limited tool call. The call must never be let through just because +// it targets the confirmation-prompt bucket rather than the execution +// bucket. +func TestRotateUserAPIKeyConfirmationPromptFailsClosedOnRedisError(t *testing.T) { + mr := miniredis.RunT(t) + cfg := newTestConfig(t, mr) + keys := newTestKeys(t, cfg) + + stateRedis := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = stateRedis.Close() }) + + store := oauth.NewRedisStore(stateRedis) + resolver := oauth.NewClientResolver(http.DefaultClient, store) + + oauthServerConfig := oauth.ServerConfig{ + Issuer: testIssuer, + Resource: cfg.MCPAudience, + FirebaseAPIKey: "test-firebase-api-key", + FirebaseAuthDomain: "httpsms-test.firebaseapp.com", + AuthorizationCodeTTL: cfg.AuthorizationCodeTTL, + AccessTokenTTL: cfg.AccessTokenTTL, + RefreshTokenTTL: cfg.RefreshTokenTTL, + } + oauthServer, err := oauth.NewServer(store, resolver, keys, approvingVerifier{}, oauthServerConfig) + require.NoError(t, err) + + // A separate, standalone miniredis instance backs only the rate + // limiter's Redis client (Dependencies.RedisClient), so it can be shut + // down without breaking the OAuth store the bearer-auth path never + // even needs. Closing it before the request forces every rate-limit + // command -- including the confirmation-prompt bucket's -- to fail. + limiterRedisServer := miniredis.RunT(t) + limiterRedisClient := redis.NewClient(&redis.Options{Addr: limiterRedisServer.Addr()}) + t.Cleanup(func() { _ = limiterRedisClient.Close() }) + limiterRedisServer.Close() + + handler, err := server.New(cfg, server.Dependencies{ + Logger: zerolog.Nop(), + Keys: keys, + OAuthServer: oauthServer, + OAuthServerConfig: oauthServerConfig, + OAuthStore: store, + APIClient: stubAPIClient{}, + RedisClient: limiterRedisClient, + APIDelegationTokenTTL: cfg.APIDelegationTokenTTL, + ConfirmationTTL: cfg.ConfirmationTTL, + RateLimits: newTestLimits(cfg), + Version: "test", + }) + require.NoError(t, err) + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + h := &testHarness{httpServer: httpServer, keys: keys, cfg: cfg} + token := h.mintToken(t, allScopes...) + + unconfirmed := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rotate_user_api_key","arguments":{}}}` + resp := postMCP(t, h, token, unconfirmed, nil) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var decoded struct { + Result *struct { + IsError bool `json:"isError"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &decoded)) + // The call must be refused -- as a JSON-RPC error -- rather than + // silently allowed through to the tool dispatcher just because Redis + // is unreachable. + require.Nilf(t, decoded.Result, "expected the call to be refused, not dispatched, body: %s", body) + require.NotNilf(t, decoded.Error, "expected a JSON-RPC error when Redis is unreachable, body: %s", body) + require.Contains(t, strings.ToLower(decoded.Error.Message), "rate limit") +} diff --git a/mcp/internal/server/stream_internal_test.go b/mcp/internal/server/stream_internal_test.go new file mode 100644 index 00000000..40951b1b --- /dev/null +++ b/mcp/internal/server/stream_internal_test.go @@ -0,0 +1,217 @@ +package server + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// readLineWithin reads one line from reader, returning an error if nothing +// arrives within timeout. A wrapper that swallows Flush shows up here as a +// timeout: the bytes would sit in net/http's response buffer until the +// handler returned, and the handlers under test deliberately do not return +// until the test has already read. +// +// It returns the timeout as an error rather than failing the test itself, +// so the caller can always unblock the still-running handler before +// asserting (a t.Fatal here would leave the handler parked forever and +// deadlock httptest.Server.Close). +func readLineWithin(reader *bufio.Reader, timeout time.Duration) (string, error) { + type readResult struct { + line string + err error + } + lines := make(chan readResult, 1) + go func() { + line, err := reader.ReadString('\n') + lines <- readResult{line: line, err: err} + }() + + select { + case result := <-lines: + return result.line, result.err + case <-time.After(timeout): + return "", errors.New("timed out waiting for a streamed line: the response was buffered, not flushed") + } +} + +// streamingClient returns an *http.Client that bounds only the wait for +// response headers, never the body. A streaming handler that never flushes +// leaves net/http buffering headers and body together until the handler +// returns, so without this bound the request itself -- not just the body +// read -- would block until the (deliberately blocked) handler finished. +func streamingClient() *http.Client { + return &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 3 * time.Second}} +} + +// TestStreamedResponsesAreFlushedThroughTheMiddlewareChain is the +// regression test for a response writer wrapper that silently breaks +// streaming: both statusCapturingWriter (logging) and noStoreWriter +// (/mcp cache control) sit between net/http and the MCP SDK's SSE writer, +// which flushes every event through http.NewResponseController. A wrapper +// that implements neither Flush nor Unwrap makes that flush a no-op, and +// every SSE event -- progress notifications, elicitation requests, +// subscription updates -- is withheld until the whole response completes. +// +// It exercises the exact production middleware composition +// (withBaseMiddleware) around the exact /mcp cache-control wrapper +// (withNoStore). +func TestStreamedResponsesAreFlushedThroughTheMiddlewareChain(t *testing.T) { + release := make(chan struct{}) + releaseOnce := sync.OnceFunc(func() { close(release) }) + flushErrors := make(chan error, 1) + + streaming := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: first\n\n")) + + // This is what mcp/event.go does for every SSE event it writes. + flushErrors <- http.NewResponseController(w).Flush() + + <-release + _, _ = w.Write([]byte("data: second\n\n")) + _ = http.NewResponseController(w).Flush() + }) + + httpServer := httptest.NewServer(withBaseMiddleware(zerolog.Nop(), false, withNoStore(streaming))) + // The handler blocks until released, and httptest.Server.Close waits + // for in-flight requests: release first, close second. + defer httpServer.Close() + defer releaseOnce() + + resp, err := streamingClient().Get(httpServer.URL) + require.NoError(t, err, "no response headers arrived: the response was buffered, not flushed") + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + + reader := bufio.NewReader(resp.Body) + firstEvent, readErr := readLineWithin(reader, 3*time.Second) + + // Unblock the handler before asserting anything, so a failure here is + // a failed assertion rather than a deadlocked httptest.Server.Close. + releaseOnce() + + require.NoError(t, readErr) + require.Equal(t, "data: first\n", firstEvent) + + // http.ResponseController.Flush must find a real flusher through the + // wrappers, never return http.ErrNotSupported. + require.NoError(t, <-flushErrors) + + blankLine, err := readLineWithin(reader, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "\n", blankLine) + + secondEvent, err := readLineWithin(reader, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "data: second\n", secondEvent) +} + +// TestMCPSSENotificationsReachTheClientBeforeTheToolReturns is the same +// regression, driven by the real MCP SDK rather than a hand-written SSE +// handler: a tool sends a progress notification and then blocks. With +// working flush plumbing the notification reaches the client while the +// tool is still running; without it the client sees nothing until the tool +// returns. +// +// It uses the SDK's SSE response mode (JSONResponse: false) because that is +// the only mode in which the SDK streams more than one message per +// response; the assembled /mcp endpoint runs in JSON mode today, but it +// shares these very wrappers, so a wrapper regression would break the +// moment streaming is turned on. +func TestMCPSSENotificationsReachTheClientBeforeTheToolReturns(t *testing.T) { + release := make(chan struct{}) + releaseOnce := sync.OnceFunc(func() { close(release) }) + defer releaseOnce() + + mcpServer := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "test"}, &mcp.ServerOptions{}) + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: "slow_stream", + Description: "sends a progress notification, then blocks until released", + }, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if token := req.Params.GetProgressToken(); token != nil { + _ = req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: token, + Message: "working", + Progress: 1, + }) + } + <-release + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, nil, nil + }) + + mcpHandler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return mcpServer }, + &mcp.StreamableHTTPOptions{ + Stateless: true, + MaxRequestBodyBytes: maxMCPRequestBodyBytes, + }, + ) + + httpServer := httptest.NewServer(withBaseMiddleware(zerolog.Nop(), false, withNoStore(mcpHandler))) + defer httpServer.Close() + defer releaseOnce() + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{` + + `"name":"slow_stream","arguments":{},"_meta":{"progressToken":"tok-1"}}}` + + req, err := http.NewRequest(http.MethodPost, httpServer.URL, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := streamingClient().Do(req) + require.NoError(t, err, "no response headers arrived: the response was buffered, not flushed") + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Contains(t, resp.Header.Get("Content-Type"), "text/event-stream") + require.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + + reader := bufio.NewReader(resp.Body) + + var ( + payload string + readErr error + ) + for { + var line string + line, readErr = readLineWithin(reader, 5*time.Second) + if readErr != nil { + break + } + if data, found := strings.CutPrefix(strings.TrimSpace(line), "data: "); found { + payload = data + break + } + } + + // Unblock the tool before asserting, so a broken flush fails as an + // assertion instead of deadlocking httptest.Server.Close. + releaseOnce() + require.NoError(t, readErr) + + var notification struct { + Method string `json:"method"` + Params struct { + Message string `json:"message"` + } `json:"params"` + } + require.NoError(t, json.Unmarshal([]byte(payload), ¬ification)) + require.Equal(t, "notifications/progress", notification.Method) + require.Equal(t, "working", notification.Params.Message) +} diff --git a/mcp/internal/tools/api_keys.go b/mcp/internal/tools/api_keys.go new file mode 100644 index 00000000..8d5db939 --- /dev/null +++ b/mcp/internal/tools/api_keys.go @@ -0,0 +1,358 @@ +package tools + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" +) + +// Exact API route create_phone_api_key's delegation token is bound to. It is +// a wire contract with api/pkg/auth's delegated MCP route table and must not +// change independently of it. +const createPhoneAPIKeyPath = "/v1/phone-api-keys" + +// rotateConfirmationRequestID is the InputRequests map key rotate_user_api_key +// uses for its confirmation elicitation. The client (or, for older protocol +// versions, the SDK's own server-side MRTR shim) must echo this same key back +// in InputResponses. +const rotateConfirmationRequestID = "confirm_rotation" + +// rotateUserAPIKeyOperation is the Confirmation.Operation value stored for a +// rotate_user_api_key confirmation handle, binding a redeemed handle to this +// exact tool and never any other confirmable operation this service might add +// in the future. +const rotateUserAPIKeyOperation = "rotate_user_api_key" + +// CreatePhoneAPIKeyInput is the input for the create_phone_api_key tool. +type CreatePhoneAPIKeyInput struct { + // Name is a human-readable label for the new phone API key. + Name string `json:"name" jsonschema:"human-readable label for the new phone API key"` +} + +// CreatePhoneAPIKeyOutput is the output for the create_phone_api_key tool. +// APIKey is a secret, one-time display value: it is returned only in this +// structured result and is never logged, traced, or persisted by this +// service. +type CreatePhoneAPIKeyOutput struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"api_key"` + Sensitive bool `json:"sensitive"` +} + +// registerCreatePhoneAPIKey registers the create_phone_api_key tool. It +// calls POST /v1/phone-api-keys and requires the phone-api-keys:write scope. +func registerCreatePhoneAPIKey(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "create_phone_api_key", + Description: "Create a new httpSMS phone API key, used to authenticate " + + "the httpSMS Android app for a subset of the user's phones. This is " + + "a sensitive, non-idempotent operation: every call mints a brand-new " + + "secret key, which is returned exactly once and can never be " + + "retrieved again -- store it immediately.", + Annotations: createAPIKeyAnnotations(), + }, newCreatePhoneAPIKeyHandler(keys, api, apiTokenTTL)) +} + +func newCreatePhoneAPIKeyHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) mcp.ToolHandlerFor[CreatePhoneAPIKeyInput, CreatePhoneAPIKeyOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in CreatePhoneAPIKeyInput) (*mcp.CallToolResult, CreatePhoneAPIKeyOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopePhoneAPIKeysWrite) + if err != nil { + return nil, CreatePhoneAPIKeyOutput{}, err + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopePhoneAPIKeysWrite}, http.MethodPost, createPhoneAPIKeyPath, apiTokenTTL) + if err != nil { + return nil, CreatePhoneAPIKeyOutput{}, fmt.Errorf("sign API delegation token: %w", err) + } + + key, err := api.CreatePhoneAPIKey(ctx, token, httpsms.CreatePhoneAPIKeyParams{Name: in.Name}) + if err != nil { + return toolError(err), CreatePhoneAPIKeyOutput{}, nil + } + + result := &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Store this API key now: it will not be shown again. Configure " + + "it in the httpSMS Android app for the intended phone(s).", + }}, + } + return result, CreatePhoneAPIKeyOutput{ + ID: key.ID, + Name: key.Name, + APIKey: key.APIKey, + Sensitive: true, + }, nil + } +} + +// RotateUserAPIKeyInput is the input for the rotate_user_api_key tool. +type RotateUserAPIKeyInput struct { + // ConfirmationHandle is the one-time confirmation handle returned by a + // prior, unconfirmed call to this tool. Only legacy clients that cannot + // complete an MCP multi-round-trip (MRTR) elicitation need to supply + // this explicitly; MRTR-capable clients instead fulfill the tool's + // "confirm_rotation" elicitation and never need to set this field. + ConfirmationHandle string `json:"confirmation_handle,omitempty" jsonschema:"one-time confirmation handle returned by a prior unconfirmed call to this tool, for legacy clients that cannot complete an MRTR elicitation"` +} + +// RotateUserAPIKeyOutput is the output for the rotate_user_api_key tool. It +// is populated only once rotation has actually happened, after confirmation. +// User.APIKey is a secret, one-time display value: it is returned only in +// this structured result and is never logged, traced, or persisted by this +// service. +type RotateUserAPIKeyOutput struct { + // User is the authenticated user's record after rotation, carrying the + // brand-new primary API key. + User httpsms.User `json:"user"` + // Sensitive marks User.APIKey as a secret, one-time display value: it + // is shown here exactly once and can never be retrieved again, + // matching create_phone_api_key's CreatePhoneAPIKeyOutput.Sensitive. + Sensitive bool `json:"sensitive"` + // Warning restates that the previous primary API key has just stopped + // working and every device or integration using it must be updated. + Warning string `json:"warning"` +} + +// registerRotateUserAPIKey registers the rotate_user_api_key tool. It calls +// DELETE /v1/users/{userID}/api-keys (userID is always the authenticated +// principal's own Firebase UID, never tool input) and requires the +// user-api-key:rotate scope. Rotation only proceeds after the caller +// confirms it, through either an MCP multi-round-trip (MRTR) elicitation or +// (for legacy clients that cannot complete one) an explicit +// confirmation_handle from a prior call; see store and confirmationTTL. +func registerRotateUserAPIKey(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration, store oauth.Store, confirmationTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "rotate_user_api_key", + Description: "Rotate the user's primary httpSMS API key, invalidating " + + "the current one and minting a brand-new secret in its place. This " + + "is a sensitive, destructive, non-idempotent operation that requires " + + "the caller to explicitly confirm before it takes effect.", + Annotations: rotateAPIKeyAnnotations(), + }, newRotateUserAPIKeyHandler(keys, api, apiTokenTTL, store, confirmationTTL)) +} + +func newRotateUserAPIKeyHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration, store oauth.Store, confirmationTTL time.Duration) mcp.ToolHandlerFor[RotateUserAPIKeyInput, *RotateUserAPIKeyOutput] { + return func(ctx context.Context, req *mcp.CallToolRequest, in RotateUserAPIKeyInput) (*mcp.CallToolResult, *RotateUserAPIKeyOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopeUserAPIKeyRotate) + if err != nil { + return nil, nil, err + } + + // clientID is always present once RequireScope has succeeded: every + // MCP access token this service mints carries a client_id claim + // (possibly empty for a hypothetical clientless token), and + // Verifier.VerifyMCPToken always stores it. + clientID, _ := auth.ClientIDFromContext(ctx) + + granted, err := resolveRotationConfirmation(ctx, store, req, in, principal, clientID) + if err != nil { + return nil, nil, err + } + + if !granted { + result, err := beginRotationConfirmation(ctx, store, principal, clientID, confirmationTTL) + if err != nil { + return nil, nil, err + } + return result, nil, nil + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopeUserAPIKeyRotate}, http.MethodDelete, rotateUserAPIKeyPath(principal.UserID), apiTokenTTL) + if err != nil { + return nil, nil, fmt.Errorf("sign API delegation token: %w", err) + } + + user, err := api.RotateUserAPIKey(ctx, token, principal.UserID) + if err != nil { + return toolError(err), nil, nil + } + + result := &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Store this new API key now: it will not be shown again. " + + "The previous primary API key has been invalidated; update " + + "every device or integration (including the httpSMS Android " + + "app, if configured with the primary key) that used it with " + + "this new key.", + }}, + } + return result, &RotateUserAPIKeyOutput{ + User: user, + Sensitive: true, + Warning: "The previous primary API key has been invalidated. Update " + + "every device or integration (including the httpSMS Android app, " + + "if configured with the primary key) that used it with this new key.", + }, nil + } +} + +// beginRotationConfirmation generates and stores a fresh one-time +// confirmation handle bound to principal, clientID, and +// rotateUserAPIKeyOperation, then returns the CallToolResult that asks the +// caller to confirm before rotation proceeds: an MRTR elicitation carrying +// the handle as RequestState. A legacy client that cannot complete that +// elicitation can instead read RequestState directly off this same JSON +// result and echo it back as RotateUserAPIKeyInput.ConfirmationHandle on a +// brand-new call. +func beginRotationConfirmation(ctx context.Context, store oauth.Store, principal auth.Principal, clientID string, confirmationTTL time.Duration) (*mcp.CallToolResult, error) { + handle, err := oauth.NewConfirmationHandle() + if err != nil { + return nil, fmt.Errorf("generate rotation confirmation handle: %w", err) + } + + if err := store.PutConfirmation(ctx, oauth.Confirmation{ + Handle: handle, + UserID: principal.UserID, + ClientID: clientID, + Operation: rotateUserAPIKeyOperation, + CreatedAt: time.Now().UTC(), + }, confirmationTTL); err != nil { + return nil, fmt.Errorf("store rotation confirmation: %w", err) + } + + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + rotateConfirmationRequestID: rotateConfirmationElicitParams(), + }, + RequestState: handle, + }, nil +} + +// rotateConfirmationElicitParams is the MRTR elicitation rotate_user_api_key +// asks the caller to fulfill before rotation proceeds. Its Message carries +// the required warning that the current primary API key will stop working. +func rotateConfirmationElicitParams() *mcp.ElicitParams { + return &mcp.ElicitParams{ + Message: "Rotating your primary httpSMS API key immediately invalidates " + + "the current key. Every device or integration using it (including " + + "the httpSMS Android app, if configured with the primary key) will " + + "stop working until reconfigured with the new key. Confirm to proceed.", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "confirmed": { + Type: "boolean", + Description: "Set to true to confirm rotating the primary API key.", + }, + }, + Required: []string{"confirmed"}, + }, + } +} + +// resolveRotationConfirmation determines whether the caller has already +// confirmed rotation. +// +// It returns (true, nil) once a previously issued confirmation handle has +// been redeemed and validated: it existed, had not expired or already been +// redeemed, was bound to this exact principal/clientID/operation, and (for +// the MRTR path) carries an accepted "confirmed" elicitation response. +// +// It returns (false, nil) when this call made no confirmation attempt at +// all (RotateUserAPIKeyInput.ConfirmationHandle is empty and req carries no +// RequestState): the caller has not been asked yet. +// +// It returns (false, err) when a confirmation attempt was made but could +// not be validated (unknown/expired/already-redeemed handle, a handle +// bound to a different user/client/operation, a declined/malformed +// elicitation response, or -- checked first, before any handle is touched +// -- an ambiguous call that supplies both an explicit legacy +// ConfirmationHandle and MRTR confirmation state). Every handle that is +// actually looked up is consumed exactly once by ConsumeConfirmation before +// this function inspects it, so a caller can never replay it, whether or +// not the attempt is ultimately accepted. +func resolveRotationConfirmation(ctx context.Context, store oauth.Store, req *mcp.CallToolRequest, in RotateUserAPIKeyInput, principal auth.Principal, clientID string) (bool, error) { + hasExplicitHandle := in.ConfirmationHandle != "" + hasMRTRState := req.Params.RequestState != "" || len(req.Params.InputResponses) > 0 + if hasExplicitHandle && hasMRTRState { + // Ambiguous: never silently prefer one confirmation method over + // the other. Reject before consuming anything or calling the API. + return false, errors.New("rotate_user_api_key received both a confirmation_handle argument and MRTR confirmation state (RequestState/InputResponses); use exactly one confirmation method, not both") + } + + handle := in.ConfirmationHandle + viaMRTR := false + if handle == "" { + if req.Params.RequestState == "" { + // No confirmation handle at all: this is the first call. + return false, nil + } + handle = req.Params.RequestState + viaMRTR = true + } + + confirmation, err := store.ConsumeConfirmation(ctx, handle) + if err != nil { + if errors.Is(err, oauth.ErrNotFound) { + return false, errors.New("this rotation confirmation has expired, was already used, or is invalid; call rotate_user_api_key again to request a new confirmation") + } + return false, fmt.Errorf("consume rotation confirmation: %w", err) + } + + if !confirmationBindingMatches(confirmation, principal, clientID) { + return false, errors.New("this rotation confirmation is not valid for the current user, client, or operation") + } + + if viaMRTR { + if err := validateRotationElicitationResponse(req); err != nil { + return false, err + } + } + + return true, nil +} + +// confirmationBindingMatches reports whether confirmation was issued for +// exactly principal, clientID, and rotateUserAPIKeyOperation. Every +// comparison is constant-time: confirmation.UserID, ClientID, and Operation +// are all values this service itself generated and stored, but comparing +// them in variable time would still let a timing side channel distinguish a +// near-miss from a random guess. +func confirmationBindingMatches(confirmation oauth.Confirmation, principal auth.Principal, clientID string) bool { + return subtle.ConstantTimeCompare([]byte(confirmation.UserID), []byte(principal.UserID)) == 1 && + subtle.ConstantTimeCompare([]byte(confirmation.ClientID), []byte(clientID)) == 1 && + subtle.ConstantTimeCompare([]byte(confirmation.Operation), []byte(rotateUserAPIKeyOperation)) == 1 +} + +// validateRotationElicitationResponse requires req to carry an accepted +// "confirmed": true response to the confirm_rotation elicitation. Any other +// shape -- a missing response, a response of the wrong type, a declined or +// cancelled action, or an accepted response missing "confirmed": true -- is +// rejected without ever calling the httpSMS API. +func validateRotationElicitationResponse(req *mcp.CallToolRequest) error { + response, ok := req.Params.InputResponses[rotateConfirmationRequestID].(*mcp.ElicitResult) + if !ok { + return errors.New("expected a confirm_rotation elicitation response") + } + if response.Action != "accept" { + return errors.New("rotation was not confirmed") + } + confirmed, _ := response.Content["confirmed"].(bool) + if !confirmed { + return errors.New("rotation was not confirmed") + } + return nil +} + +// rotateUserAPIKeyPath returns the exact DELETE /v1/users/{userID}/api-keys +// path for userID, byte-for-byte identical to the path +// httpsms.HTTPClient.RotateUserAPIKey builds and actually requests. The API +// delegation token minted for this call must be bound to this same literal +// path (not a wildcard pattern), because api/pkg/auth's delegated MCP +// verifier requires an exact match between a token's Path claim and the +// real request path. +func rotateUserAPIKeyPath(userID string) string { + return "/v1/users/" + url.PathEscape(userID) + "/api-keys" +} diff --git a/mcp/internal/tools/api_keys_test.go b/mcp/internal/tools/api_keys_test.go new file mode 100644 index 00000000..cd618903 --- /dev/null +++ b/mcp/internal/tools/api_keys_test.go @@ -0,0 +1,744 @@ +package tools_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" + "github.com/NdoleStudio/httpsms/mcp/internal/tools" +) + +// --- create_phone_api_key --------------------------------------------------------- + +func TestCreatePhoneAPIKeyForwardsOnlyNameAndReturnsTheSecretOnce(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + createdAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + stub := &stubClient{createKeyResult: httpsms.PhoneAPIKey{ + ID: "key-1", Name: "android-phone", PhoneNumbers: []string{"+18005550199"}, APIKey: "phone-api-key-secret", CreatedAt: createdAt, UpdatedAt: createdAt, + }} + session := newSession(t, ctx, keys, stub) + + var out tools.CreatePhoneAPIKeyOutput + result := callTool(t, session, "create_phone_api_key", map[string]any{"name": "android-phone"}, &out) + + assert.Equal(t, "key-1", out.ID) + assert.Equal(t, "android-phone", out.Name) + assert.Equal(t, "phone-api-key-secret", out.APIKey) + assert.True(t, out.Sensitive) + assert.NotEmpty(t, resultText(result), "the result must instruct the user to store the key immediately") + + require.Len(t, stub.createKeyCalls, 1) + assert.Equal(t, "android-phone", stub.createKeyCalls[0].Params.Name) + assertDelegationToken(t, keys, stub.createKeyCalls[0].Token, http.MethodPost, "/v1/phone-api-keys", []string{auth.ScopePhoneAPIKeysWrite}) +} + +func TestCreatePhoneAPIKeyDeniedWithoutScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopePhonesRead}) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "create_phone_api_key", map[string]any{"name": "android-phone"}) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls(), "a scope-denied call must never reach the httpSMS API") +} + +func TestCreatePhoneAPIKeyDeniedWithoutAnyToken(t *testing.T) { + keys := newTestKeySet(t) + ctx := context.Background() + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "create_phone_api_key", map[string]any{"name": "android-phone"}) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +func TestCreatePhoneAPIKeySurfacesAPIErrorAsToolError(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{createKeyErr: &httpsms.APIError{StatusCode: http.StatusUnprocessableEntity, Message: "name is required"}} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "create_phone_api_key", map[string]any{"name": ""}) + assert.Contains(t, resultText(result), "name is required") +} + +func TestCreatePhoneAPIKeyToolIsMarkedNotIdempotentAndNotDestructive(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + tool := toolByName(t, session, "create_phone_api_key") + require.NotNil(t, tool.Annotations) + assert.False(t, tool.Annotations.ReadOnlyHint) + assert.False(t, tool.Annotations.IdempotentHint) + if tool.Annotations.DestructiveHint != nil { + assert.False(t, *tool.Annotations.DestructiveHint) + } +} + +// TestCreatePhoneAPIKeyNeverLeaksSecretOutsideStructuredResult asserts the +// minted secret appears only in the tool's structured result, never on +// stdout/stderr (the only "logs" this service can currently produce +// mid-request; see observability.New for the service-wide JSON logger). +func TestCreatePhoneAPIKeyNeverLeaksSecretOutsideStructuredResult(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + const secret = "unique-phone-api-key-secret-4b9f9e6c-do-not-log" + stub := &stubClient{createKeyResult: httpsms.PhoneAPIKey{ID: "key-1", Name: "android-phone", APIKey: secret}} + session := newSession(t, ctx, keys, stub) + + var out tools.CreatePhoneAPIKeyOutput + captured := captureStdoutStderr(t, func() { + callTool(t, session, "create_phone_api_key", map[string]any{"name": "android-phone"}, &out) + }) + + require.Equal(t, secret, out.APIKey, "the structured result is the one place the secret must appear") + assert.NotContains(t, captured, secret, "the secret must never be written to stdout or stderr") +} + +// --- rotate_user_api_key: confirmation lifecycle --------------------------------------------------------- + +// newRotateSession builds a client/server session for rotate_user_api_key +// with the client's automatic multi-round-trip (MRTR) retry middleware +// disabled (see mcp.MultiRoundTripOptions.Disabled), so CallTool returns +// the server's raw per-round-trip *mcp.CallToolResult -- InputRequests, +// RequestState, and NeedsInput() -- instead of transparently completing an +// entire confirm-then-rotate dance in a single call. This mirrors a client +// that cannot complete an MRTR elicitation at all (the "legacy" case this +// tool must also support) while giving every test full, explicit control +// over each individual round trip. +func newRotateSession(t *testing.T, ctx context.Context, keys *auth.KeySet, api httpsms.Client, store oauth.Store, confirmationTTL time.Duration) *mcp.ClientSession { + t.Helper() + + server := mcp.NewServer(&mcp.Implementation{Name: "httpsms-mcp-test", Version: "test"}, nil) + tools.Register(server, keys, api, testAPITokenTTL, store, confirmationTTL) + + t1, t2 := mcp.NewInMemoryTransports() + _, err := server.Connect(ctx, t1, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, &mcp.ClientOptions{ + MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + }) + session, err := client.Connect(context.Background(), t2, nil) + require.NoError(t, err) + + t.Cleanup(func() { _ = session.Close() }) + return session +} + +// acceptedConfirmation is the InputResponses value a client sends back to +// accept rotate_user_api_key's confirm_rotation elicitation. +func acceptedConfirmation() *mcp.ElicitResult { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirmed": true}} +} + +func TestRotateUserAPIKeyDeniedWithoutScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopePhonesRead}) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err, "tools/call must not be a protocol error") + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls(), "a scope-denied call must never reach the httpSMS API") +} + +func TestRotateUserAPIKeyDeniedWithoutAnyToken(t *testing.T) { + keys := newTestKeySet(t) + ctx := context.Background() + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// TestRotateUserAPIKeyFirstCallAsksForConfirmationAndNeverCallsTheAPI is the +// direct analogue of the task-8 brief's illustrative handler-level +// assertion, exercised end-to-end through a real tools/call round trip. +func TestRotateUserAPIKeyFirstCallAsksForConfirmationAndNeverCallsTheAPI(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err, "tools/call must not be a protocol error") + require.False(t, result.IsError) + require.True(t, result.NeedsInput(), "the first call must ask for confirmation, not rotate immediately") + require.Contains(t, result.InputRequests, "confirm_rotation") + elicit, ok := result.InputRequests["confirm_rotation"].(*mcp.ElicitParams) + require.True(t, ok) + assert.NotEmpty(t, elicit.Message) + assert.Contains(t, elicit.Message, "stop working", "the elicitation message must warn the current key will stop working") + assert.NotEmpty(t, result.RequestState) + assert.Nil(t, result.StructuredContent) + assert.Equal(t, 0, stub.totalCalls(), "the API must never be called before confirmation") +} + +// TestRotateUserAPIKeyMRTRAcceptedConfirmationRotatesExactlyOnce drives the +// full MRTR round trip manually: an initial call, then a retry echoing back +// an accepted confirm_rotation response and the RequestState handle. +func TestRotateUserAPIKeyMRTRAcceptedConfirmationRotatesExactlyOnce(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, Email: testUserEmail, APIKey: "new-primary-api-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + require.True(t, first.NeedsInput()) + handle := first.RequestState + require.NotEmpty(t, handle) + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + InputResponses: mcp.InputResponseMap{"confirm_rotation": acceptedConfirmation()}, + RequestState: handle, + }) + require.NoError(t, err) + require.False(t, second.IsError, resultText(second)) + require.False(t, second.NeedsInput()) + + var out tools.RotateUserAPIKeyOutput + require.NoError(t, decodeStructuredContent(second, &out)) + assert.Equal(t, "new-primary-api-key", out.User.APIKey) + assert.True(t, out.Sensitive, "the rotated key must be explicitly marked sensitive, like create_phone_api_key's output") + assert.NotEmpty(t, out.Warning) + + require.Len(t, stub.rotateCalls, 1) + assert.Equal(t, testUserID, stub.rotateCalls[0].Params, "rotation must always target the authenticated principal's own user ID") + assertDelegationToken(t, keys, stub.rotateCalls[0].Token, http.MethodDelete, "/v1/users/"+testUserID+"/api-keys", []string{auth.ScopeUserAPIKeyRotate}) +} + +// TestRotateUserAPIKeyLegacyConfirmationHandleResultMarksNewKeySensitive +// asserts a successful rotation's result -- both its structured output and +// its human-readable text content -- explicitly identifies the brand-new +// primary API key as a sensitive, one-time value, matching +// create_phone_api_key's CreatePhoneAPIKeyOutput.Sensitive/text pairing: +// callers must be told, in both channels, to store it now because it will +// never be shown again. +func TestRotateUserAPIKeyLegacyConfirmationHandleResultMarksNewKeySensitive(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-primary-api-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + require.NotEmpty(t, handle) + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": handle}, + }) + require.NoError(t, err) + require.False(t, second.IsError, resultText(second)) + + var out tools.RotateUserAPIKeyOutput + require.NoError(t, decodeStructuredContent(second, &out)) + assert.True(t, out.Sensitive, "structured output must mark the new key sensitive") + + text := resultText(second) + assert.Contains(t, text, "will not be shown again", "text content must warn the new key will not be shown again") + assert.Contains(t, text, "Store", "text content must instruct the caller to store the new key now") +} + +// TestRotateUserAPIKeyIgnoresAnyUserIDSuppliedAsToolInput asserts that even +// if a caller tries to smuggle a different user ID into the call +// arguments, it can never reach the handler at all: RotateUserAPIKeyInput +// has no field that could carry one, so the MCP SDK's automatic input +// schema validation rejects the extra "user_id" property before the +// handler ever runs (defense in depth on top of the handler itself always +// targeting the authenticated principal recovered from the verified MCP +// bearer token, never anything read from tool input -- see the successful +// rotation tests above, none of which ever supply a user ID as input). +func TestRotateUserAPIKeyIgnoresAnyUserIDSuppliedAsToolInput(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"user_id": "attacker-controlled-user-id"}, + }) + require.NoError(t, err, "tools/call must not be a protocol error") + require.True(t, result.IsError) + assert.Contains(t, resultText(result), "user_id") + assert.Equal(t, 0, stub.totalCalls(), "an invalid call must never reach the httpSMS API") +} + +func TestRotateUserAPIKeyMRTRDeclinedConfirmationDoesNotRotate(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + InputResponses: mcp.InputResponseMap{"confirm_rotation": &mcp.ElicitResult{Action: "decline"}}, + RequestState: handle, + }) + require.NoError(t, err) + require.True(t, second.IsError) + assert.NotEmpty(t, resultText(second)) + assert.Equal(t, 0, stub.totalCalls(), "a declined confirmation must never reach the httpSMS API") +} + +func TestRotateUserAPIKeyMRTRAcceptedButUnconfirmedContentDoesNotRotate(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + InputResponses: mcp.InputResponseMap{"confirm_rotation": &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirmed": false}}}, + RequestState: first.RequestState, + }) + require.NoError(t, err) + require.True(t, second.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// TestRotateUserAPIKeyMRTRMalformedResponseDoesNotRotate asserts a response +// of the wrong InputResponse concrete type (not *mcp.ElicitResult) under +// the confirm_rotation key is rejected instead of causing a panic or an +// accidental rotation. +func TestRotateUserAPIKeyMRTRMalformedResponseDoesNotRotate(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + InputResponses: mcp.InputResponseMap{"confirm_rotation": &mcp.ListRootsResult{}}, + RequestState: first.RequestState, + }) + require.NoError(t, err) + require.True(t, second.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// TestRotateUserAPIKeyMRTRReplayIsRejected asserts a handle that has +// already been redeemed by a completed rotation can never be redeemed +// again, even with a freshly re-accepted confirmation response. +func TestRotateUserAPIKeyMRTRReplayIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + + retryParams := &mcp.CallToolParams{ + Name: "rotate_user_api_key", + InputResponses: mcp.InputResponseMap{"confirm_rotation": acceptedConfirmation()}, + RequestState: handle, + } + + second, err := session.CallTool(context.Background(), retryParams) + require.NoError(t, err) + require.False(t, second.IsError, resultText(second)) + require.Len(t, stub.rotateCalls, 1) + + // Replaying the exact same retry (same handle, same accepted response) + // must fail: the handle was already consumed by the successful call + // above and must never authorize a second rotation. + third, err := session.CallTool(context.Background(), retryParams) + require.NoError(t, err) + require.True(t, third.IsError) + assert.Len(t, stub.rotateCalls, 1, "a replayed confirmation must never call the API a second time") +} + +// TestRotateUserAPIKeyAmbiguousConfirmationBothHandleAndMRTRStateIsRejected +// asserts that a call supplying both an explicit legacy +// confirmation_handle argument and MRTR confirmation state +// (RequestState/InputResponses) is rejected outright, rather than silently +// preferring one confirmation method over the other. Critically, the +// handle from the first call must remain unconsumed by this ambiguous +// attempt: a follow-up call that echoes it back cleanly (only as a legacy +// argument) must still succeed and rotate exactly once. +func TestRotateUserAPIKeyAmbiguousConfirmationBothHandleAndMRTRStateIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + require.NotEmpty(t, handle) + + ambiguous, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": handle}, + InputResponses: mcp.InputResponseMap{"confirm_rotation": acceptedConfirmation()}, + RequestState: handle, + }) + require.NoError(t, err, "tools/call must not be a protocol error") + require.True(t, ambiguous.IsError, "a call supplying both confirmation methods must be rejected") + assert.Equal(t, 0, stub.totalCalls(), "an ambiguous confirmation attempt must never call the API") + + // The handle must still be unconsumed: a clean legacy retry with only + // the argument set (no MRTR state) must still succeed. + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": handle}, + }) + require.NoError(t, err) + require.False(t, second.IsError, resultText(second)) + assert.Len(t, stub.rotateCalls, 1, "the unconsumed handle must still authorize exactly one rotation") +} + +// TestRotateUserAPIKeyAmbiguousConfirmationHandleWithInputResponsesOnlyIsRejected +// covers the narrower ambiguous shape where MRTR state is signalled only +// via InputResponses (no RequestState echoed back), alongside an explicit +// legacy confirmation_handle argument. +func TestRotateUserAPIKeyAmbiguousConfirmationHandleWithInputResponsesOnlyIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + require.NotEmpty(t, handle) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": handle}, + InputResponses: mcp.InputResponseMap{"confirm_rotation": acceptedConfirmation()}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// --- rotate_user_api_key: legacy explicit confirmation_handle --------------------------------------------------------- + +func TestRotateUserAPIKeyLegacyConfirmationHandleRotatesExactlyOnce(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-primary-api-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + require.NotEmpty(t, handle) + + // The legacy retry is a brand-new, ordinary tool call: no + // InputResponses, no RequestState, just the handle read off the first + // call's raw JSON result and echoed back as a plain argument. + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": handle}, + }) + require.NoError(t, err) + require.False(t, second.IsError, resultText(second)) + + var out tools.RotateUserAPIKeyOutput + require.NoError(t, decodeStructuredContent(second, &out)) + assert.Equal(t, "new-primary-api-key", out.User.APIKey) + + require.Len(t, stub.rotateCalls, 1) + assert.Equal(t, testUserID, stub.rotateCalls[0].Params) +} + +func TestRotateUserAPIKeyLegacyHandleReplayIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-key"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + + args := map[string]any{"confirmation_handle": handle} + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key", Arguments: args}) + require.NoError(t, err) + require.False(t, second.IsError, resultText(second)) + require.Len(t, stub.rotateCalls, 1) + + third, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key", Arguments: args}) + require.NoError(t, err) + require.True(t, third.IsError) + assert.Len(t, stub.rotateCalls, 1, "a replayed legacy handle must never call the API a second time") +} + +func TestRotateUserAPIKeyLegacyHandleExpiredIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, server := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, time.Minute) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + handle := first.RequestState + + server.FastForward(2 * time.Minute) + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": handle}, + }) + require.NoError(t, err) + require.True(t, second.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +func TestRotateUserAPIKeyLegacyHandleUnknownIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": "this-handle-was-never-issued"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// TestRotateUserAPIKeyLegacyHandleWrongUserIsRejected asserts a +// confirmation handle bound to a different user's Firebase UID (however it +// might have leaked or been guessed) can never authorize rotation for the +// current caller. +func TestRotateUserAPIKeyLegacyHandleWrongUserIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + require.NoError(t, store.PutConfirmation(context.Background(), oauth.Confirmation{ + Handle: "handle-for-a-different-user", + UserID: "someone-elses-firebase-uid", + ClientID: "test-client", + Operation: "rotate_user_api_key", + }, testConfirmationTTL)) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": "handle-for-a-different-user"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// TestRotateUserAPIKeyLegacyHandleWrongClientIsRejected mirrors +// TestRotateUserAPIKeyLegacyHandleWrongUserIsRejected for the OAuth client +// binding: the same user, but a handle minted for a different OAuth +// client, must not authorize this session's rotation. +func TestRotateUserAPIKeyLegacyHandleWrongClientIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) // contextWithPrincipal always binds client_id "test-client" + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + require.NoError(t, store.PutConfirmation(context.Background(), oauth.Confirmation{ + Handle: "handle-for-a-different-client", + UserID: testUserID, + ClientID: "some-other-oauth-client", + Operation: "rotate_user_api_key", + }, testConfirmationTTL)) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": "handle-for-a-different-client"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +// TestRotateUserAPIKeyLegacyHandleWrongOperationIsRejected asserts a handle +// minted for the correct user and client but a different operation (e.g. a +// future confirmable tool this service might add) cannot be replayed here. +func TestRotateUserAPIKeyLegacyHandleWrongOperationIsRejected(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + require.NoError(t, store.PutConfirmation(context.Background(), oauth.Confirmation{ + Handle: "handle-for-a-different-operation", + UserID: testUserID, + ClientID: "test-client", + Operation: "some_other_future_operation", + }, testConfirmationTTL)) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": "handle-for-a-different-operation"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 0, stub.totalCalls()) +} + +func TestRotateUserAPIKeySurfacesAPIErrorAsToolError(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateErr: &httpsms.APIError{StatusCode: http.StatusTooManyRequests, Message: "too many rotations"}} + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, stub, store, testConfirmationTTL) + + first, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "rotate_user_api_key"}) + require.NoError(t, err) + + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "rotate_user_api_key", + Arguments: map[string]any{"confirmation_handle": first.RequestState}, + }) + require.NoError(t, err) + require.True(t, second.IsError) + assert.Contains(t, resultText(second), "too many rotations") +} + +func TestRotateUserAPIKeyToolIsMarkedDestructiveAndNotIdempotent(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + store, _ := newTestConfirmationStore(t) + session := newRotateSession(t, ctx, keys, &stubClient{}, store, testConfirmationTTL) + + tool := toolByName(t, session, "rotate_user_api_key") + require.NotNil(t, tool.Annotations) + assert.False(t, tool.Annotations.ReadOnlyHint) + assert.False(t, tool.Annotations.IdempotentHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) +} + +// TestRotateUserAPIKeyMRTRFullRoundTripViaElicitationHandler proves the +// happy path works transparently, end-to-end, for a real MRTR-capable +// client: a single high-level CallTool call, with the SDK's own client-side +// middleware automatically fulfilling the confirm_rotation elicitation +// through an ElicitationHandler and retrying, exactly as documented in the +// go-sdk's own Example_mrtr. +func TestRotateUserAPIKeyMRTRFullRoundTripViaElicitationHandler(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{rotateResult: httpsms.User{ID: testUserID, APIKey: "new-primary-api-key"}} + store, _ := newTestConfirmationStore(t) + + server := mcp.NewServer(&mcp.Implementation{Name: "httpsms-mcp-test", Version: "test"}, nil) + tools.Register(server, keys, stub, testAPITokenTTL, store, testConfirmationTTL) + + t1, t2 := mcp.NewInMemoryTransports() + _, err := server.Connect(ctx, t1, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + assert.Contains(t, req.Params.Message, "stop working") + return acceptedConfirmation(), nil + }, + }) + session, err := client.Connect(context.Background(), t2, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + var out tools.RotateUserAPIKeyOutput + callTool(t, session, "rotate_user_api_key", nil, &out) + + assert.Equal(t, "new-primary-api-key", out.User.APIKey) + assert.Len(t, stub.rotateCalls, 1) +} + +// --- shared test helpers --------------------------------------------------------- + +// decodeStructuredContent decodes result's StructuredContent into out, for +// asserting on a rotate_user_api_key result's output without relying on the +// callTool helper's built-in "must not be a tool error" assertion (some +// call sites here already asserted that separately, with a more useful +// failure message via resultText). +func decodeStructuredContent(result *mcp.CallToolResult, out any) error { + raw, err := json.Marshal(result.StructuredContent) + if err != nil { + return err + } + return json.Unmarshal(raw, out) +} + +// captureStdoutStderr redirects the process's stdout and stderr to a pipe +// for the duration of fn, and returns everything written to either. +func captureStdoutStderr(t *testing.T, fn func()) string { + t.Helper() + + origStdout, origStderr := os.Stdout, os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout, os.Stderr = w, w + + captured := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + captured <- buf.String() + }() + + fn() + + require.NoError(t, w.Close()) + os.Stdout, os.Stderr = origStdout, origStderr + return <-captured +} diff --git a/mcp/internal/tools/messages.go b/mcp/internal/tools/messages.go new file mode 100644 index 00000000..bfc958e4 --- /dev/null +++ b/mcp/internal/tools/messages.go @@ -0,0 +1,324 @@ +package tools + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" +) + +// Exact API routes the tools in this file mint delegation tokens for. Each +// is a wire contract with api/pkg/auth's delegated MCP route table and +// must not change independently of it. +const ( + sendSMSPath = "/v1/messages/send" + listMessageThreadsPath = "/v1/message-threads" + listThreadMessagesPath = "/v1/messages" + listIncomingMessagesPath = "/v1/messages/incoming" +) + +// listMessageThreadsMaxLimit bounds how many message threads a single +// list_message_threads call may request. It is enforced in the tool's +// input schema, so an out-of-range request is rejected by the MCP SDK's +// automatic input validation before the handler -- and therefore before +// any downstream API call -- ever runs. +const listMessageThreadsMaxLimit = 20 + +// SendSMSInput is the input for the send_sms tool. +// +// SendSMSInput has no "sim" field: the httpSMS API selects the sending SIM +// implicitly from From (every registered phone number is already bound to +// exactly one SIM slot), so a separate SIM selector would be accepted but +// never forwarded to the API by api/pkg/requests.MessageSend -- a no-op +// field this tool deliberately does not expose. +type SendSMSInput struct { + // From is the registered httpSMS phone number to send from, in E.164 + // format. + From string `json:"from" jsonschema:"registered httpSMS phone number to send from, in E.164 format"` + // To is the destination phone number, in E.164 format. + To string `json:"to" jsonschema:"destination phone number, in E.164 format"` + // Content is the SMS content. + Content string `json:"content" jsonschema:"SMS content"` + // Attachments are optional MMS attachment URLs. + Attachments []string `json:"attachments,omitempty" jsonschema:"URLs of MMS attachments; sending any attachment sends the message as an MMS"` + // Encrypted marks Content as end-to-end encrypted by the sending + // device. + Encrypted bool `json:"encrypted,omitempty" jsonschema:"whether Content is end-to-end encrypted by the sending device"` + // RequestID is a caller-supplied idempotency key for this send. + RequestID string `json:"request_id,omitempty" jsonschema:"caller-supplied idempotency key used to track this request"` + // SendAt schedules the message instead of sending immediately. + SendAt *time.Time `json:"send_at,omitempty" jsonschema:"schedule the message to be sent at this future time instead of immediately"` +} + +// SendSMSOutput is the output for the send_sms tool. +type SendSMSOutput struct { + // Message is the message record created by the send request. + Message httpsms.Message `json:"message"` +} + +// registerSendSMS registers the send_sms tool. It calls +// POST /v1/messages/send and requires the messages:send scope. +func registerSendSMS(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "send_sms", + Description: "Send an SMS or MMS message from one of the user's " + + "registered httpSMS phones. Sending any attachment sends the " + + "message as an MMS. Provide request_id to make retries safe.", + Annotations: sendAnnotations(), + }, newSendSMSHandler(keys, api, apiTokenTTL)) +} + +func newSendSMSHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) mcp.ToolHandlerFor[SendSMSInput, SendSMSOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in SendSMSInput) (*mcp.CallToolResult, SendSMSOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopeMessagesSend) + if err != nil { + return nil, SendSMSOutput{}, err + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopeMessagesSend}, http.MethodPost, sendSMSPath, apiTokenTTL) + if err != nil { + return nil, SendSMSOutput{}, fmt.Errorf("sign API delegation token: %w", err) + } + + message, err := api.SendSMS(ctx, token, httpsms.SendSMSParams{ + From: in.From, + To: in.To, + Content: in.Content, + Attachments: in.Attachments, + Encrypted: in.Encrypted, + RequestID: in.RequestID, + SendAt: in.SendAt, + }) + if err != nil { + return toolError(err), SendSMSOutput{}, nil + } + + return nil, SendSMSOutput{Message: message}, nil + } +} + +// ListMessageThreadsInput is the input for the list_message_threads tool. +type ListMessageThreadsInput struct { + // Owner is the registered httpSMS phone number owning the threads, in + // E.164 format. + Owner string `json:"owner" jsonschema:"registered httpSMS phone number owning the threads, in E.164 format"` + // IsArchived filters to archived (true) or unarchived (false) threads + // only. Omit to get unarchived threads. + IsArchived *bool `json:"is_archived,omitempty" jsonschema:"filter to archived (true) or unarchived (false) threads only; omit for unarchived threads"` + // WithContacts includes each contact's saved name, if any. + WithContacts bool `json:"with_contacts,omitempty" jsonschema:"include each contact's saved name, if any"` + // Query filters threads by contact name or number substring. + Query string `json:"query,omitempty" jsonschema:"filter threads by contact name or phone number substring"` + // Skip is the number of matching threads to skip, for pagination. + Skip int `json:"skip,omitempty" jsonschema:"number of matching threads to skip, for pagination"` + // Limit bounds how many threads are returned, up to + // listMessageThreadsMaxLimit. + Limit int `json:"limit,omitempty" jsonschema:"maximum number of threads to return"` +} + +// ListMessageThreadsOutput is the output for the list_message_threads tool. +type ListMessageThreadsOutput struct { + // Threads are the matching conversations between Owner and its + // contacts. + Threads []httpsms.MessageThread `json:"threads"` + // Count is len(Threads). + Count int `json:"count"` +} + +// registerListMessageThreads registers the list_message_threads tool. It +// calls GET /v1/message-threads and requires the messages:read scope. +func registerListMessageThreads(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_message_threads", + Description: "List the user's message-thread conversations between " + + "a registered phone (owner) and its contacts.", + InputSchema: listMessageThreadsInputSchema(), + Annotations: readOnlyAnnotations(), + }, newListMessageThreadsHandler(keys, api, apiTokenTTL)) +} + +// listMessageThreadsInputSchema infers ListMessageThreadsInput's default +// schema and then clamps "limit" to [1, listMessageThreadsMaxLimit] and +// "skip" to a non-negative minimum, so the MCP SDK's automatic input +// validation rejects an out-of-range request before the handler runs. +func listMessageThreadsInputSchema() *jsonschema.Schema { + schema, err := jsonschema.For[ListMessageThreadsInput](nil) + if err != nil { + panic(fmt.Sprintf("tools: cannot infer list_message_threads input schema: %v", err)) + } + + schema.Properties["limit"].Minimum = jsonschema.Ptr(1.0) + schema.Properties["limit"].Maximum = jsonschema.Ptr(float64(listMessageThreadsMaxLimit)) + schema.Properties["skip"].Minimum = jsonschema.Ptr(0.0) + + return schema +} + +func newListMessageThreadsHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) mcp.ToolHandlerFor[ListMessageThreadsInput, ListMessageThreadsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in ListMessageThreadsInput) (*mcp.CallToolResult, ListMessageThreadsOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopeMessagesRead) + if err != nil { + return nil, ListMessageThreadsOutput{}, err + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopeMessagesRead}, http.MethodGet, listMessageThreadsPath, apiTokenTTL) + if err != nil { + return nil, ListMessageThreadsOutput{}, fmt.Errorf("sign API delegation token: %w", err) + } + + threads, err := api.ListMessageThreads(ctx, token, httpsms.ListMessageThreadsParams{ + Owner: in.Owner, + IsArchived: in.IsArchived, + WithContacts: in.WithContacts, + Query: in.Query, + Skip: in.Skip, + Limit: in.Limit, + }) + if err != nil { + return toolError(err), ListMessageThreadsOutput{}, nil + } + + return nil, ListMessageThreadsOutput{Threads: threads, Count: len(threads)}, nil + } +} + +// ListThreadMessagesInput is the input for the list_thread_messages tool. +// Owner and Contact are both required: together they identify the single +// thread being read. +type ListThreadMessagesInput struct { + // Owner is the registered httpSMS phone number that owns the thread, in + // E.164 format. + Owner string `json:"owner" jsonschema:"registered httpSMS phone number that owns the thread, in E.164 format"` + // Contact is the other party in the thread, in E.164 format. + Contact string `json:"contact" jsonschema:"the other party's phone number in the thread, in E.164 format"` + // Query filters messages by content substring. + Query string `json:"query,omitempty" jsonschema:"filter messages whose content contains this substring"` + // Skip is the number of matching messages to skip, for pagination. + Skip int `json:"skip,omitempty" jsonschema:"number of matching messages to skip, for pagination"` + // Limit bounds how many messages are returned. + Limit int `json:"limit,omitempty" jsonschema:"maximum number of messages to return"` +} + +// ListThreadMessagesOutput is the output for the list_thread_messages tool. +type ListThreadMessagesOutput struct { + // Messages are the matching messages exchanged between Owner and + // Contact. + Messages []httpsms.Message `json:"messages"` + // Count is len(Messages). + Count int `json:"count"` +} + +// registerListThreadMessages registers the list_thread_messages tool. It +// calls GET /v1/messages and requires the messages:read scope. +func registerListThreadMessages(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_thread_messages", + Description: "List the messages exchanged between a registered " + + "phone (owner) and a specific contact.", + Annotations: readOnlyAnnotations(), + }, newListThreadMessagesHandler(keys, api, apiTokenTTL)) +} + +func newListThreadMessagesHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) mcp.ToolHandlerFor[ListThreadMessagesInput, ListThreadMessagesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in ListThreadMessagesInput) (*mcp.CallToolResult, ListThreadMessagesOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopeMessagesRead) + if err != nil { + return nil, ListThreadMessagesOutput{}, err + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopeMessagesRead}, http.MethodGet, listThreadMessagesPath, apiTokenTTL) + if err != nil { + return nil, ListThreadMessagesOutput{}, fmt.Errorf("sign API delegation token: %w", err) + } + + messages, err := api.ListThreadMessages(ctx, token, httpsms.ListThreadMessagesParams{ + Owner: in.Owner, + Contact: in.Contact, + Query: in.Query, + Skip: in.Skip, + Limit: in.Limit, + }) + if err != nil { + return toolError(err), ListThreadMessagesOutput{}, nil + } + + return nil, ListThreadMessagesOutput{Messages: messages, Count: len(messages)}, nil + } +} + +// ListIncomingMessagesInput is the input for the list_incoming_messages +// tool. +type ListIncomingMessagesInput struct { + // Owners optionally restricts results to these registered phone + // numbers. Omit to search across every registered phone. + Owners []string `json:"owners,omitempty" jsonschema:"restrict results to these registered phone numbers; omit to search every registered phone"` + // Statuses optionally restricts results to these message statuses. + Statuses []string `json:"statuses,omitempty" jsonschema:"restrict results to these message statuses"` + // Query filters messages by content or contact substring. + Query string `json:"query,omitempty" jsonschema:"filter messages by content or contact phone number substring"` + // SortBy optionally names the field results are ordered by. + SortBy string `json:"sort_by,omitempty" jsonschema:"field to sort results by"` + // SortDescending optionally reverses the sort order. + SortDescending *bool `json:"sort_descending,omitempty" jsonschema:"sort in descending order; omit to use the API's default order"` + // Skip is the number of matching messages to skip, for pagination. + Skip int `json:"skip,omitempty" jsonschema:"number of matching messages to skip, for pagination"` + // Limit bounds how many messages are returned. + Limit int `json:"limit,omitempty" jsonschema:"maximum number of messages to return"` +} + +// ListIncomingMessagesOutput is the output for the list_incoming_messages +// tool. +type ListIncomingMessagesOutput struct { + // Messages are the matching mobile-originated (incoming) messages. + Messages []httpsms.Message `json:"messages"` + // Count is len(Messages). + Count int `json:"count"` +} + +// registerListIncomingMessages registers the list_incoming_messages tool. +// It calls GET /v1/messages/incoming (never the CAPTCHA-protected +// /v1/messages/search route) and requires the messages:read scope. +func registerListIncomingMessages(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_incoming_messages", + Description: "List the user's incoming (mobile-originated) SMS " + + "messages received on any registered phone, optionally filtered " + + "by owner, status, or content.", + Annotations: readOnlyAnnotations(), + }, newListIncomingMessagesHandler(keys, api, apiTokenTTL)) +} + +func newListIncomingMessagesHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) mcp.ToolHandlerFor[ListIncomingMessagesInput, ListIncomingMessagesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in ListIncomingMessagesInput) (*mcp.CallToolResult, ListIncomingMessagesOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopeMessagesRead) + if err != nil { + return nil, ListIncomingMessagesOutput{}, err + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopeMessagesRead}, http.MethodGet, listIncomingMessagesPath, apiTokenTTL) + if err != nil { + return nil, ListIncomingMessagesOutput{}, fmt.Errorf("sign API delegation token: %w", err) + } + + messages, err := api.ListIncomingMessages(ctx, token, httpsms.ListIncomingMessagesParams{ + Owners: in.Owners, + Statuses: in.Statuses, + Query: in.Query, + SortBy: in.SortBy, + SortDescending: in.SortDescending, + Skip: in.Skip, + Limit: in.Limit, + }) + if err != nil { + return toolError(err), ListIncomingMessagesOutput{}, nil + } + + return nil, ListIncomingMessagesOutput{Messages: messages, Count: len(messages)}, nil + } +} diff --git a/mcp/internal/tools/messages_test.go b/mcp/internal/tools/messages_test.go new file mode 100644 index 00000000..b1b5d0bc --- /dev/null +++ b/mcp/internal/tools/messages_test.go @@ -0,0 +1,787 @@ +package tools_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "sort" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/golang-jwt/jwt/v5" + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" + "github.com/NdoleStudio/httpsms/mcp/internal/tools" +) + +const ( + testMCPIssuer = "https://mcp.httpsms.com" + testMCPAudience = "https://mcp.httpsms.com/mcp" + testAPIAudience = "https://api.httpsms.com" + testSigningKeyID = "test-key-1" + testUserID = "user-id" + testUserEmail = "user@example.com" + testAPITokenTTL = 2 * time.Minute +) + +// allScopes are every scope required by any tool registered by +// tools.Register, used to build an authorized context for tests that are +// not specifically exercising scope denial. +var allScopes = []string{ + auth.ScopePhonesRead, + auth.ScopeMessagesRead, + auth.ScopeMessagesSend, + auth.ScopePhoneAPIKeysWrite, + auth.ScopeUserAPIKeyRotate, +} + +// --- test doubles ----------------------------------------------------- + +// stubClient is a httpsms.Client test double that records every call made +// to it and returns pre-configured results, so tests can assert both on +// what a tool returned and on exactly which downstream API calls (if any) +// it made. +type stubClient struct { + listPhonesCalls []stubCall[httpsms.ListPhonesParams] + listPhonesResult []httpsms.Phone + listPhonesErr error + + sendSMSCalls []stubCall[httpsms.SendSMSParams] + sendSMSResult httpsms.Message + sendSMSErr error + + listThreadsCalls []stubCall[httpsms.ListMessageThreadsParams] + listThreadsResult []httpsms.MessageThread + listThreadsErr error + + listThreadMessagesCalls []stubCall[httpsms.ListThreadMessagesParams] + listThreadMessagesResult []httpsms.Message + listThreadMessagesErr error + + listIncomingCalls []stubCall[httpsms.ListIncomingMessagesParams] + listIncomingResult []httpsms.Message + listIncomingErr error + + createKeyCalls []stubCall[httpsms.CreatePhoneAPIKeyParams] + createKeyResult httpsms.PhoneAPIKey + createKeyErr error + + rotateCalls []stubCall[string] + rotateResult httpsms.User + rotateErr error +} + +// stubCall records one call's delegated token and parameters. +type stubCall[P any] struct { + Token string + Params P +} + +var _ httpsms.Client = (*stubClient)(nil) + +func (s *stubClient) ListPhones(_ context.Context, token string, params httpsms.ListPhonesParams) ([]httpsms.Phone, error) { + s.listPhonesCalls = append(s.listPhonesCalls, stubCall[httpsms.ListPhonesParams]{Token: token, Params: params}) + return s.listPhonesResult, s.listPhonesErr +} + +func (s *stubClient) SendSMS(_ context.Context, token string, params httpsms.SendSMSParams) (httpsms.Message, error) { + s.sendSMSCalls = append(s.sendSMSCalls, stubCall[httpsms.SendSMSParams]{Token: token, Params: params}) + return s.sendSMSResult, s.sendSMSErr +} + +func (s *stubClient) ListMessageThreads(_ context.Context, token string, params httpsms.ListMessageThreadsParams) ([]httpsms.MessageThread, error) { + s.listThreadsCalls = append(s.listThreadsCalls, stubCall[httpsms.ListMessageThreadsParams]{Token: token, Params: params}) + return s.listThreadsResult, s.listThreadsErr +} + +func (s *stubClient) ListThreadMessages(_ context.Context, token string, params httpsms.ListThreadMessagesParams) ([]httpsms.Message, error) { + s.listThreadMessagesCalls = append(s.listThreadMessagesCalls, stubCall[httpsms.ListThreadMessagesParams]{Token: token, Params: params}) + return s.listThreadMessagesResult, s.listThreadMessagesErr +} + +func (s *stubClient) ListIncomingMessages(_ context.Context, token string, params httpsms.ListIncomingMessagesParams) ([]httpsms.Message, error) { + s.listIncomingCalls = append(s.listIncomingCalls, stubCall[httpsms.ListIncomingMessagesParams]{Token: token, Params: params}) + return s.listIncomingResult, s.listIncomingErr +} + +func (s *stubClient) CreatePhoneAPIKey(_ context.Context, token string, params httpsms.CreatePhoneAPIKeyParams) (httpsms.PhoneAPIKey, error) { + s.createKeyCalls = append(s.createKeyCalls, stubCall[httpsms.CreatePhoneAPIKeyParams]{Token: token, Params: params}) + return s.createKeyResult, s.createKeyErr +} + +func (s *stubClient) RotateUserAPIKey(_ context.Context, token string, userID string) (httpsms.User, error) { + s.rotateCalls = append(s.rotateCalls, stubCall[string]{Token: token, Params: userID}) + return s.rotateResult, s.rotateErr +} + +// totalCalls reports how many downstream API calls s has recorded across +// every method, so a test can assert that a denied or invalid call never +// reached the httpSMS API. +func (s *stubClient) totalCalls() int { + return len(s.listPhonesCalls) + len(s.sendSMSCalls) + len(s.listThreadsCalls) + + len(s.listThreadMessagesCalls) + len(s.listIncomingCalls) + + len(s.createKeyCalls) + len(s.rotateCalls) +} + +// --- test fixtures ------------------------------------------------------ + +// newTestKeySet builds a KeySet with test issuer/audiences already +// configured, mirroring internal/auth's own test fixture (it cannot be +// imported directly: internal/auth's fixture lives in package auth_test). +func newTestKeySet(t *testing.T) *auth.KeySet { + t.Helper() + + keys, err := auth.NewKeySet(newTestRSAPrivateKeyPEM(t), testSigningKeyID) + require.NoError(t, err) + require.NoError(t, keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) + return keys +} + +// contextWithPrincipal returns a context carrying a verified MCP bearer +// token for a principal holding scopes, exactly as a real request context +// would carry one after passing through +// mcpauth.RequireBearerToken(verifier.VerifyMCPToken, ...). It does this by +// actually running that middleware against a synthetic HTTP request and +// capturing the context it produces, rather than reaching into any +// unexported context key. +func contextWithPrincipal(t *testing.T, keys *auth.KeySet, scopes []string) context.Context { + t.Helper() + + raw, err := keys.SignMCPAccessToken(auth.Principal{UserID: testUserID, Email: testUserEmail}, "test-client", scopes, time.Minute) + require.NoError(t, err) + + return contextFromBearerToken(t, keys, raw) +} + +// contextFromBearerToken runs the real bearer-token middleware against raw +// and returns the resulting request context, whatever it turns out to +// contain (or not contain, for an invalid token). +func contextFromBearerToken(t *testing.T, keys *auth.KeySet, raw string) context.Context { + t.Helper() + + verifier := auth.NewVerifier(keys) + middleware := mcpauth.RequireBearerToken(verifier.VerifyMCPToken, nil) + + var captured context.Context + handler := middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + captured = r.Context() + })) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + if raw != "" { + req.Header.Set("Authorization", "Bearer "+raw) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if captured == nil { + // Authentication was rejected before reaching the inner handler + // (e.g. no token at all): return the plain request context, which + // carries no TokenInfo, exactly like a real unauthenticated call. + return req.Context() + } + return captured +} + +// testConfirmationTTL is the rotation-confirmation-handle TTL used by +// every test session; it must be short enough that +// TestRotateUserAPIKeyConfirmationHandleExpires can advance past it with a +// small, fast miniredis.FastForward call. +const testConfirmationTTL = 5 * time.Minute + +// newSession registers every tool against api using keys and apiTokenTTL, +// connects an in-memory client/server pair rooted at ctx (so every tool +// call in the resulting session observes whatever principal/scopes ctx +// carries), and returns the client session plus a cleanup func. It backs +// rotate_user_api_key's confirmation handles with a fresh, throwaway +// miniredis instance: tests that need to control or inspect that store +// directly (expiry, replay) should use newSessionWithStore instead. +func newSession(t *testing.T, ctx context.Context, keys *auth.KeySet, api httpsms.Client) *mcp.ClientSession { + t.Helper() + + store, _ := newTestConfirmationStore(t) + return newSessionWithStore(t, ctx, keys, api, store, testConfirmationTTL) +} + +// newTestConfirmationStore starts an in-memory miniredis server and returns +// an oauth.Store backed by it along with the miniredis handle, for tests +// that need to fast-forward time or otherwise inspect confirmation state +// directly. +func newTestConfirmationStore(t *testing.T) (oauth.Store, *miniredis.Miniredis) { + t.Helper() + + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + return oauth.NewRedisStore(client), server +} + +// newSessionWithStore is newSession with an explicit confirmation store and +// TTL, for tests that drive rotate_user_api_key's confirmation flow. +func newSessionWithStore(t *testing.T, ctx context.Context, keys *auth.KeySet, api httpsms.Client, store oauth.Store, confirmationTTL time.Duration) *mcp.ClientSession { + t.Helper() + + server := mcp.NewServer(&mcp.Implementation{Name: "httpsms-mcp-test", Version: "test"}, nil) + tools.Register(server, keys, api, testAPITokenTTL, store, confirmationTTL) + + t1, t2 := mcp.NewInMemoryTransports() + _, err := server.Connect(ctx, t1, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil) + session, err := client.Connect(context.Background(), t2, nil) + require.NoError(t, err) + + t.Cleanup(func() { _ = session.Close() }) + return session +} + +// callTool calls name with arguments on session and decodes its structured +// output into out. It fails the test if the call is a protocol error or a +// tool-level error. +func callTool(t *testing.T, session *mcp.ClientSession, name string, arguments map[string]any, out any) *mcp.CallToolResult { + t.Helper() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: arguments}) + require.NoError(t, err, "tools/call must not be a protocol error") + require.False(t, result.IsError, "expected a successful tool result") + + if out != nil { + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, out)) + } + return result +} + +// callToolExpectingError calls name with arguments and asserts the result +// is a tool-level error (not a protocol error). +func callToolExpectingError(t *testing.T, session *mcp.ClientSession, name string, arguments map[string]any) *mcp.CallToolResult { + t.Helper() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: arguments}) + require.NoError(t, err, "tools/call must not be a protocol error") + require.True(t, result.IsError, "expected a tool-level error result") + return result +} + +// resultText concatenates the text of every TextContent block in result, +// for asserting on tool error messages. +func resultText(result *mcp.CallToolResult) string { + var text string + for _, c := range result.Content { + if tc, ok := c.(*mcp.TextContent); ok { + text += tc.Text + } + } + return text +} + +// toolByName returns the *mcp.Tool named name from session's tool list. +func toolByName(t *testing.T, session *mcp.ClientSession, name string) *mcp.Tool { + t.Helper() + + for tool, err := range session.Tools(context.Background(), nil) { + require.NoError(t, err) + if tool.Name == name { + return tool + } + } + t.Fatalf("tool %q was not registered", name) + return nil +} + +// schemaProperty returns schema's "properties"."name" entry as a +// map[string]any, for asserting on inferred/customized JSON schema +// constraints from the client's point of view (a map[string]any, per +// mcp.Tool.InputSchema's documented client-side representation). +func schemaProperty(t *testing.T, schema any, name string) map[string]any { + t.Helper() + + m, ok := schema.(map[string]any) + require.True(t, ok, "schema must decode to a map[string]any") + props, ok := m["properties"].(map[string]any) + require.True(t, ok, "schema must have a properties map") + prop, ok := props[name].(map[string]any) + require.True(t, ok, "schema must have a %q property", name) + return prop +} + +func schemaRequired(t *testing.T, schema any) []string { + t.Helper() + + m, ok := schema.(map[string]any) + require.True(t, ok, "schema must decode to a map[string]any") + raw, ok := m["required"].([]any) + if !ok { + return nil + } + required := make([]string, len(raw)) + for i, v := range raw { + required[i] = v.(string) + } + return required +} + +// --- registration --------------------------------------------------------- + +func TestRegisterRegistersExactlySevenTools(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + var names []string + for tool, err := range session.Tools(context.Background(), nil) { + require.NoError(t, err) + names = append(names, tool.Name) + } + sort.Strings(names) + + assert.Equal(t, []string{ + "create_phone_api_key", + "list_incoming_messages", + "list_message_threads", + "list_phones", + "list_thread_messages", + "rotate_user_api_key", + "send_sms", + }, names) +} + +func TestListPhonesToolIsMarkedReadOnly(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + tool := toolByName(t, session, "list_phones") + require.NotNil(t, tool.Annotations) + assert.True(t, tool.Annotations.ReadOnlyHint) +} + +func TestSendSMSToolIsMarkedDestructiveAndNotIdempotent(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + tool := toolByName(t, session, "send_sms") + require.NotNil(t, tool.Annotations) + assert.False(t, tool.Annotations.ReadOnlyHint) + assert.False(t, tool.Annotations.IdempotentHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) +} + +// --- schemas --------------------------------------------------------- + +func TestListMessageThreadsSchemaEnforcesMaxLimitOfTwenty(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + tool := toolByName(t, session, "list_message_threads") + limit := schemaProperty(t, tool.InputSchema, "limit") + assert.Equal(t, float64(20), limit["maximum"]) + + assert.Contains(t, schemaRequired(t, tool.InputSchema), "owner") +} + +func TestListThreadMessagesSchemaRequiresOwnerAndContact(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + tool := toolByName(t, session, "list_thread_messages") + required := schemaRequired(t, tool.InputSchema) + assert.Contains(t, required, "owner") + assert.Contains(t, required, "contact") + assert.NotContains(t, required, "query") +} + +func TestSendSMSSchemaRequiresFromToContentOnly(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + session := newSession(t, ctx, keys, &stubClient{}) + + tool := toolByName(t, session, "send_sms") + required := schemaRequired(t, tool.InputSchema) + assert.ElementsMatch(t, []string{"from", "to", "content"}, required) + + m, ok := tool.InputSchema.(map[string]any) + require.True(t, ok) + props, ok := m["properties"].(map[string]any) + require.True(t, ok) + assert.NotContains(t, props, "sim", "send_sms must not expose an unsupported sim field") +} + +func TestListMessageThreadsRejectsLimitAboveTwenty(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_message_threads", map[string]any{ + "owner": "+18005550199", + "limit": 21, + }) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls(), "an invalid request must never reach the httpSMS API") +} + +func TestListThreadMessagesRejectsMissingOwnerAndContact(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_thread_messages", map[string]any{ + "query": "hello", + }) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +// --- list_phones --------------------------------------------------------- + +func TestListPhonesReturnsStableStructuredContent(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + + createdAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + stub := &stubClient{listPhonesResult: []httpsms.Phone{ + {ID: "phone-1", PhoneNumber: "+18005550199", SIM: "DEFAULT", MessagesPerMinute: 10, CreatedAt: createdAt, UpdatedAt: createdAt}, + }} + session := newSession(t, ctx, keys, stub) + + var out tools.ListPhonesOutput + callTool(t, session, "list_phones", map[string]any{"query": "8005550199", "limit": 5}, &out) + + require.Len(t, out.Phones, 1) + assert.Equal(t, "phone-1", out.Phones[0].ID) + assert.Equal(t, "+18005550199", out.Phones[0].PhoneNumber) + assert.Equal(t, "DEFAULT", out.Phones[0].SIM) + assert.Equal(t, 1, out.Count) + + require.Len(t, stub.listPhonesCalls, 1) + call := stub.listPhonesCalls[0] + assert.Equal(t, "8005550199", call.Params.Query) + assert.Equal(t, 5, call.Params.Limit) + assert.NotEmpty(t, call.Token) +} + +func TestListPhonesMintsAPhonesReadScopedDelegationTokenBoundToGetPhones(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + callTool(t, session, "list_phones", nil, new(tools.ListPhonesOutput)) + + require.Len(t, stub.listPhonesCalls, 1) + assertDelegationToken(t, keys, stub.listPhonesCalls[0].Token, http.MethodGet, "/v1/phones", []string{auth.ScopePhonesRead}) +} + +func TestListPhonesDeniedWithoutPhonesReadScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopeMessagesRead}) // valid token, wrong scope + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_phones", nil) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls(), "a scope-denied call must never reach the httpSMS API") +} + +func TestListPhonesDeniedWithoutAnyToken(t *testing.T) { + keys := newTestKeySet(t) + ctx := context.Background() // no verified MCP bearer token at all + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_phones", nil) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +func TestListPhonesSurfacesAPIErrorAsToolError(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{listPhonesErr: &httpsms.APIError{StatusCode: http.StatusTooManyRequests, Message: "rate limited", RequestID: "req-1"}} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_phones", nil) + assert.Contains(t, resultText(result), "rate limited") +} + +// --- send_sms --------------------------------------------------------- + +func TestSendSMSForwardsAllSupportedOptionalFields(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + + sendAt := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + stub := &stubClient{sendSMSResult: httpsms.Message{ID: "message-1", Owner: "+18005550199", Contact: "+18005550100", Content: "hello", Status: "pending"}} + session := newSession(t, ctx, keys, stub) + + var out tools.SendSMSOutput + callTool(t, session, "send_sms", map[string]any{ + "from": "+18005550199", + "to": "+18005550100", + "content": "hello", + "attachments": []any{"https://example.com/image.jpg"}, + "encrypted": true, + "request_id": "req-123", + "send_at": sendAt.Format(time.RFC3339), + }, &out) + + assert.Equal(t, "message-1", out.Message.ID) + + require.Len(t, stub.sendSMSCalls, 1) + params := stub.sendSMSCalls[0].Params + assert.Equal(t, "+18005550199", params.From) + assert.Equal(t, "+18005550100", params.To) + assert.Equal(t, "hello", params.Content) + assert.Equal(t, []string{"https://example.com/image.jpg"}, params.Attachments) + assert.True(t, params.Encrypted) + assert.Equal(t, "req-123", params.RequestID) + require.NotNil(t, params.SendAt) + assert.True(t, sendAt.Equal(*params.SendAt)) +} + +func TestSendSMSMintsAMessagesSendScopedDelegationTokenBoundToPostSend(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + callTool(t, session, "send_sms", map[string]any{"from": "+18005550199", "to": "+18005550100", "content": "hi"}, new(tools.SendSMSOutput)) + + require.Len(t, stub.sendSMSCalls, 1) + assertDelegationToken(t, keys, stub.sendSMSCalls[0].Token, http.MethodPost, "/v1/messages/send", []string{auth.ScopeMessagesSend}) +} + +func TestSendSMSDeniedWithoutMessagesSendScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopePhonesRead}) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "send_sms", map[string]any{"from": "+18005550199", "to": "+18005550100", "content": "hi"}) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +func TestSendSMSSurfacesAPIErrorAsToolError(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{sendSMSErr: &httpsms.APIError{StatusCode: http.StatusPaymentRequired, Message: "insufficient balance"}} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "send_sms", map[string]any{"from": "+18005550199", "to": "+18005550100", "content": "hi"}) + assert.Contains(t, resultText(result), "insufficient balance") +} + +// --- list_message_threads --------------------------------------------------------- + +func TestListMessageThreadsForwardsFilters(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{listThreadsResult: []httpsms.MessageThread{{ID: "thread-1", Owner: "+18005550199", Contact: "+18005550100"}}} + session := newSession(t, ctx, keys, stub) + + var out tools.ListMessageThreadsOutput + callTool(t, session, "list_message_threads", map[string]any{ + "owner": "+18005550199", + "is_archived": true, + "with_contacts": true, + "query": "friend", + "skip": 2, + "limit": 10, + }, &out) + + require.Len(t, out.Threads, 1) + assert.Equal(t, 1, out.Count) + + require.Len(t, stub.listThreadsCalls, 1) + params := stub.listThreadsCalls[0].Params + assert.Equal(t, "+18005550199", params.Owner) + require.NotNil(t, params.IsArchived) + assert.True(t, *params.IsArchived) + assert.True(t, params.WithContacts) + assert.Equal(t, "friend", params.Query) + assert.Equal(t, 2, params.Skip) + assert.Equal(t, 10, params.Limit) + + assertDelegationToken(t, keys, stub.listThreadsCalls[0].Token, http.MethodGet, "/v1/message-threads", []string{auth.ScopeMessagesRead}) +} + +func TestListMessageThreadsDeniedWithoutMessagesReadScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopePhonesRead}) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_message_threads", map[string]any{"owner": "+18005550199"}) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +// --- list_thread_messages --------------------------------------------------------- + +func TestListThreadMessagesForwardsFilters(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{listThreadMessagesResult: []httpsms.Message{{ID: "message-1", Owner: "+18005550199", Contact: "+18005550100", Content: "hi"}}} + session := newSession(t, ctx, keys, stub) + + var out tools.ListThreadMessagesOutput + callTool(t, session, "list_thread_messages", map[string]any{ + "owner": "+18005550199", + "contact": "+18005550100", + "query": "hi", + "skip": 1, + "limit": 5, + }, &out) + + require.Len(t, out.Messages, 1) + assert.Equal(t, 1, out.Count) + + require.Len(t, stub.listThreadMessagesCalls, 1) + params := stub.listThreadMessagesCalls[0].Params + assert.Equal(t, "+18005550199", params.Owner) + assert.Equal(t, "+18005550100", params.Contact) + assert.Equal(t, "hi", params.Query) + assert.Equal(t, 1, params.Skip) + assert.Equal(t, 5, params.Limit) + + assertDelegationToken(t, keys, stub.listThreadMessagesCalls[0].Token, http.MethodGet, "/v1/messages", []string{auth.ScopeMessagesRead}) +} + +func TestListThreadMessagesDeniedWithoutMessagesReadScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopePhonesRead}) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_thread_messages", map[string]any{"owner": "+18005550199", "contact": "+18005550100"}) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +// --- list_incoming_messages --------------------------------------------------------- + +func TestListIncomingMessagesCallsTheDedicatedIncomingEndpoint(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{listIncomingResult: []httpsms.Message{{ID: "message-1", Type: "mobile-originated", Content: "hi"}}} + session := newSession(t, ctx, keys, stub) + + var out tools.ListIncomingMessagesOutput + callTool(t, session, "list_incoming_messages", map[string]any{ + "owners": []any{"+18005550199"}, + "statuses": []any{"received"}, + "query": "hi", + "sort_by": "order_timestamp", + "sort_descending": true, + "skip": 0, + "limit": 25, + }, &out) + + require.Len(t, out.Messages, 1) + assert.Equal(t, 1, out.Count) + + require.Len(t, stub.listIncomingCalls, 1) + params := stub.listIncomingCalls[0].Params + assert.Equal(t, []string{"+18005550199"}, params.Owners) + assert.Equal(t, []string{"received"}, params.Statuses) + assert.Equal(t, "hi", params.Query) + assert.Equal(t, "order_timestamp", params.SortBy) + require.NotNil(t, params.SortDescending) + assert.True(t, *params.SortDescending) + assert.Equal(t, 25, params.Limit) + + assertDelegationToken(t, keys, stub.listIncomingCalls[0].Token, http.MethodGet, "/v1/messages/incoming", []string{auth.ScopeMessagesRead}) + + // This tool must never call the CAPTCHA-protected general search route: + // the stub only implements ListIncomingMessages, so any use of a + // different underlying route would have to go through it too. Assert + // exactly one call was made overall. + assert.Equal(t, 1, stub.totalCalls()) +} + +func TestListIncomingMessagesDeniedWithoutMessagesReadScope(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, []string{auth.ScopePhonesRead}) + stub := &stubClient{} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_incoming_messages", nil) + assert.NotEmpty(t, resultText(result)) + assert.Equal(t, 0, stub.totalCalls()) +} + +func TestListIncomingMessagesSurfacesAPIErrorAsToolError(t *testing.T) { + keys := newTestKeySet(t) + ctx := contextWithPrincipal(t, keys, allScopes) + stub := &stubClient{listIncomingErr: &httpsms.APIError{StatusCode: http.StatusInternalServerError, Message: "httpSMS API request failed"}} + session := newSession(t, ctx, keys, stub) + + result := callToolExpectingError(t, session, "list_incoming_messages", nil) + assert.Contains(t, resultText(result), "httpSMS API request failed") +} + +// --- helpers shared across tool tests --------------------------------------------------------- + +// assertDelegationToken verifies raw is an API delegation token minted by +// keys for exactly method/path and carrying exactly scopes -- proving each +// tool mints a fresh, narrowly-bound token per call rather than reusing or +// widening one. +func assertDelegationToken(t *testing.T, keys *auth.KeySet, raw string, method string, path string, scopes []string) { + t.Helper() + + claims := parseDelegationClaims(t, raw, keys) + assert.Equal(t, method, claims.Method) + assert.Equal(t, path, claims.Path) + assert.Equal(t, scopes, claims.Scopes) + assert.Equal(t, testUserID, claims.Subject) + require.Len(t, claims.Audience, 1) + assert.Equal(t, testAPIAudience, claims.Audience[0]) +} + +// parseDelegationClaims verifies raw against keys' own public key and +// returns its claims, failing the test if raw does not parse or verify. +func parseDelegationClaims(t *testing.T, raw string, keys *auth.KeySet) *auth.AccessClaims { + t.Helper() + + claims := new(auth.AccessClaims) + token, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) { + return keys.PublicKey(), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})) + require.NoError(t, err) + require.True(t, token.Valid) + return claims +} + +// newTestRSAPrivateKeyPEM generates a throwaway 2048-bit RSA private key +// encoded as PKCS#1 PEM, for use only in tests. +func newTestRSAPrivateKeyPEM(t *testing.T) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} diff --git a/mcp/internal/tools/phones.go b/mcp/internal/tools/phones.go new file mode 100644 index 00000000..01e6c028 --- /dev/null +++ b/mcp/internal/tools/phones.go @@ -0,0 +1,75 @@ +package tools + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" +) + +// listPhonesPath is the exact API route the list_phones tool's delegation +// token is bound to. It is a wire contract with api/pkg/auth's delegated +// MCP route table and must not change independently of it. +const listPhonesPath = "/v1/phones" + +// ListPhonesInput is the input for the list_phones tool. +type ListPhonesInput struct { + // Query filters phones whose phone number contains this substring. + Query string `json:"query,omitempty" jsonschema:"filter phones whose phone number contains this substring"` + // Skip is the number of matching phones to skip, for pagination. + Skip int `json:"skip,omitempty" jsonschema:"number of matching phones to skip, for pagination"` + // Limit bounds how many phones are returned. + Limit int `json:"limit,omitempty" jsonschema:"maximum number of phones to return"` +} + +// ListPhonesOutput is the output for the list_phones tool. +type ListPhonesOutput struct { + // Phones are the user's registered httpSMS sending phones matching the + // request. + Phones []httpsms.Phone `json:"phones"` + // Count is len(Phones). + Count int `json:"count"` +} + +// registerListPhones registers the list_phones tool. It calls +// GET /v1/phones and requires the phones:read scope. +func registerListPhones(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_phones", + Description: "List the user's registered httpSMS sending phones, " + + "including each phone's number, SIM slot, and per-minute sending " + + "rate. Use this to find a valid \"from\" number before sending an " + + "SMS or listing message threads.", + Annotations: readOnlyAnnotations(), + }, newListPhonesHandler(keys, api, apiTokenTTL)) +} + +func newListPhonesHandler(keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) mcp.ToolHandlerFor[ListPhonesInput, ListPhonesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in ListPhonesInput) (*mcp.CallToolResult, ListPhonesOutput, error) { + principal, err := auth.RequireScope(ctx, auth.ScopePhonesRead) + if err != nil { + return nil, ListPhonesOutput{}, err + } + + token, err := keys.SignAPIDelegationToken(principal, []string{auth.ScopePhonesRead}, http.MethodGet, listPhonesPath, apiTokenTTL) + if err != nil { + return nil, ListPhonesOutput{}, fmt.Errorf("sign API delegation token: %w", err) + } + + phones, err := api.ListPhones(ctx, token, httpsms.ListPhonesParams{ + Query: in.Query, + Skip: in.Skip, + Limit: in.Limit, + }) + if err != nil { + return toolError(err), ListPhonesOutput{}, nil + } + + return nil, ListPhonesOutput{Phones: phones, Count: len(phones)}, nil + } +} diff --git a/mcp/internal/tools/register.go b/mcp/internal/tools/register.go new file mode 100644 index 00000000..a3ed4da2 --- /dev/null +++ b/mcp/internal/tools/register.go @@ -0,0 +1,115 @@ +// Package tools registers and implements the httpSMS MCP tool catalog: +// list_phones, send_sms, list_message_threads, list_thread_messages, +// list_incoming_messages, create_phone_api_key, and rotate_user_api_key. +// +// Every tool follows the same shape: +// +// 1. require the MCP access token's scope for this tool and recover the +// calling Principal (auth.RequireScope); +// 2. mint a new short-lived API delegation token scoped to exactly the +// one downstream httpSMS API operation this call is about to make +// (auth.KeySet.SignAPIDelegationToken) -- the user ID bound into that +// token is always the authenticated Principal's own Firebase UID, never +// a value read from tool input; +// 3. call the typed httpsms.Client with that token; +// 4. return a stable, structured result. +// +// A tool never converts an upstream failure into a success-shaped empty +// result: httpsms.Client errors are already safe to expose to an MCP client +// (see the httpsms package's documented error-safety guarantees) and are +// returned as a tool-level error via toolError, never a protocol error. +// +// rotate_user_api_key additionally requires the caller to explicitly +// confirm before its one destructive side effect (invalidating the user's +// current primary API key) takes effect; see resolveRotationConfirmation. +package tools + +import ( + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" +) + +// Register adds every tool to server, in the order approved by design: +// phones, send, threads, thread messages, incoming messages, create phone +// API key, rotate user API key. keys mints the per-call API delegation +// token each tool needs; api is the typed httpSMS client each tool calls; +// apiTokenTTL bounds the lifetime of every minted delegation token. store +// and confirmationTTL back rotate_user_api_key's one-time rotation +// confirmation handles. +func Register(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration, store oauth.Store, confirmationTTL time.Duration) { + registerListPhones(server, keys, api, apiTokenTTL) + registerSendSMS(server, keys, api, apiTokenTTL) + registerListMessageThreads(server, keys, api, apiTokenTTL) + registerListThreadMessages(server, keys, api, apiTokenTTL) + registerListIncomingMessages(server, keys, api, apiTokenTTL) + registerCreatePhoneAPIKey(server, keys, api, apiTokenTTL) + registerRotateUserAPIKey(server, keys, api, apiTokenTTL, store, confirmationTTL) +} + +// toolError converts err into a *mcp.CallToolResult carrying it as a +// tool-level error (CallToolResult.IsError set, err.Error() as the result's +// text content) rather than a JSON-RPC protocol error. err must already be +// safe to expose to an MCP client: every error httpsms.Client returns is +// documented to never carry a bearer token, request body, or SMS content, +// only a status code, the API's own message, field validation errors, and +// this client's own request ID. +func toolError(err error) *mcp.CallToolResult { + result := &mcp.CallToolResult{} + result.SetError(err) + return result +} + +// readOnlyAnnotations marks a tool as read-only: it never modifies state +// and is safe to call repeatedly with the same arguments. +func readOnlyAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ + ReadOnlyHint: true, + IdempotentHint: true, + } +} + +// sendAnnotations marks a tool as performing a non-idempotent, potentially +// destructive side effect: sending a message is not safe to retry blindly, +// since repeating the call sends a second message. +func sendAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: boolPtr(true), + IdempotentHint: false, + } +} + +// createAPIKeyAnnotations marks a tool as performing a non-idempotent, +// additive side effect: creating a phone API key never destroys or +// invalidates any existing state, but calling it twice with the same +// arguments still mints two distinct new secret keys. +func createAPIKeyAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: boolPtr(false), + IdempotentHint: false, + } +} + +// rotateAPIKeyAnnotations marks a tool as performing a non-idempotent, +// destructive side effect: rotating the user's primary API key invalidates +// the current one, so repeating the call is not safe to retry blindly. +func rotateAPIKeyAnnotations() *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{ + ReadOnlyHint: false, + DestructiveHint: boolPtr(true), + IdempotentHint: false, + } +} + +// boolPtr returns a pointer to b, for building *bool-valued struct literals +// (mcp.ToolAnnotations.DestructiveHint and the *bool tool-input fields whose +// presence must be distinguishable from their zero value). +func boolPtr(b bool) *bool { + return &b +} diff --git a/tests/.env.test b/tests/.env.test index c4c1e3ef..3db9ed0e 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -32,3 +32,9 @@ CLOUDFLARE_TURNSTILE_SECRET_KEY= HEARTBEAT_DB_BACKEND=mongodb CONTACT_DB_BACKEND=mongodb MONGODB_URI=mongodb://httpsms:testpassword@mongodb:27017/?authSource=admin&appName=httpsms + +# Delegated MCP authentication (see the api and mcp services in docker-compose.yml). +# The API only enables delegated MCP tokens when all three are set together. +MCP_AUTH_ISSUER=http://localhost:8082 +MCP_AUTH_AUDIENCE=http://api:8000 +MCP_AUTH_JWKS_URL=http://mcp:8080/.well-known/jwks.json diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 00000000..555e4e64 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,8 @@ +# Generated integration-test credentials and key material. Every file here is +# produced by generate-firebase-credentials.sh, is throwaway, and must never be +# committed. +firebase-credentials.json +mcp-test-signing-key.pem +mcp-test-signing-cert.pem +mcp-test-openssl.cnf +wiremock/mappings/firebase-certs.generated.json diff --git a/tests/README.md b/tests/README.md index 35c144b6..63275847 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,162 +1,250 @@ # Integration Tests -End-to-end tests for the httpSMS API. The suite runs the API and its data -stores in Docker, keeps the existing Firebase/WireMock coverage, and adds a -standard-library-only HTTPS adapter emulator for URL-backed phone gateways. +End-to-end integration tests for the httpSMS API and the hosted httpSMS MCP server. These tests validate the complete SMS lifecycle (including URL-backed phone gateways through a standard-library-only HTTPS adapter emulator), and the complete MCP OAuth/tool surface, by running the full application stack in Docker. ## Architecture -```text - ┌──────────────────┐ - │ API (Go) │ - │ Port 8000 │ - └───────┬──────────┘ - │ - FCM HTTP │ HTTPS callbacks - ┌──────────────────┐ │ ┌──────────────────────┐ - │ WireMock │◀──────┘ │ Adapter emulator │ - │ Port 8080 │ │ HTTPS callback :9091│ - └──────────────────┘ │ HTTP control :9092│ - └──────────┬───────────┘ - │ phone API calls - └──────────▶ API - -┌──────────────────┐ HTTP ┌────────────────────────────┐ -│ Test runner │───────────────────▶│ API, WireMock, and adapter │ -│ Go test on host │ │ control endpoints │ -└──────────────────┘ └────────────────────────────┘ - -Data stores: CockroachDB, Redis, and MongoDB. +``` +┌──────────────┐ HTTP ┌──────────────┐ HTTPS callbacks ┌──────────────────────┐ +│ Test Runner │──────────────▶│ API (Go) │────────────────────▶│ Adapter Emulator │ +│ (Go test) │──┐ │ Port 8000 │◀────────────────────│ Callback :9091 │ +└──────┬───────┘ │ └──────┬───────┘ phone API calls │ Control :9092 │ + │ │ │ └──────────────────────┘ + │ MCP (Streamable HTTP) │ FCM push / events (HTTP) + │ + OAuth 2.1 │ + ▼ ▼ +┌──────────────┐ delegated ┌──────────────┐ +│ MCP server │──────────────▶│ WireMock │ +│ Port 8082 │ JWT (JWKS) │ Port 8080 │ +└──────┬───────┘ └──────────────┘ + │ + │ OAuth state, confirmations, rate limits + ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ CockroachDB │ │ Redis │ │ MongoDB │ +│ Port 26257 │ │ Port 6379 │ │ Port 27017 │ +└──────────────┘ └──────────────┘ └──────────────┘ ``` ### Components -| Component | Description | -| --- | --- | -| **API** | The httpSMS Go API server | -| **WireMock** | Existing fake Firebase and webhook endpoints | -| **Adapter emulator** | HTTPS URL-backed phone gateway with an HTTP-only host control API | -| **CockroachDB** | Relational database for API data | -| **Redis** | Cache and local event queue backend | -| **MongoDB** | Heartbeat and contact backend | -| **Seed** | One-shot container that inserts integration users and API keys | -| **Test runner** | Go tests running on the host | +| Component | Description | +| --------------------- | ----------------------------------------------------------------------------------- | +| **API** | The httpSMS Go API server running in Docker | +| **MCP** | The hosted httpSMS MCP server (`mcp/Dockerfile`), OAuth + MCP tools, port 8082 | +| **WireMock** | Fake FCM, OAuth token, webhook receiver, and Firebase certificate endpoints | +| **Adapter emulator** | HTTPS URL-backed phone gateway with an HTTP-only host control API | +| **CockroachDB** | Database for the API (single-node, insecure mode) | +| **Redis** | Standalone Redis: API cache/queue plus MCP OAuth state, confirmations, rate limits | +| **MongoDB** | Heartbeat and contact storage backend | +| **Seed** | One-shot container that seeds test data into CockroachDB | +| **Test Runner** | Go test binary that runs on the host machine | ### Gateway Flows -1. **Existing FCM flow:** the API sends Firebase-compatible requests to - WireMock. Existing tests fetch outstanding messages and submit phone events - without changing their transport. -2. **URL-backed outgoing flow:** the API posts an FCM-compatible envelope to - `https://adapter-emulator:9091/notifications/{gatewayID}`. The adapter uses - its registered phone API key to fetch the outstanding message and post - `SENT` followed by `DELIVERED`. -3. **URL-backed incoming flow:** the test calls the adapter control API on - host port `9092`; the adapter posts `/v1/messages/receive` as the registered - phone. -4. **Heartbeat wake-up:** the test dispatches `phone.heartbeat.missed` through - `/v1/events`. The API sends an HTTPS callback containing - `KEY_HEARTBEAT_ID`, and the adapter posts `/v1/heartbeats`. - -The HTTPS endpoint uses a two-day throwaway CA and server certificate with the -DNS SAN `adapter-emulator`. The API container trusts only that generated CA via -`SSL_CERT_FILE`; HTTPS verification is never bypassed. The notification sender -uses the standard OpenTelemetry-instrumented Go HTTP transport. +1. **Send SMS flow**: Test sends `POST /v1/messages/send` → API pushes an FCM notification to WireMock → test fires `SENT` and `DELIVERED` events as the phone → test polls `GET /v1/messages/{id}` until status is `delivered` + +2. **Receive SMS flow**: Test sends `POST /v1/messages/receive` (as the phone) → API stores message → test verifies via `GET /v1/messages/{id}` + +3. **MCP flow**: Test completes a real PKCE authorization-code flow against the MCP server (signing a Firebase ID token the MCP server verifies against the WireMock certificate endpoint) → calls MCP tools over Streamable HTTP → the MCP server mints a short-lived, operation-bound delegation JWT per call → the API verifies it against the MCP server's JWKS document + +4. **URL-backed outgoing flow**: the API posts an FCM-compatible envelope to `https://adapter-emulator:9091/notifications/{gatewayID}`. The adapter uses its registered phone API key to fetch the outstanding message and post `SENT` followed by `DELIVERED` + +5. **URL-backed incoming flow**: the test calls the adapter control API on host port `9092`; the adapter posts `/v1/messages/receive` as the registered phone + +6. **Heartbeat wake-up flow**: the test dispatches `phone.heartbeat.missed` through `/v1/events`. The API sends an HTTPS callback containing `KEY_HEARTBEAT_ID`, and the adapter posts `/v1/heartbeats` + +### FCM Redirect + +The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect all FCM HTTP requests to WireMock instead of Google's servers. WireMock serves: + +- `/token` — Fake OAuth2 token endpoint (Firebase SDK requests tokens before sending) +- `/v1/projects/:project/messages:send` — Fake FCM push endpoint +- `/webhooks/*` — Webhook receiver +- `/firebase-certs` — Firebase signing certificate map (generated, see below) + +### Adapter TLS Certificates + +The adapter emulator's HTTPS endpoint uses a two-day throwaway CA and server certificate with the DNS SAN `adapter-emulator`, generated by `generate-adapter-certificates.sh`. The API container trusts only that generated CA via `SSL_CERT_FILE`; HTTPS verification is never bypassed. The notification sender uses the standard OpenTelemetry-instrumented Go HTTP transport. + +### MCP identity and signing keys + +`generate-firebase-credentials.sh` generates every credential the stack needs, all throwaway and all git-ignored: + +| Artifact | Used by | +| ------------------------------------------------ | ------------------------------------------------------------------------------ | +| `firebase-credentials.json` | API, as `FIREBASE_CREDENTIALS` | +| `mcp-test-signing-key.pem` | MCP container (`MCP_SIGNING_PRIVATE_KEY_FILE`) and the tests, which sign test Firebase ID tokens and delegation tokens with it | +| `mcp-test-signing-cert.pem` | The self-signed certificate matching that key | +| `wiremock/mappings/firebase-certs.generated.json` | WireMock stub serving `{"mcp-test-key": ""}` at `/firebase-certs` | + +No secret is committed: the key, certificate, and generated mapping are listed in [`.gitignore`](./.gitignore). ## Test Coverage -- [x] Existing encrypted send/receive phone scenarios through WireMock -- [x] Existing rate-limit, webhook, contacts, bulk, and thread scenarios -- [x] URL-backed outgoing message reaches `delivered` -- [x] URL-backed incoming message reaches `received` -- [x] URL-backed heartbeat callback stores a heartbeat -- [x] Adapter callback notification IDs are deduplicated in memory -- [x] HTTPS certificate trust is exercised +- [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → SENT/DELIVERED events → message reaches `delivered` status +- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint +- [x] **Message thread unread count E2E** — Incoming SMS and missed calls increment the unread count, the existing thread update endpoint clears it, and outbound activity preserves the count +- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled +- [x] **Contacts E2E** — JSON CRUD, search and pagination totals, CSV import normalization, and contact details attached to message threads +- [x] **URL-backed outgoing message** reaches `delivered` through the adapter emulator's HTTPS callback +- [x] **URL-backed incoming message** reaches `received` through the adapter's host control API +- [x] **URL-backed heartbeat callback** stores a heartbeat +- [x] **Adapter callback deduplication** — adapter callback notification IDs are deduplicated in memory +- [x] **HTTPS certificate trust** is exercised end-to-end for the adapter emulator +- [x] **MCP readiness** — `/health` and `/healthz`, plus the secure response headers and request ID every response carries +- [x] **MCP discovery** — RFC 9728 protected-resource metadata (root and `/mcp`-suffixed), RFC 8414 authorization-server metadata, JWKS, CORS preflight +- [x] **MCP authentication challenge** — unauthenticated, malformed, and wrong-audience tokens are refused with `WWW-Authenticate` pointing at the resource metadata +- [x] **MCP DCR and CIMD** — dynamic client registration, rejected client metadata, and a CIMD `client_id` resolving to a private host +- [x] **MCP OAuth** — PKCE authorization-code exchange through real Firebase ID token verification, code replay, consent replay, mismatched client/redirect/resource/verifier, unverifiable identity tokens, refresh rotation and replay, scope narrowing and escalation +- [x] **MCP protocol** — `2026-07-28` discovery/tool listing through the official SDK client, `2025-11-25` initialize/tools-list/tools-call over the raw wire protocol, and the exact seven-tool catalog on both +- [x] **MCP tools** — `list_phones`, `send_sms` (through the FCM push and delivery events), `list_message_threads`, `list_thread_messages`, `list_incoming_messages` (received SMS present, missed calls absent), `create_phone_api_key`, `rotate_user_api_key` +- [x] **MCP scopes** — every tool is refused when its scope was not granted +- [x] **MCP delegation binding** — the API enforces the exact method, path, audience, issuer, expiry, and scope of every delegation token +- [x] **MCP incoming vs. search** — `/v1/messages/incoming` serves a delegated token while the CAPTCHA-protected `/v1/messages/search` stays protected +- [x] **MCP rotation confirmation** — an unconfirmed call never rotates, a legacy confirmation handle and an MRTR elicitation each rotate exactly once, the previous primary key stops working, and a redeemed handle can never be replayed +- [x] **MCP rate limits** — an exhausted per-user/per-tool budget is rejected before the tool runs, with a structured retry hint, robustly across the UTC hour boundary the budget window is aligned to +- [x] **MCP user-data isolation** — the MCP user sees its seeded phone and thread while a second, fully isolated user sees no phones, threads, thread messages, or incoming messages, even when naming the other user's phone number explicitly +- [x] **MCP secret handling** — minted API keys, access tokens, and refresh tokens never appear in the MCP server's logs (and never in a test failure message either) ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) with Docker Compose - [Go 1.25+](https://go.dev/dl/) -- [jq](https://jqlang.github.io/jq/download/) -- [OpenSSL](https://www.openssl.org/) +- [jq](https://jqlang.github.io/jq/download/) (for Firebase credentials generation) +- [OpenSSL](https://www.openssl.org/) (for RSA key and certificate generation) On Windows, the scripts can be run with Git Bash, for example `C:\Program Files\Git\bin\bash.exe`. ## Running Locally -### 1. Generate throwaway credentials and certificates +### 1. Generate Credentials and Certificates Run both scripts before starting Docker: ```bash cd tests -bash generate-firebase-credentials.sh firebase-credentials.json +bash generate-firebase-credentials.sh bash generate-adapter-certificates.sh certs export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) ``` -The generated Firebase credential and the complete `certs/` directory are -ignored by Git. +`generate-firebase-credentials.sh` creates `firebase-credentials.json`, the MCP signing key and certificate, and the WireMock Firebase certificate mapping. `generate-adapter-certificates.sh` creates the throwaway CA and server certificate the adapter emulator's HTTPS endpoint uses (in `certs/`). Re-run either any time; every artifact is disposable, and the generated Firebase credential and the complete `certs/` directory are ignored by Git. -### 2. Start the stack and wait for seeding +### 2. Start the Stack ```bash -docker compose up -d --build --wait +docker compose up -d --build --wait mcp +``` + +Naming `mcp` starts it and every service it depends on (CockroachDB, Redis, MongoDB, WireMock, the adapter emulator, and the API) and, because `--wait` only ever waits on the services named on the command line and their dependencies, it blocks until each of those health checks passes. Do not run a bare `docker compose up --wait`: that also targets the one-shot `cockroachdb-init` and `seed` containers, which have no health check and are *expected* to exit, so `--wait` treats them as failures. + +### 3. Seed the Database + +```bash +docker compose up -d seed docker compose wait seed -sleep 2 ``` -### 3. Run the complete suite +The seed container inserts the test users, the immutable seeded MCP phone and message thread, and their API keys into CockroachDB after the API has run its GORM migrations. It is idempotent (`ON CONFLICT ... DO NOTHING`), so re-running it against an already-seeded database is harmless. + +### 4. Run Tests ```bash -go test -v -timeout 300s ./... +go test -v -timeout 900s ./... ``` -### 4. Tear down +Only the MCP suite: + +```bash +go test -v -timeout 900s -run 'TestMCP' ./... +``` + +In a random order (every test is order independent): + +```bash +go test -v -timeout 900s -shuffle=on ./... +``` + +The suite is re-runnable: running it again against the same containers, without tearing anything down, passes. Nothing it mutates is a prerequisite of anything it asserts — the rotation tests use a dedicated user and always derive the current key by rotating once first, and the rate-limit test authenticates as a brand-new Firebase UID so its hourly budget is always untouched. Every MCP test starts with a fast preflight that validates the immutable seeded prerequisites and prints the exact reset commands if (and only if) one of them is invalid. + +### 5. Tear Down ```bash docker compose down -v ``` -### One-liner +The `-v` flag removes volumes (database data). This is only needed when you are finished, or when the preflight tells you a seeded prerequisite is missing or stale — not between ordinary runs. + +### One-Liner ```bash cd tests && \ bash generate-firebase-credentials.sh firebase-credentials.json && \ bash generate-adapter-certificates.sh certs && \ export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) && \ - docker compose up -d --build --wait && \ + docker compose up -d --build --wait mcp && \ + docker compose up -d seed && \ docker compose wait seed && \ - sleep 2 && \ - go test -v -timeout 300s ./... ; \ - docker compose down -v + go test -v -timeout 900s ./... ``` +## Ports + +| Port | Service | +| ------- | ------------------------------------------ | +| `8000` | API | +| `8080` | WireMock (FCM, webhooks, Firebase certs) | +| `8081` | CockroachDB admin UI | +| `8082` | MCP server (`/mcp`, OAuth, discovery) | +| `9092` | Adapter emulator (host control API) | +| `6379` | Redis | +| `26257` | CockroachDB SQL | +| `27017` | MongoDB | + +The adapter emulator's HTTPS callback port (`9091`) is only reached over the Docker network by the API container and is not published to the host. + ## CI/CD -`.github/workflows/api.yml` generates both the fake Firebase credential and -the adapter CA/server certificate before building the Compose stack. The -workflow runs API handler integration tests and this complete host-side suite, -collects service logs on failure, and always tears the stack down. +Integration tests run automatically in the `test` job ("Integration Tests") of [`.github/workflows/api.yml`](../.github/workflows/api.yml): + +- **Trigger**: Push to `main` or pull request targeting `main` +- **Flow**: Generates credentials (including the MCP signing key, certificate, and WireMock certificate mapping) and the adapter CA/server certificates → Builds the API, MCP, and adapter emulator images → Starts the Docker stack → Waits for the MongoDB, API, MCP, and adapter emulator health checks → Seeds the DB → Runs the MCP unit tests (`go test -race -count=1 ./...` in `mcp/`) and builds the MCP server binary → Runs the API handler tests and this suite → Collects logs on failure → Tears down +- **Gate**: The `deploy` job in the same workflow only runs after the `test` job passes, so a failing MCP unit test, MCP build, health check, or integration test blocks the deploy ## Test Data -| Entity | Value | -| --- | --- | -| User API key | `test-user-api-key` | -| System API key | `system-user-api-key` | -| User ID | `test-user-id` | -| System user ID | `system-user-id` | +| Entity | Value | +| -------------------------- | ---------------------------- | +| User API Key | `test-user-api-key` | +| User ID | `test-user-id` | +| System API Key | `system-user-api-key` | +| System User ID | `system-user-id` | +| Rotation User API Key | `rotate-test-api-key` | +| Rotation User ID | `rotate-test-user-id` | +| MCP User API Key | `mcp-test-user-api-key` | +| MCP User ID | `mcp-test-user-id` | +| MCP rotation user | `mcp-rotation-user-id` | +| MCP isolated user | `mcp-rate-limit-user-id` | +| MCP seeded phone | `+18885550101` | +| MCP seeded thread contact | `+18885550202` | +| MCP signing key ID | `mcp-test-key` | +| Firebase project | `httpsms-test` | -Adapter tests create a unique gateway UUID, phone number, phone API key, and -callback path per test. See [`seed.sql`](./seed.sql) for shared seed data. +`mcp-test-user-api-key` is never rotated by any test. The MCP rotation tests only ever rotate `mcp-rotation-user-id`'s key, and they derive its current value by rotating once first rather than assuming the seeded value is still current. The MCP isolated user never owns any data, which is what the user-data isolation test asserts. The MCP rate-limit test authenticates as a brand-new, throwaway Firebase UID on every attempt. + +The seeded MCP phone and message thread are immutable: no test mutates or deletes them, and the suite's preflight validates both before any MCP test runs. + +Adapter tests create a unique gateway UUID, phone number, phone API key, and callback path per test. Other phones, phone API keys, and message threads are created at runtime by the tests themselves. + +See [`seed.sql`](./seed.sql) for the complete seed data. ## Project Structure -```text +``` tests/ -├── adapter-emulator/ +├── adapter-emulator/ # Standard-library-only HTTPS adapter emulator │ ├── Dockerfile │ ├── go.mod │ ├── main.go @@ -165,18 +253,23 @@ tests/ │ ├── notification_handler.go │ ├── control_handler.go │ └── emulator_test.go -├── wiremock/ -│ └── mappings/ -├── adapter_integration_test.go -├── integration_test.go -├── helpers_test.go -├── docker-compose.yml -├── .env.test -├── seed.sql -├── generate-firebase-credentials.sh -├── generate-adapter-certificates.sh +├── docker-compose.yml # Full stack orchestration +├── seed.sql # Database seed data +├── .env.test # API and MCP environment variables +├── .gitignore # Generated credentials and key material +├── generate-firebase-credentials.sh # Generates credentials and MCP signing key material +├── generate-adapter-certificates.sh # Generates the adapter emulator's throwaway CA/server certs ├── go.mod -└── go.sum +├── go.sum +├── helpers_test.go # API test utilities (HTTP client, polling) +├── integration_test.go # API E2E test cases +├── adapter_integration_test.go # URL-backed phone gateway E2E test cases +├── contacts_integration_test.go +├── read_receipts_test.go +├── unarchive_thread_integration_test.go +├── mcp_helpers_test.go # MCP/OAuth test utilities (token signing, OAuth flow, MCP client) +├── mcp_integration_test.go # MCP E2E test cases +└── wiremock/mappings/ # FCM, OAuth token, webhook, and Firebase certificate stubs ``` ## Troubleshooting @@ -187,9 +280,44 @@ tests/ docker compose logs --tail 200 api adapter-emulator ``` -Confirm `tests/certs/ca.pem`, `server.pem`, and `server-key.pem` exist. TLS -errors should be fixed by regenerating certificates; do not disable HTTPS -verification. +Confirm `tests/certs/ca.pem`, `server.pem`, and `server-key.pem` exist. TLS errors should be fixed by regenerating certificates with `bash generate-adapter-certificates.sh certs`; do not disable HTTPS verification. + +Also check: + +- `FIREBASE_CREDENTIALS` env var not set or malformed +- CockroachDB not ready (increase `start_period` in healthcheck) + +### MCP server fails to start + +```bash +docker compose logs mcp +``` + +Common issues: + +- `mcp-test-signing-key.pem` missing — run `bash generate-firebase-credentials.sh` +- The key file is not readable by the container's unprivileged `mcp` user (the generator sets mode `0644`) +- A configuration error: the MCP server names every missing or invalid setting in a single startup error + +### MCP tests fail with `access_denied` on the consent step + +WireMock is not serving the generated certificate, or the certificate no longer matches the signing key. Re-run `bash generate-firebase-credentials.sh` and restart WireMock so it reloads its mappings: + +```bash +docker compose restart wiremock mcp +``` + +### MCP tests fail with 401 on the API + +The API could not verify the delegation token. Check that `MCP_AUTH_ISSUER`, `MCP_AUTH_AUDIENCE`, and `MCP_AUTH_JWKS_URL` in `.env.test` still match the MCP service's `MCP_BASE_URL`, `API_AUDIENCE`, and JWKS route. + +### MCP rotation or rate-limit tests fail on a re-run + +They should not: the rotation tests use a dedicated user and derive its current key by rotating once first, and the rate-limit test authenticates as a brand-new Firebase UID on every attempt. If a rotation test still fails, check the MCP preflight message — it names the exact seeded prerequisite that is missing and the commands to restore it. + +### A test fails with "MCP integration preflight failed" + +A prerequisite the suite cannot run without is missing. The message names it and prints the exact recovery commands: either re-run `bash generate-firebase-credentials.sh`, start the stack, or (only when an *immutable* seeded row is missing or stale) reset with `docker compose down -v` and re-seed. ### URL-backed outgoing message times out @@ -197,29 +325,25 @@ verification. docker compose logs --tail 200 api adapter-emulator ``` -Adapter logs should show callback receipt, the outstanding-message fetch, -`SENT`, and `DELIVERED`. Confirm `SSL_CERT_FILE=/adapter-certs/ca.pem` is -present in the API container. +Adapter logs should show callback receipt, the outstanding-message fetch, `SENT`, and `DELIVERED`. Confirm `SSL_CERT_FILE=/adapter-certs/ca.pem` is present in the API container. ### URL-backed incoming message times out -Adapter logs should show the control request followed by a call to -`/v1/messages/receive`. The gateway registration contains the per-test phone -number and phone API key. +Adapter logs should show the control request followed by a call to `/v1/messages/receive`. The gateway registration contains the per-test phone number and phone API key. ### Heartbeat callback times out -API logs should show `phone.heartbeat.missed`. Adapter logs should show -`KEY_HEARTBEAT_ID` followed by a successful heartbeat POST. +API logs should show `phone.heartbeat.missed`. Adapter logs should show `KEY_HEARTBEAT_ID` followed by a successful heartbeat POST. -### Existing FCM scenario times out +### Tests timeout waiting for `delivered` status (existing FCM scenarios) + +Check the API and WireMock logs: ```bash -docker compose logs --tail 200 api wiremock +docker compose logs api wiremock ``` -Keep `FCM_ENDPOINT=http://wiremock:8080`; the adapter service does not replace -or weaken the WireMock phone tests. +If no FCM request appears, the API isn't reaching WireMock (check `FCM_ENDPOINT` in `.env.test`). Keep `FCM_ENDPOINT=http://wiremock:8080`; the adapter service does not replace or weaken the WireMock phone tests. ### Seed container fails @@ -227,5 +351,14 @@ or weaken the WireMock phone tests. docker compose logs seed ``` -If a relation does not exist, inspect API migration/startup logs before -increasing health-check timing. +If you see "relation does not exist" errors, the API hasn't finished GORM migrations yet. Increase the API's `start_period` in `docker-compose.yml`. + +## Adding New Tests + +1. Add test functions to `integration_test.go` (or create new `*_test.go` files); MCP cases belong in `mcp_integration_test.go`, adapter cases belong in `adapter_integration_test.go` +2. Use `requestJSON()`/`requestJSONAs()` for authenticated HTTP calls +3. Use `pollMessageStatus()`/`pollMessageStatusAs()` to wait for async state changes +4. For MCP cases, start with `requireMCPStack(t)`, then use `completeOAuthCodeFlow()` and `newMCPClient()`; never rotate `test-user-api-key` or `mcp-test-user-api-key`, and never mutate the seeded MCP phone or thread +5. Keep every test independent of order, of other tests, and of how many times the suite has already run: derive mutated state inside the test rather than assuming a seeded value is still current +6. Never let a secret reach the test log: quote response bodies through `redactSecrets()` and assert log redaction with `assertSecretNotLogged()` +7. Update the test coverage checklist in this README diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index ca03a313..8dc621b1 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -113,6 +113,14 @@ services: - .env.test environment: FIREBASE_CREDENTIALS: "${FIREBASE_CREDENTIALS}" + # Delegated MCP authentication. The issuer matches the MCP service's + # own public base URL (MCP_BASE_URL below), the audience matches the + # API URL the MCP service is configured to call, and the JWKS document + # is fetched over the Docker network rather than through the host port + # mapping. + MCP_AUTH_ISSUER: http://localhost:8082 + MCP_AUTH_AUDIENCE: http://api:8000 + MCP_AUTH_JWKS_URL: http://mcp:8080/.well-known/jwks.json SSL_CERT_FILE: /adapter-certs/ca.pem volumes: - ./certs/ca.pem:/adapter-certs/ca.pem:ro @@ -123,6 +131,63 @@ services: retries: 20 start_period: 30s + mcp: + build: + context: ../mcp + ports: + - "8082:8080" + depends_on: + api: + condition: service_healthy + redis: + condition: service_healthy + wiremock: + condition: service_healthy + env_file: + # Only for REDIS_URL and ENV; every MCP-specific setting is declared + # explicitly below so the MCP service's configuration is readable in + # one place. + - .env.test + environment: + PORT: "8080" + # The base URL is the host-mapped URL, because it is also the issuer, + # the RFC 8707 resource identifier ("/mcp"), and the value + # published in OAuth discovery metadata: every one of those must be + # the URL an MCP client on the host actually connects to. + MCP_BASE_URL: http://localhost:8082 + HTTPSMS_API_URL: http://api:8000 + API_AUDIENCE: http://api:8000 + FIREBASE_PROJECT_ID: httpsms-test + FIREBASE_API_KEY: test-firebase-api-key + FIREBASE_AUTH_DOMAIN: httpsms-test.firebaseapp.com + # Deterministic, offline Firebase certificate endpoint: WireMock serves + # the self-signed certificate generated alongside the signing key, so + # the integration tests can mint Firebase ID tokens the MCP service + # actually verifies (see generate-firebase-credentials.sh). + FIREBASE_CERTS_URL: http://wiremock:8080/firebase-certs + MCP_SIGNING_PRIVATE_KEY_FILE: /run/secrets/mcp-test-signing-key.pem + MCP_SIGNING_KEY_ID: mcp-test-key + # Rate-limit budgets sized for a suite that must be re-runnable against + # a live stack: high enough that repeated full runs within the same + # hour never trip a limit (an hourly window outlives a test run), low + # enough that the dedicated rate-limit test still exhausts the hourly + # create_phone_api_key budget in a couple of seconds. That test + # authenticates as a brand-new Firebase UID on every attempt, so it + # only ever spends its own untouched budget; KEY_CREATES_PER_HOUR is + # mirrored by mcpKeyCreatesPerHour in mcp_integration_test.go. + READ_TOOLS_PER_MINUTE: "240" + SEND_TOOLS_PER_MINUTE: "30" + KEY_CREATES_PER_HOUR: "40" + KEY_ROTATIONS_PER_HOUR: "500" + volumes: + - ./mcp-test-signing-key.pem:/run/secrets/mcp-test-signing-key.pem:ro + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/health"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 5s + seed: image: cockroachdb/cockroach:latest depends_on: diff --git a/tests/generate-firebase-credentials.sh b/tests/generate-firebase-credentials.sh index 70f47cd8..75c68399 100644 --- a/tests/generate-firebase-credentials.sh +++ b/tests/generate-firebase-credentials.sh @@ -1,12 +1,44 @@ #!/bin/bash -# Generates a fake Firebase service account JSON for integration tests. -# The RSA key is throwaway — it only needs to be valid so the Firebase SDK can sign JWTs. -# WireMock does not validate these tokens. +# Generates the throwaway credentials the integration stack needs: +# +# 1. a fake Firebase service account JSON (consumed by the API through the +# FIREBASE_CREDENTIALS environment variable), and +# 2. the test-only RSA signing key, self-signed certificate, and WireMock +# Firebase certificate mapping the MCP service and the MCP integration +# tests share. +# +# Every artifact is disposable and is regenerated on each invocation. None of +# them is ever committed: see tests/.gitignore. +# +# The RSA key in the service account JSON is throwaway — it only needs to be +# valid so the Firebase SDK can sign JWTs. WireMock does not validate those +# tokens. +# +# The MCP signing key is used for two things, both test-only: +# +# * the MCP service signs its own MCP access tokens and downstream API +# delegation tokens with it (MCP_SIGNING_PRIVATE_KEY_FILE), publishing the +# matching public key at /.well-known/jwks.json for the API to verify +# against, and +# * the integration tests sign deterministic Firebase-style ID tokens with +# it, which the MCP service verifies against the self-signed certificate +# served by WireMock at /firebase-certs under the "mcp-test-key" key ID. set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + OUTFILE="${1:-firebase-credentials.json}" +MCP_SIGNING_KEY="$SCRIPT_DIR/mcp-test-signing-key.pem" +MCP_SIGNING_CERT="$SCRIPT_DIR/mcp-test-signing-cert.pem" +MCP_CERTS_MAPPING="$SCRIPT_DIR/wiremock/mappings/firebase-certs.generated.json" + +# The key ID published in the WireMock Firebase certificate map, used as the +# "kid" header of every test Firebase ID token and of every token the MCP +# service mints (MCP_SIGNING_KEY_ID in tests/docker-compose.yml). +MCP_SIGNING_KEY_ID="mcp-test-key" + # Generate a 2048-bit RSA key PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null) @@ -29,3 +61,68 @@ cat > "$OUTFILE" < "$OPENSSL_CONF_FILE" <<'EOF' +[req] +distinguished_name = dn +prompt = no +x509_extensions = ext + +[dn] +CN = httpsms-mcp-integration-tests + +[ext] +basicConstraints = critical,CA:FALSE +EOF + +openssl genrsa -out "$MCP_SIGNING_KEY" 2048 2>/dev/null +openssl req -x509 -new -key "$MCP_SIGNING_KEY" -days 3650 \ + -config "$OPENSSL_CONF_FILE" \ + -out "$MCP_SIGNING_CERT" 2>/dev/null + +rm -f "$OPENSSL_CONF_FILE" + +# The MCP container runs as the unprivileged "mcp" user and mounts the key +# read-only, so it must be world readable. This is a throwaway test key that +# never leaves the developer machine or the CI runner. +chmod 0644 "$MCP_SIGNING_KEY" "$MCP_SIGNING_CERT" + +echo "Generated $MCP_SIGNING_KEY" +echo "Generated $MCP_SIGNING_CERT" + +# Emit the WireMock stub for the Firebase certificate endpoint the MCP service +# verifies test Firebase ID tokens against. The body is the flat +# "key ID -> PEM certificate" map Google's securetoken endpoint serves (not a +# JWKS document), which is exactly what the MCP service's Firebase certificate +# cache parses. +CERT_ESCAPED=$(awk '{printf "%s\\n", $0}' "$MCP_SIGNING_CERT") + +cat > "$MCP_CERTS_MAPPING" < 0 && response.StatusCode < http.StatusBadRequest { + require.NoError(t, json.Unmarshal(raw, &decoded), "response body: %s", string(raw)) + } + + return response, decoded +} + +// doMCPRequest performs a single HTTP request against the MCP service and +// returns the response (with its body already drained and closed) and the body. +func doMCPRequest(t *testing.T, method, requestURL, contentType string, body io.Reader) (*http.Response, string) { + t.Helper() + + request, err := http.NewRequest(method, requestURL, body) + require.NoError(t, err) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + + response, err := noRedirectClient().Do(request) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + + raw, err := io.ReadAll(response.Body) + require.NoError(t, err) + + return response, string(raw) +} + +// getJSON fetches requestURL and decodes the JSON body into out, returning the +// response for status/header assertions. +func getJSON(t *testing.T, requestURL string, out any) *http.Response { + t.Helper() + + response, body := doMCPRequest(t, http.MethodGet, requestURL, "", nil) + if out != nil { + require.NoError(t, json.Unmarshal([]byte(body), out), "body: %s", body) + } + + return response +} + +// apiRequestWithBearer performs an httpSMS API request authenticated with a +// bearer token (an MCP API delegation token) rather than an x-api-key. +func apiRequestWithBearer(t *testing.T, method, path, token string) (int, string) { + t.Helper() + + request, err := http.NewRequest(method, apiBaseURL+path, nil) + require.NoError(t, err) + request.Header.Set("Authorization", "Bearer "+token) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + + return response.StatusCode, string(body) +} + +// apiRequestWithAPIKey performs an httpSMS API request authenticated with an +// x-api-key, returning the status code and body. +func apiRequestWithAPIKey(t *testing.T, method, path, apiKey string) (int, string) { + t.Helper() + + request, err := http.NewRequest(method, apiBaseURL+path, nil) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + + return response.StatusCode, string(body) +} + +// setupPhoneForUser registers a phone (and its phone API key) for an arbitrary +// user, mirroring setupPhone but authenticating with that user's own primary +// API key so the MCP test user gets phones of its own. +func setupPhoneForUser(ctx context.Context, t *testing.T, userAPIKeyValue string, messagesPerMinute uint) testPhone { + t.Helper() + + phoneNumber := randomPhoneNumber() + fcmToken := "fcm-" + uuid.NewString() + + var created struct { + Data struct { + APIKey string `json:"api_key"` + } `json:"data"` + } + requestJSONAs(ctx, t, http.MethodPost, "/v1/phone-api-keys", userAPIKeyValue, map[string]any{ + "name": "mcp-test-key-" + uuid.NewString(), + }, http.StatusOK, &created) + require.NotEmpty(t, created.Data.APIKey) + + requestJSONAs(ctx, t, http.MethodPut, "/v1/phones", userAPIKeyValue, map[string]any{ + "phone_number": phoneNumber, + "fcm_token": fcmToken, + "messages_per_minute": messagesPerMinute, + "max_send_attempts": 2, + "message_expiration_seconds": 600, + "sim": "SIM1", + }, http.StatusOK, nil) + + requestJSONAs(ctx, t, http.MethodPut, "/v1/phones/fcm-token", created.Data.APIKey, map[string]any{ + "phone_number": phoneNumber, + "fcm_token": fcmToken, + "sim": "SIM1", + }, http.StatusOK, nil) + + waitForPhoneAuthorization(ctx, t, created.Data.APIKey, phoneNumber, 20*time.Second) + + return testPhone{ + PhoneNumber: phoneNumber, + PhoneAPIKey: created.Data.APIKey, + FcmToken: fcmToken, + } +} + +// requestJSONAs is requestJSON for an arbitrary API key: it retries a 401 for +// a short while, because a freshly minted phone API key is not immediately +// visible to every API instance's cache. +func requestJSONAs( + ctx context.Context, + t *testing.T, + method string, + path string, + apiKey string, + payload any, + expectedStatus int, + output any, +) { + t.Helper() + + var encoded []byte + if payload != nil { + var err error + encoded, err = json.Marshal(payload) + require.NoError(t, err) + } + + deadline := time.Now().Add(20 * time.Second) + for { + request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, strings.NewReader(string(encoded))) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + require.NoError(t, err) + + if response.StatusCode == http.StatusUnauthorized && expectedStatus != http.StatusUnauthorized && time.Now().Before(deadline) { + time.Sleep(500 * time.Millisecond) + continue + } + + require.Equal(t, expectedStatus, response.StatusCode, "%s %s: %s", method, path, string(body)) + if output != nil { + require.NoError(t, json.Unmarshal(body, output)) + } + return + } +} + +// receiveSMSAs submits an inbound SMS through the phone endpoint, exactly as +// the Android app does. +func receiveSMSAs(ctx context.Context, t *testing.T, phone testPhone, from, content string) { + t.Helper() + + requestJSONAs(ctx, t, http.MethodPost, "/v1/messages/receive", phone.PhoneAPIKey, map[string]any{ + "from": from, + "to": phone.PhoneNumber, + "content": content, + "encrypted": false, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + }, http.StatusOK, nil) +} + +// reportMissedCallAs submits a missed call through the phone endpoint. +func reportMissedCallAs(ctx context.Context, t *testing.T, phone testPhone, from string) { + t.Helper() + + requestJSONAs(ctx, t, http.MethodPost, "/v1/messages/calls/missed", phone.PhoneAPIKey, map[string]any{ + "from": from, + "to": phone.PhoneNumber, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + }, http.StatusOK, nil) +} + +// newRateLimitUserID returns a brand-new, throwaway Firebase UID for the +// rate-limit test. Rate-limit budgets are keyed by user and tool, so a fresh +// UID always starts with an untouched budget: the rate-limit assertion is +// therefore exactly as strong as before while surviving repeated runs against +// the same live stack. Unlike a delegated MCP call to a route the API +// authenticates purely off the token's own claims, create_phone_api_key's +// delegated token is only ever honored for a subject the API can load a real +// users row for (see MCPDelegationAuth), so seedRateLimitUser must create one +// before this UID can mint anything. +func newRateLimitUserID() string { + return "mcp-rate-limit-" + uuid.NewString() +} + +// cockroachDSN is the CockroachDB connection string the test binary uses to +// seed and clean up throwaway rate-limit users directly, mirroring +// DATABASE_URL in tests/.env.test with the host-mapped port (26257) the +// stack publishes for exactly this purpose, rather than the in-network +// "cockroachdb" hostname the containers themselves resolve. +const cockroachDSN = "postgresql://root@localhost:26257/httpsms?sslmode=disable" + +// seedRateLimitUser inserts a brand-new user row for a throwaway rate-limit +// UID directly into CockroachDB -- the same mechanism tests/seed.sql uses -- +// and registers a t.Cleanup that deletes it, and any phone API keys it ends +// up owning, once the test finishes. Rate-limit budgets are still keyed by +// UID alone, so a fresh UID starts with an untouched budget regardless of +// this row's lifetime; seeding and deleting it around the test just keeps +// create_phone_api_key from failing 401 without leaving a permanent row +// behind, unlike a fixed seeded user shared across every run. +// +// The insert is deliberately minimal, mirroring the columns tests/seed.sql +// sets for every other seeded user: id, email, api_key, timezone, and +// subscription_name, with created_at/updated_at defaulted to NOW(). +func seedRateLimitUser(t *testing.T) (userID string, apiKey string) { + t.Helper() + + db, err := sql.Open("postgres", cockroachDSN) + require.NoError(t, err, "open CockroachDB connection for rate-limit user setup") + t.Cleanup(func() { _ = db.Close() }) + require.NoError(t, db.PingContext(context.Background()), "connect to CockroachDB at %s to seed the rate-limit user", cockroachDSN) + + userID = newRateLimitUserID() + apiKey = "mcp-rate-limit-key-" + uuid.NewString() + + _, err = db.ExecContext(context.Background(), ` + INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) + VALUES ($1, $2, $3, 'UTC', 'pro-monthly', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING + `, userID, userID+"@httpsms.com", apiKey) + require.NoError(t, err, "seed rate-limit user %s", userID) + + t.Cleanup(func() { cleanupRateLimitUser(t, userID) }) + + return userID, apiKey +} + +// cleanupRateLimitUser best-effort deletes the phone API keys and user row +// seedRateLimitUser created for userID. It never fails the test: a stack +// that has already been torn down (or a connection that cannot be reopened +// from a t.Cleanup running after other cleanups closed it) must not turn a +// passing rate-limit assertion into a failure over housekeeping. Any error +// is logged instead, exactly as the suite's other best-effort cleanups do. +func cleanupRateLimitUser(t *testing.T, userID string) { + t.Helper() + + db, err := sql.Open("postgres", cockroachDSN) + if err != nil { + t.Logf("cannot open CockroachDB connection to clean up rate-limit user %s: %v", userID, err) + return + } + defer func() { _ = db.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := db.ExecContext(ctx, `DELETE FROM phone_api_keys WHERE user_id = $1`, userID); err != nil { + t.Logf("cannot delete phone API keys for rate-limit user %s: %v", userID, err) + return + } + if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, userID); err != nil { + t.Logf("cannot delete rate-limit user %s: %v", userID, err) + } +} + +// countPhoneAPIKeys returns how many phone_api_keys rows userID owns. The +// rate-limit test uses it to assert every pre-budget create_phone_api_key +// call actually persisted a key -- not just that the MCP service reported +// success -- before the call that must be rate limited. +func countPhoneAPIKeys(t *testing.T, userID string) int { + t.Helper() + + db, err := sql.Open("postgres", cockroachDSN) + require.NoError(t, err, "open CockroachDB connection to count phone API keys for %s", userID) + defer func() { _ = db.Close() }() + + var count int + err = db.QueryRowContext(context.Background(), `SELECT count(*) FROM phone_api_keys WHERE user_id = $1`, userID).Scan(&count) + require.NoError(t, err, "count phone API keys for %s", userID) + + return count +} + +// pollMessageStatusAs polls GET /v1/messages/{id} with apiKey until the +// message reaches targetStatus, mirroring pollMessageStatus for a user other +// than the shared integration-test user. +func pollMessageStatusAs(ctx context.Context, t *testing.T, apiKey, messageID, targetStatus string, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + lastStatus := "" + for time.Now().Before(deadline) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBaseURL+"/v1/messages/"+messageID, nil) + require.NoError(t, err) + request.Header.Set("x-api-key", apiKey) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + require.NoError(t, err) + + if response.StatusCode == http.StatusOK { + var decoded struct { + Data struct { + Status string `json:"status"` + } `json:"data"` + } + if json.Unmarshal(body, &decoded) == nil { + lastStatus = decoded.Data.Status + if lastStatus == targetStatus { + return + } + } + } + + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("message %s did not reach status %q within %v (last status %q)", messageID, targetStatus, timeout, lastStatus) +} + +// mcpContainerLogs returns the MCP container's logs, or ok == false when the +// Docker CLI is unavailable (the log-redaction assertion is then skipped +// rather than failing for an unrelated reason). +func mcpContainerLogs(t *testing.T) (string, bool) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + command := exec.CommandContext(ctx, "docker", "compose", "logs", "--no-color", "mcp") + output, err := command.CombinedOutput() + if err != nil { + t.Logf("cannot read MCP container logs (%v); skipping the log redaction assertion", err) + return "", false + } + + return string(output), true +} + +// resetStackInstruction is the exact recovery procedure for a stack whose +// immutable seeded prerequisites are missing or stale. It is only ever +// printed when a prerequisite really is invalid: a routine repeated run +// against a healthy stack never needs it, because no test mutates anything +// the preflight checks. +const resetStackInstruction = "reset the stack and re-seed it:\n" + + " cd tests\n" + + " docker compose down -v\n" + + " docker compose up -d --build --wait mcp\n" + + " docker compose up -d seed && docker compose wait seed" + +var ( + mcpPreflightOnce sync.Once + mcpPreflightErr error +) + +// requireMCPStack runs the (fast, once-per-binary) preflight every MCP test +// starts with: the generated signing key exists, the API and MCP services +// answer their health checks, and every immutable seeded prerequisite is +// present. It fails with the exact command to run rather than letting a +// missing prerequisite surface later as an unrelated assertion failure. +func requireMCPStack(t *testing.T) { + t.Helper() + + mcpPreflightOnce.Do(func() { mcpPreflightErr = mcpPreflight() }) + if mcpPreflightErr != nil { + t.Fatalf("MCP integration preflight failed: %v", mcpPreflightErr) + } +} + +// mcpPreflight validates every prerequisite the MCP suite cannot run without. +// It deliberately checks only immutable state: the rotation user's primary +// API key is never checked, because rotating it is exactly what the rotation +// tests do, and requiring its seeded value to still be current is what made +// the suite non-re-runnable in the first place. +func mcpPreflight() error { + if _, err := os.Stat(mcpSigningKeyPath); err != nil { + return fmt.Errorf("%s is missing: run `bash generate-firebase-credentials.sh` in tests/ (%w)", mcpSigningKeyPath, err) + } + + client := &http.Client{Timeout: 10 * time.Second} + services := []struct{ name, url string }{ + {"the httpSMS API", apiBaseURL + "/health"}, + {"the MCP service", mcpBaseURL + "/health"}, + } + for _, service := range services { + if err := preflightStatusOK(client, service.url, ""); err != nil { + return fmt.Errorf("%s is not reachable: %w\nstart the stack with `docker compose up -d --build --wait mcp` in tests/", service.name, err) + } + } + + seededUsers := []struct{ name, apiKey string }{ + {"the MCP test user (" + mcpTestUserID + ")", mcpTestUserAPIKey}, + {"the isolated user (" + mcpIsolatedUserID + ")", mcpIsolatedUserKey}, + {"the phone API key user (" + mcpPhoneAPIKeyUserID + ")", mcpPhoneAPIKeyUserAPIKey}, + } + for _, user := range seededUsers { + if err := preflightStatusOK(client, apiBaseURL+"/v1/users/me", user.apiKey); err != nil { + return fmt.Errorf("the seeded primary API key of %s is not valid: %w\nno test ever rotates it, so %s", user.name, err, resetStackInstruction) + } + } + + if err := preflightContains( + client, + apiBaseURL+"/v1/phones?skip=0&limit=5&query="+url.QueryEscape(mcpSeededPhoneQuery), + mcpTestUserAPIKey, + mcpSeededPhoneNumber, + ); err != nil { + return fmt.Errorf("the seeded MCP phone %s is missing: %w\n%s", mcpSeededPhoneNumber, err, resetStackInstruction) + } + + if err := preflightContains( + client, + apiBaseURL+"/v1/message-threads?skip=0&limit=5&owner="+url.QueryEscape(mcpSeededPhoneNumber), + mcpTestUserAPIKey, + mcpSeededThreadContact, + ); err != nil { + return fmt.Errorf("the seeded MCP message thread %s -> %s is missing: %w\n%s", mcpSeededPhoneNumber, mcpSeededThreadContact, err, resetStackInstruction) + } + + return nil +} + +// preflightStatusOK performs a single GET and requires a 200. apiKey may be +// empty for an unauthenticated health endpoint. The response body is never +// included in the error: /v1/users/me carries the user's primary API key. +func preflightStatusOK(client *http.Client, requestURL, apiKey string) error { + status, _, err := preflightGet(client, requestURL, apiKey) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("GET %s returned %d", requestURL, status) + } + return nil +} + +// preflightContains performs a single authenticated GET and requires the +// response to contain expected. Only expected -- never the response body, +// which may carry secrets -- appears in the error. +func preflightContains(client *http.Client, requestURL, apiKey, expected string) error { + status, body, err := preflightGet(client, requestURL, apiKey) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("GET %s returned %d", requestURL, status) + } + if !strings.Contains(body, expected) { + return fmt.Errorf("GET %s did not return %s", requestURL, expected) + } + return nil +} + +// preflightGet performs one plain GET and returns its status and body. +func preflightGet(client *http.Client, requestURL, apiKey string) (int, string, error) { + request, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return 0, "", err + } + if apiKey != "" { + request.Header.Set("x-api-key", apiKey) + } + + response, err := client.Do(request) + if err != nil { + return 0, "", err + } + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(response.Body) + if err != nil { + return response.StatusCode, "", err + } + + return response.StatusCode, string(body), nil +} + +// assertAPIKeyAccepted asserts apiKey still authenticates against the API. +// The response body carries the user's primary API key, so only its redacted +// form ever reaches the failure message. +func assertAPIKeyAccepted(t *testing.T, apiKey, description string) { + t.Helper() + + status, body := apiRequestWithAPIKey(t, http.MethodGet, "/v1/users/me", apiKey) + require.Equal(t, http.StatusOK, status, "%s: %s", description, redactSecrets(body)) +} + +// assertAPIKeyRejected asserts apiKey no longer authenticates against the +// API, again without ever printing an unredacted body. +func assertAPIKeyRejected(t *testing.T, apiKey, description string) { + t.Helper() + + status, body := apiRequestWithAPIKey(t, http.MethodGet, "/v1/users/me", apiKey) + assert.Equal(t, http.StatusUnauthorized, status, "%s: %s", description, redactSecrets(body)) +} + +// bootstrapPrimaryAPIKey rotates userID's primary httpSMS API key through a +// delegation token this test binary mints itself, and returns the brand-new +// key. +// +// It is how a test that must start from a primary API key whose value it knows +// obtains one, instead of assuming a seeded key is still current: a seeded +// primary key is only ever valid until the first run of the first test that +// rotates it, which is exactly what made this suite non-re-runnable. The +// delegation token is bound to this one method and path, so the API's own +// delegated-MCP verifier accepts it for nothing else. +func bootstrapPrimaryAPIKey(t *testing.T, userID string) string { + t.Helper() + + path := "/v1/users/" + url.PathEscape(userID) + "/api-keys" + token := signAPIDelegationToken(t, userID, []string{"user-api-key:rotate"}, http.MethodDelete, path) + + status, body := apiRequestWithBearer(t, http.MethodDelete, path, token) + require.Equal(t, http.StatusOK, status, "cannot bootstrap a primary API key for %s: %s", userID, redactSecrets(body)) + + var rotated struct { + Data struct { + ID string `json:"id"` + APIKey string `json:"api_key"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(body), &rotated)) + require.Equal(t, userID, rotated.Data.ID) + require.NotEmpty(t, rotated.Data.APIKey) + + return rotated.Data.APIKey +} + +// currentRateLimitWindow returns the start of the UTC hour the MCP service's +// rate limiter is currently counting hourly budgets in. It mirrors +// time.Now().UTC().Truncate(time.Hour) in mcp/internal/server/rate_limit.go. +func currentRateLimitWindow() time.Time { + return time.Now().UTC().Truncate(time.Hour) +} + +// waitForRateLimitWindowHeadroom blocks until the current hourly rate-limit +// window has enough time left for a whole exhaust-the-budget sequence. +// +// The hourly budget is counted in fixed UTC hour windows, so a sequence that +// straddles the top of the hour would see the counter reset midway and the +// final call succeed. Waiting out the last seconds of a window keeps the +// assertion exactly as strict (the call after the budget really must be +// refused) instead of weakening it to tolerate a reset. +func waitForRateLimitWindowHeadroom(t *testing.T) { + t.Helper() + + const headroom = 30 * time.Second + + remaining := time.Until(currentRateLimitWindow().Add(time.Hour)) + if remaining >= headroom { + return + } + + t.Logf("only %s left in the current UTC rate-limit window; waiting for the next one", remaining.Round(time.Second)) + time.Sleep(remaining + time.Second) +} diff --git a/tests/mcp_integration_test.go b/tests/mcp_integration_test.go new file mode 100644 index 00000000..32c75812 --- /dev/null +++ b/tests/mcp_integration_test.go @@ -0,0 +1,1325 @@ +package tests + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMCPReadiness asserts the MCP service's liveness/readiness endpoints, +// which are what Docker Compose and Cloud Run both gate traffic on. +func TestMCPReadiness(t *testing.T) { + requireMCPStack(t) + + for _, path := range []string{"/health", "/healthz"} { + response, body := doMCPRequest(t, http.MethodGet, mcpBaseURL+path, "", nil) + require.Equal(t, http.StatusOK, response.StatusCode, "%s: %s", path, body) + assert.Equal(t, "ok", strings.TrimSpace(body)) + assert.Equal(t, "nosniff", response.Header.Get("X-Content-Type-Options")) + assert.Equal(t, "DENY", response.Header.Get("X-Frame-Options")) + assert.NotEmpty(t, response.Header.Get("X-Request-Id")) + } +} + +// TestMCPMetadataDiscovery asserts the RFC 9728 protected-resource document +// (on both the root and path-suffixed routes), the RFC 8414 authorization +// server document, and the JWKS document the httpSMS API verifies delegation +// tokens against. +func TestMCPMetadataDiscovery(t *testing.T) { + requireMCPStack(t) + + t.Run("protected resource metadata", func(t *testing.T) { + for _, path := range []string{"/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"} { + var document struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported"` + } + response := getJSON(t, mcpBaseURL+path, &document) + + require.Equal(t, http.StatusOK, response.StatusCode, path) + assert.Equal(t, "application/json", response.Header.Get("Content-Type")) + assert.Equal(t, "*", response.Header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, mcpResource, document.Resource) + assert.Equal(t, []string{mcpBaseURL}, document.AuthorizationServers) + assert.Equal(t, mcpAllScopes, document.ScopesSupported) + } + }) + + t.Run("authorization server metadata", func(t *testing.T) { + var document struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ScopesSupported []string `json:"scopes_supported"` + IssParameterSupported bool `json:"authorization_response_iss_parameter_supported"` + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` + } + response := getJSON(t, mcpBaseURL+"/.well-known/oauth-authorization-server", &document) + + require.Equal(t, http.StatusOK, response.StatusCode) + assert.Equal(t, mcpBaseURL, document.Issuer) + assert.Equal(t, mcpBaseURL+"/oauth/authorize", document.AuthorizationEndpoint) + assert.Equal(t, mcpBaseURL+"/oauth/token", document.TokenEndpoint) + assert.Equal(t, mcpBaseURL+"/oauth/register", document.RegistrationEndpoint) + assert.Equal(t, mcpBaseURL+"/.well-known/jwks.json", document.JWKSURI) + assert.Equal(t, []string{"code"}, document.ResponseTypesSupported) + assert.Equal(t, []string{"authorization_code", "refresh_token"}, document.GrantTypesSupported) + assert.Equal(t, []string{"S256"}, document.CodeChallengeMethodsSupported) + assert.Equal(t, mcpAllScopes, document.ScopesSupported) + assert.True(t, document.IssParameterSupported, "RFC 9207 iss must be advertised") + assert.True(t, document.ClientIDMetadataDocumentSupported, "CIMD must be advertised") + }) + + t.Run("jwks", func(t *testing.T) { + var document struct { + Keys []struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + response := getJSON(t, mcpBaseURL+"/.well-known/jwks.json", &document) + + require.Equal(t, http.StatusOK, response.StatusCode) + require.Len(t, document.Keys, 1) + assert.Equal(t, "RSA", document.Keys[0].Kty) + assert.Equal(t, "sig", document.Keys[0].Use) + assert.Equal(t, "RS256", document.Keys[0].Alg) + assert.Equal(t, mcpSigningKeyID, document.Keys[0].Kid) + assert.NotEmpty(t, document.Keys[0].N) + assert.NotEmpty(t, document.Keys[0].E) + + // The JWKS document must publish the public half only. + _, rawBody := doMCPRequest(t, http.MethodGet, mcpBaseURL+"/.well-known/jwks.json", "", nil) + assert.NotContains(t, rawBody, "\"d\"", "the JWKS must never carry a private exponent") + assert.NotContains(t, rawBody, "PRIVATE KEY") + }) + + t.Run("metadata preflight", func(t *testing.T) { + request, err := http.NewRequest(http.MethodOptions, mcpBaseURL+"/.well-known/oauth-protected-resource", nil) + require.NoError(t, err) + + response, err := noRedirectClient().Do(request) + require.NoError(t, err) + defer func() { _ = response.Body.Close() }() + + assert.Equal(t, http.StatusNoContent, response.StatusCode) + assert.Equal(t, "*", response.Header.Get("Access-Control-Allow-Origin")) + assert.Contains(t, response.Header.Get("Access-Control-Allow-Methods"), http.MethodGet) + }) +} + +// TestMCPUnauthenticatedChallenge asserts that the MCP endpoint refuses every +// unauthenticated or invalidly authenticated request with a 401 carrying the +// RFC 9728 resource metadata pointer a client needs to start authorization. +func TestMCPUnauthenticatedChallenge(t *testing.T) { + requireMCPStack(t) + + tests := map[string]string{ + "no token": "", + "garbage token": "not-a-jwt", + "foreign token": signAPIDelegationToken(t, mcpTestUserID, []string{"phones:read"}, http.MethodGet, "/v1/phones"), + } + + for name, token := range tests { + t.Run(name, func(t *testing.T) { + response, _ := callLegacyMCP(t, token, mcpProtocolLatest, "tools/list", map[string]any{}) + + require.Equal(t, http.StatusUnauthorized, response.StatusCode) + + challenge := response.Header.Get("WWW-Authenticate") + require.NotEmpty(t, challenge, "a 401 must carry a WWW-Authenticate challenge") + assert.Contains(t, challenge, "Bearer") + assert.Contains(t, challenge, "resource_metadata=") + assert.Contains(t, challenge, mcpBaseURL+"/.well-known/oauth-protected-resource") + }) + } +} + +// TestMCPOAuthClientRegistration covers RFC 7591 Dynamic Client Registration +// and the CIMD (Client ID Metadata Document) client resolution path the +// authorization server advertises. +func TestMCPOAuthClientRegistration(t *testing.T) { + requireMCPStack(t) + + t.Run("registers a public client", func(t *testing.T) { + clientID, redirectURI := registerMCPOAuthClient(t) + assert.NotEmpty(t, clientID) + assert.True(t, strings.HasPrefix(redirectURI, "http://localhost:53682/callback/")) + }) + + t.Run("rejects invalid client metadata", func(t *testing.T) { + cases := map[string]map[string]any{ + "non loopback http redirect": { + "client_name": "bad redirect", + "redirect_uris": []string{"http://example.com/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + }, + "confidential client": { + "client_name": "confidential", + "redirect_uris": []string{"https://example.com/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "client_secret_basic", + }, + "missing redirect uris": { + "client_name": "no redirects", + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + }, + } + + for name, metadata := range cases { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(metadata) + require.NoError(t, err) + + response, responseBody := doMCPRequest(t, http.MethodPost, mcpBaseURL+"/oauth/register", "application/json", strings.NewReader(string(body))) + require.Equal(t, http.StatusBadRequest, response.StatusCode, responseBody) + assert.Equal(t, "no-store", response.Header.Get("Cache-Control")) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(responseBody), &failure)) + assert.Contains(t, []string{"invalid_client_metadata", "invalid_redirect_uri"}, failure.Error) + }) + } + }) + + t.Run("refuses a CIMD client id that resolves to a private host", func(t *testing.T) { + // A URL client_id is resolved as a Client ID Metadata Document. The + // resolver must refuse to fetch one from a private or loopback + // address, which is the SSRF guard the hosted service depends on. + response, body := getAuthorizePage(t, url.Values{ + "client_id": {"https://localhost/cimd.json"}, + "redirect_uri": {"http://localhost:53682/callback"}, + "response_type": {"code"}, + "state": {uuid.NewString()}, + "code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"}, + "code_challenge_method": {"S256"}, + "resource": {mcpResource}, + "scope": {"phones:read"}, + }) + + require.Equal(t, http.StatusBadRequest, response.StatusCode, body) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(body), &failure)) + assert.Equal(t, "invalid_client", failure.Error) + }) +} + +// TestMCPOAuthAuthorizationCodeFlow covers the complete PKCE authorization +// code flow, through real Firebase ID token verification against the +// WireMock-served certificate, and the access token it yields. +func TestMCPOAuthAuthorizationCodeFlow(t *testing.T) { + requireMCPStack(t) + + params := requestAuthorizationCode(t, mcpTestUserID, mcpTestUserEmail, mcpAllScopes) + assert.Equal(t, mcpBaseURL, params.Issuer, "RFC 9207 iss must be echoed on the authorization response") + + response, body := redeemAuthorizationCode(t, params) + require.Equal(t, http.StatusOK, response.StatusCode, redactSecrets(body)) + assert.Equal(t, "no-store", response.Header.Get("Cache-Control")) + + var tokens tokenResponse + require.NoError(t, json.Unmarshal([]byte(body), &tokens)) + assert.Equal(t, "Bearer", tokens.TokenType) + assert.Positive(t, tokens.ExpiresIn) + assert.ElementsMatch(t, mcpAllScopes, strings.Fields(tokens.Scope)) + + t.Run("the issued access token is accepted by the MCP endpoint", func(t *testing.T) { + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest) + result, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.NotEmpty(t, result.Tools) + }) + + t.Run("an authorization code cannot be replayed", func(t *testing.T) { + replayResponse, replayBody := redeemAuthorizationCode(t, params) + require.Equal(t, http.StatusBadRequest, replayResponse.StatusCode, redactSecrets(replayBody)) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(replayBody), &failure)) + assert.Equal(t, "invalid_grant", failure.Error) + }) +} + +// TestMCPOAuthAuthorizationErrors covers every rejected authorization and +// token request: a mismatched resource, an unregistered redirect URI, an +// unverifiable identity token (wrong issuer, wrong audience, wrong signing +// key), and a token request whose client, redirect URI, or PKCE verifier does +// not match the code. +func TestMCPOAuthAuthorizationErrors(t *testing.T) { + requireMCPStack(t) + + clientID, redirectURI := registerMCPOAuthClient(t) + _, challenge := pkcePair(t) + + baseQuery := func() url.Values { + return url.Values{ + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "state": {uuid.NewString()}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "resource": {mcpResource}, + "scope": {"phones:read"}, + } + } + + t.Run("unregistered redirect uri is refused directly", func(t *testing.T) { + query := baseQuery() + query.Set("redirect_uri", "http://localhost:53682/not-registered") + + response, body := getAuthorizePage(t, query) + require.Equal(t, http.StatusBadRequest, response.StatusCode, body) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(body), &failure)) + assert.Equal(t, "invalid_request", failure.Error) + }) + + t.Run("a wrong resource is refused through the redirect", func(t *testing.T) { + query := baseQuery() + query.Set("resource", "https://evil.example.com/mcp") + + response, body := getAuthorizePage(t, query) + require.Equal(t, http.StatusFound, response.StatusCode, body) + + location, err := url.Parse(response.Header.Get("Location")) + require.NoError(t, err) + assert.Equal(t, "invalid_target", location.Query().Get("error")) + assert.Equal(t, mcpBaseURL, location.Query().Get("iss")) + }) + + t.Run("a plain code challenge is refused", func(t *testing.T) { + query := baseQuery() + query.Set("code_challenge_method", "plain") + + response, _ := getAuthorizePage(t, query) + require.Equal(t, http.StatusFound, response.StatusCode) + + location, err := url.Parse(response.Header.Get("Location")) + require.NoError(t, err) + assert.Equal(t, "invalid_request", location.Query().Get("error")) + }) + + t.Run("an unverifiable identity token is refused", func(t *testing.T) { + cases := map[string]string{ + "wrong issuer": signFirebaseTokenWithClaims(t, func(claims jwt.MapClaims) { claims["iss"] = "https://securetoken.google.com/some-other-project" }), + "wrong audience": signFirebaseTokenWithClaims(t, func(claims jwt.MapClaims) { claims["aud"] = "some-other-project" }), + "unknown key id": signFirebaseTokenWithKeyID(t, "unknown-key-id"), + "expired": signFirebaseTokenWithClaims(t, func(claims jwt.MapClaims) { claims["exp"] = time.Now().Add(-time.Hour).Unix() }), + } + + for name, idToken := range cases { + t.Run(name, func(t *testing.T) { + transactionID := startAuthorization(t, clientID, redirectURI, uuid.NewString(), challenge, []string{"phones:read"}) + + form := url.Values{"transaction_id": {transactionID}, "id_token": {idToken}} + form["approved_scopes"] = []string{"phones:read"} + + response, body := doMCPRequest( + t, + http.MethodPost, + mcpBaseURL+"/oauth/firebase/complete", + "application/x-www-form-urlencoded", + strings.NewReader(form.Encode()), + ) + require.Equal(t, http.StatusUnauthorized, response.StatusCode, body) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(body), &failure)) + assert.Equal(t, "access_denied", failure.Error) + }) + } + }) + + t.Run("a consent transaction cannot be replayed", func(t *testing.T) { + state := uuid.NewString() + transactionID := startAuthorization(t, clientID, redirectURI, state, challenge, []string{"phones:read"}) + idToken := signFirebaseTestToken(t, mcpTestUserID, mcpTestUserEmail) + + location := completeFirebaseConsent(t, transactionID, idToken, []string{"phones:read"}, http.StatusFound) + redirected, err := url.Parse(location) + require.NoError(t, err) + require.NotEmpty(t, redirected.Query().Get("code")) + + form := url.Values{"transaction_id": {transactionID}, "id_token": {idToken}} + form["approved_scopes"] = []string{"phones:read"} + + response, body := doMCPRequest( + t, + http.MethodPost, + mcpBaseURL+"/oauth/firebase/complete", + "application/x-www-form-urlencoded", + strings.NewReader(form.Encode()), + ) + require.Equal(t, http.StatusBadRequest, response.StatusCode, body) + }) + + t.Run("token requests that do not match the code are refused", func(t *testing.T) { + overrides := map[string]func(url.Values){ + "wrong code verifier": func(form url.Values) { form.Set("code_verifier", strings.Repeat("a", 43)) }, + "wrong client id": func(form url.Values) { form.Set("client_id", uuid.NewString()) }, + "wrong redirect uri": func(form url.Values) { form.Set("redirect_uri", "http://localhost:53682/other") }, + "wrong resource": func(form url.Values) { form.Set("resource", "https://evil.example.com/mcp") }, + } + + for name, override := range overrides { + t.Run(name, func(t *testing.T) { + params := requestAuthorizationCode(t, mcpTestUserID, mcpTestUserEmail, []string{"phones:read"}) + + response, body := redeemAuthorizationCode(t, params, override) + require.Equal(t, http.StatusBadRequest, response.StatusCode, redactSecrets(body)) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(body), &failure)) + assert.Equal(t, "invalid_grant", failure.Error) + + // The code was consumed before the mismatch was detected, so + // a corrected retry must fail too. + retryResponse, retryBody := redeemAuthorizationCode(t, params) + assert.Equal(t, http.StatusBadRequest, retryResponse.StatusCode, redactSecrets(retryBody)) + }) + } + }) + + t.Run("an approval of no scope is refused", func(t *testing.T) { + state := uuid.NewString() + transactionID := startAuthorization(t, clientID, redirectURI, state, challenge, []string{"phones:read"}) + + location := completeFirebaseConsent(t, transactionID, signFirebaseTestToken(t, mcpTestUserID, mcpTestUserEmail), nil, http.StatusFound) + redirected, err := url.Parse(location) + require.NoError(t, err) + assert.Equal(t, "access_denied", redirected.Query().Get("error")) + assert.Empty(t, redirected.Query().Get("code")) + }) +} + +// TestMCPOAuthRefreshRotation covers refresh-token rotation, replay of a +// consumed refresh token, scope narrowing, and scope escalation. +func TestMCPOAuthRefreshRotation(t *testing.T) { + requireMCPStack(t) + + params := requestAuthorizationCode(t, mcpTestUserID, mcpTestUserEmail, []string{"phones:read", "messages:read"}) + + response, body := redeemAuthorizationCode(t, params) + require.Equal(t, http.StatusOK, response.StatusCode, redactSecrets(body)) + + var issued tokenResponse + require.NoError(t, json.Unmarshal([]byte(body), &issued)) + + t.Run("rotates the refresh token", func(t *testing.T) { + refreshResponse, refreshBody := refreshTokens(t, issued.RefreshToken, params.ClientID) + require.Equal(t, http.StatusOK, refreshResponse.StatusCode, redactSecrets(refreshBody)) + + var rotated tokenResponse + require.NoError(t, json.Unmarshal([]byte(refreshBody), &rotated)) + assert.False(t, rotated.RefreshToken == issued.RefreshToken, "the refresh token must rotate") + assert.NotEmpty(t, rotated.AccessToken) + assert.ElementsMatch(t, []string{"phones:read", "messages:read"}, strings.Fields(rotated.Scope)) + + // The rotated access token must work against the MCP endpoint. + session := newMCPClient(t, rotated.AccessToken, mcpProtocolLatest) + _, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + + t.Run("a replayed refresh token is refused", func(t *testing.T) { + replayResponse, replayBody := refreshTokens(t, issued.RefreshToken, params.ClientID) + require.Equal(t, http.StatusBadRequest, replayResponse.StatusCode, redactSecrets(replayBody)) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(replayBody), &failure)) + assert.Equal(t, "invalid_grant", failure.Error) + }) + + t.Run("scope may be narrowed but never widened", func(t *testing.T) { + narrowedResponse, narrowedBody := refreshTokens(t, rotated.RefreshToken, params.ClientID, func(form url.Values) { + form.Set("scope", "phones:read") + }) + require.Equal(t, http.StatusOK, narrowedResponse.StatusCode, redactSecrets(narrowedBody)) + + var narrowed tokenResponse + require.NoError(t, json.Unmarshal([]byte(narrowedBody), &narrowed)) + assert.Equal(t, "phones:read", narrowed.Scope) + + widenedResponse, widenedBody := refreshTokens(t, narrowed.RefreshToken, params.ClientID, func(form url.Values) { + form.Set("scope", "phones:read messages:send") + }) + require.Equal(t, http.StatusBadRequest, widenedResponse.StatusCode, redactSecrets(widenedBody)) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(widenedBody), &failure)) + assert.Equal(t, "invalid_scope", failure.Error) + }) + }) + + t.Run("a refresh token is bound to its client", func(t *testing.T) { + otherParams := requestAuthorizationCode(t, mcpTestUserID, mcpTestUserEmail, []string{"phones:read"}) + otherResponse, otherBody := redeemAuthorizationCode(t, otherParams) + require.Equal(t, http.StatusOK, otherResponse.StatusCode, redactSecrets(otherBody)) + + var otherTokens tokenResponse + require.NoError(t, json.Unmarshal([]byte(otherBody), &otherTokens)) + + mismatchResponse, mismatchBody := refreshTokens(t, otherTokens.RefreshToken, params.ClientID) + require.Equal(t, http.StatusBadRequest, mismatchResponse.StatusCode, redactSecrets(mismatchBody)) + + var failure oauthErrorResponse + require.NoError(t, json.Unmarshal([]byte(mismatchBody), &failure)) + assert.Equal(t, "invalid_grant", failure.Error) + }) +} + +// TestMCPProtocolNegotiation asserts the served protocol surface for both the +// current (2026-07-28) and previous (2025-11-25) protocol versions, and that +// the tool catalog is exactly the seven approved tools on both. +func TestMCPProtocolNegotiation(t *testing.T) { + requireMCPStack(t) + + tokens := completeOAuthCodeFlow(t, mcpAllScopes) + + t.Run("2026-07-28 discovery and tool listing", func(t *testing.T) { + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest) + + initialized := session.InitializeResult() + require.NotNil(t, initialized) + assert.Equal(t, mcpProtocolLatest, initialized.ProtocolVersion) + require.NotNil(t, initialized.ServerInfo) + assert.Equal(t, "httpSMS", initialized.ServerInfo.Name) + assert.NotEmpty(t, initialized.ServerInfo.Version) + + result, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + + names := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + names = append(names, tool.Name) + } + assert.ElementsMatch(t, mcpToolNames, names, "the served tool catalog must be exactly the approved seven tools") + }) + + t.Run("2025-11-25 initialize and tool call", func(t *testing.T) { + response, initialize := callLegacyMCP(t, tokens.AccessToken, mcpProtocolPrevious, "initialize", map[string]any{ + "protocolVersion": mcpProtocolPrevious, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "httpsms-legacy-client", "version": "test"}, + }) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Nil(t, initialize.Error, "legacy initialize failed: %+v", initialize.Error) + + var initialized struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + } `json:"serverInfo"` + } + require.NoError(t, json.Unmarshal(initialize.Result, &initialized)) + assert.Equal(t, mcpProtocolPrevious, initialized.ProtocolVersion) + assert.Equal(t, "httpSMS", initialized.ServerInfo.Name) + + listResponse, list := callLegacyMCP(t, tokens.AccessToken, mcpProtocolPrevious, "tools/list", map[string]any{}) + require.Equal(t, http.StatusOK, listResponse.StatusCode) + require.Nil(t, list.Error, "legacy tools/list failed: %+v", list.Error) + + var listed struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + require.NoError(t, json.Unmarshal(list.Result, &listed)) + + names := make([]string, 0, len(listed.Tools)) + for _, tool := range listed.Tools { + names = append(names, tool.Name) + } + assert.ElementsMatch(t, mcpToolNames, names) + + callResponse, call := callLegacyMCP(t, tokens.AccessToken, mcpProtocolPrevious, "tools/call", map[string]any{ + "name": "list_phones", + "arguments": map[string]any{"query": mcpSeededPhoneQuery, "limit": 5}, + }) + require.Equal(t, http.StatusOK, callResponse.StatusCode) + require.Nil(t, call.Error, "legacy tools/call failed: %+v", call.Error) + + var called struct { + IsError bool `json:"isError"` + StructuredContent struct { + Count int `json:"count"` + Phones []struct { + PhoneNumber string `json:"phone_number"` + } `json:"phones"` + } `json:"structuredContent"` + } + require.NoError(t, json.Unmarshal(call.Result, &called)) + assert.False(t, called.IsError, "legacy tool call returned a tool error: %s", string(call.Result)) + require.Equal(t, len(called.StructuredContent.Phones), called.StructuredContent.Count) + require.Len(t, called.StructuredContent.Phones, 1, "the seeded phone query must match exactly one phone") + assert.Equal(t, mcpSeededPhoneNumber, called.StructuredContent.Phones[0].PhoneNumber) + }) +} + +// TestMCPToolsThroughRealStack drives every read tool, the send tool, and the +// incoming-message path against the real MCP service, the real httpSMS API, +// and the existing FCM emulator. +func TestMCPToolsThroughRealStack(t *testing.T) { + requireMCPStack(t) + + ctx := context.Background() + tokens := completeOAuthCodeFlow(t, mcpAllScopes) + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest) + + phone := setupPhoneForUser(ctx, t, mcpTestUserAPIKey, 60) + contact := randomPhoneNumber() + + t.Run("list_phones returns the registered phone", func(t *testing.T) { + var output struct { + Phones []struct { + PhoneNumber string `json:"phone_number"` + SIM string `json:"sim"` + } `json:"phones"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, session, "list_phones", map[string]any{"limit": 20}), &output) + + require.Equal(t, len(output.Phones), output.Count) + numbers := make([]string, 0, len(output.Phones)) + for _, listed := range output.Phones { + numbers = append(numbers, listed.PhoneNumber) + } + assert.Contains(t, numbers, phone.PhoneNumber) + }) + + t.Run("send_sms delivers through the FCM emulator", func(t *testing.T) { + var output struct { + Message struct { + ID string `json:"id"` + Status string `json:"status"` + Owner string `json:"owner"` + } `json:"message"` + } + decodeToolOutput(t, callMCPTool(t, session, "send_sms", map[string]any{ + "from": phone.PhoneNumber, + "to": contact, + "content": "Hello from the MCP integration suite", + "request_id": uuid.NewString(), + }), &output) + + require.NotEmpty(t, output.Message.ID) + assert.Equal(t, phone.PhoneNumber, output.Message.Owner) + + waitForFCMPush(t, output.Message.ID, 30*time.Second) + fireEvent(ctx, t, phone.PhoneAPIKey, output.Message.ID, "SENT") + fireEvent(ctx, t, phone.PhoneAPIKey, output.Message.ID, "DELIVERED") + pollMessageStatusAs(ctx, t, mcpTestUserAPIKey, output.Message.ID, "delivered", 30*time.Second) + }) + + t.Run("list_message_threads returns the conversation", func(t *testing.T) { + var output struct { + Threads []struct { + Owner string `json:"owner"` + Contact string `json:"contact"` + } `json:"threads"` + Count int `json:"count"` + } + + found := false + for attempt := 0; attempt < 6 && !found; attempt++ { + if attempt > 0 { + time.Sleep(time.Second) + } + decodeToolOutput(t, callMCPTool(t, session, "list_message_threads", map[string]any{ + "owner": phone.PhoneNumber, + "limit": 20, + }), &output) + + for _, thread := range output.Threads { + if thread.Contact == contact { + assert.Equal(t, phone.PhoneNumber, thread.Owner) + found = true + } + } + } + assert.True(t, found, "thread %s -> %s was not returned by list_message_threads", phone.PhoneNumber, contact) + }) + + t.Run("list_thread_messages returns the sent message", func(t *testing.T) { + var output struct { + Messages []struct { + Content string `json:"content"` + Type string `json:"type"` + } `json:"messages"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, session, "list_thread_messages", map[string]any{ + "owner": phone.PhoneNumber, + "contact": contact, + "limit": 20, + }), &output) + + require.Equal(t, len(output.Messages), output.Count) + contents := make([]string, 0, len(output.Messages)) + for _, message := range output.Messages { + contents = append(contents, message.Content) + } + assert.Contains(t, contents, "Hello from the MCP integration suite") + }) + + t.Run("list_incoming_messages returns received SMS but never missed calls", func(t *testing.T) { + incomingContent := "Inbound " + uuid.NewString() + receiveSMSAs(ctx, t, phone, contact, incomingContent) + reportMissedCallAs(ctx, t, phone, contact) + + type incomingOutput struct { + Messages []struct { + Content string `json:"content"` + Type string `json:"type"` + } `json:"messages"` + Count int `json:"count"` + } + + var output incomingOutput + found := false + for attempt := 0; attempt < 6 && !found; attempt++ { + if attempt > 0 { + time.Sleep(time.Second) + } + decodeToolOutput(t, callMCPTool(t, session, "list_incoming_messages", map[string]any{ + "owners": []string{phone.PhoneNumber}, + "limit": 50, + }), &output) + + for _, message := range output.Messages { + if message.Content == incomingContent { + found = true + } + } + } + require.True(t, found, "the received SMS was not returned by list_incoming_messages") + + for _, message := range output.Messages { + assert.Equal(t, "mobile-originated", message.Type, "list_incoming_messages must only return mobile-originated messages") + assert.NotEqual(t, "Missed phone call", message.Content, "a missed call must never appear in incoming messages") + } + }) +} + +// TestMCPUserDataIsolation asserts every MCP read tool is scoped to the +// authenticated user's own data: the MCP test user sees its seeded phone and +// message thread, while a second, fully isolated user -- authenticated through +// its own complete OAuth flow against the same MCP service -- sees none of it, +// even when it names the other user's phone number explicitly. +// +// The data it reads is seeded and immutable (see seed.sql), so the assertion +// holds on a fresh stack, on a repeated run, when this test runs alone, and in +// any shuffled order. +func TestMCPUserDataIsolation(t *testing.T) { + requireMCPStack(t) + + readScopes := []string{"phones:read", "messages:read"} + + ownerTokens := completeOAuthCodeFlow(t, readScopes) + ownerSession := newMCPClient(t, ownerTokens.AccessToken, mcpProtocolLatest) + + isolatedTokens := completeOAuthCodeFlowAs(t, mcpIsolatedUserID, mcpIsolatedUserEmail, readScopes) + isolatedSession := newMCPClient(t, isolatedTokens.AccessToken, mcpProtocolLatest) + + t.Run("the MCP user sees its seeded phone", func(t *testing.T) { + var output struct { + Phones []struct { + PhoneNumber string `json:"phone_number"` + } `json:"phones"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, ownerSession, "list_phones", map[string]any{"query": mcpSeededPhoneQuery, "limit": 20}), &output) + + require.Equal(t, len(output.Phones), output.Count) + numbers := make([]string, 0, len(output.Phones)) + for _, phone := range output.Phones { + numbers = append(numbers, phone.PhoneNumber) + } + assert.Contains(t, numbers, mcpSeededPhoneNumber) + }) + + t.Run("the MCP user sees its seeded message thread", func(t *testing.T) { + var output struct { + Threads []struct { + Owner string `json:"owner"` + Contact string `json:"contact"` + } `json:"threads"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, ownerSession, "list_message_threads", map[string]any{ + "owner": mcpSeededPhoneNumber, + "limit": 20, + }), &output) + + require.Equal(t, len(output.Threads), output.Count) + contacts := make([]string, 0, len(output.Threads)) + for _, thread := range output.Threads { + assert.Equal(t, mcpSeededPhoneNumber, thread.Owner) + contacts = append(contacts, thread.Contact) + } + assert.Contains(t, contacts, mcpSeededThreadContact) + }) + + t.Run("the isolated user sees no phones at all", func(t *testing.T) { + var output struct { + Phones []struct { + PhoneNumber string `json:"phone_number"` + } `json:"phones"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, isolatedSession, "list_phones", map[string]any{"limit": 20}), &output) + + assert.Empty(t, output.Phones, "the isolated user must never see another user's phones") + assert.Zero(t, output.Count) + }) + + t.Run("the isolated user sees no threads on another user's phone", func(t *testing.T) { + var output struct { + Threads []struct { + Contact string `json:"contact"` + } `json:"threads"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, isolatedSession, "list_message_threads", map[string]any{ + "owner": mcpSeededPhoneNumber, + "limit": 20, + }), &output) + + assert.Empty(t, output.Threads, "naming another user's phone number must never disclose their threads") + assert.Zero(t, output.Count) + }) + + t.Run("the isolated user sees no messages in another user's thread", func(t *testing.T) { + var output struct { + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, isolatedSession, "list_thread_messages", map[string]any{ + "owner": mcpSeededPhoneNumber, + "contact": mcpSeededThreadContact, + "limit": 20, + }), &output) + + assert.Empty(t, output.Messages, "naming another user's thread must never disclose its messages") + assert.Zero(t, output.Count) + }) + + t.Run("the isolated user sees no incoming messages", func(t *testing.T) { + var output struct { + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, isolatedSession, "list_incoming_messages", map[string]any{ + "owners": []string{mcpSeededPhoneNumber}, + "limit": 20, + }), &output) + + assert.Empty(t, output.Messages, "the isolated user must never see another user's incoming messages") + assert.Zero(t, output.Count) + }) +} + +// TestMCPToolScopes asserts a token is only good for the tools its granted +// scopes cover. +func TestMCPToolScopes(t *testing.T) { + requireMCPStack(t) + + tokens := completeOAuthCodeFlow(t, []string{"phones:read"}) + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest) + + t.Run("a granted scope works", func(t *testing.T) { + var output struct { + Phones []struct { + PhoneNumber string `json:"phone_number"` + } `json:"phones"` + Count int `json:"count"` + } + decodeToolOutput(t, callMCPTool(t, session, "list_phones", map[string]any{"query": mcpSeededPhoneQuery, "limit": 5}), &output) + + require.Equal(t, len(output.Phones), output.Count) + numbers := make([]string, 0, len(output.Phones)) + for _, phone := range output.Phones { + numbers = append(numbers, phone.PhoneNumber) + } + assert.Contains(t, numbers, mcpSeededPhoneNumber, "a granted phones:read scope must return the caller's own seeded phone") + }) + + refusals := map[string]struct { + tool string + arguments map[string]any + scope string + }{ + "messages:read": {tool: "list_message_threads", arguments: map[string]any{"owner": "+18005550199", "limit": 5}, scope: "messages:read"}, + "messages:send": {tool: "send_sms", arguments: map[string]any{"from": "+18005550199", "to": "+18005550100", "content": "nope"}, scope: "messages:send"}, + "phone-api-keys:write": { + tool: "create_phone_api_key", + arguments: map[string]any{"name": "should not be created"}, + scope: "phone-api-keys:write", + }, + "user-api-key:rotate": {tool: "rotate_user_api_key", arguments: map[string]any{}, scope: "user-api-key:rotate"}, + } + + for name, refusal := range refusals { + t.Run("a missing "+name+" scope is refused", func(t *testing.T) { + result := callMCPTool(t, session, refusal.tool, refusal.arguments) + require.True(t, result.IsError, "%s must be refused without the %s scope", refusal.tool, refusal.scope) + assert.Contains(t, toolResultText(result), refusal.scope) + }) + } +} + +// TestMCPDelegationTokenBinding asserts the httpSMS API enforces the exact +// method, path, audience, issuer, and scope every MCP delegation token is +// minted for. It signs delegation tokens with the same key the MCP service +// uses, which is the only way to present the API with a token that is valid +// but wrongly bound. +func TestMCPDelegationTokenBinding(t *testing.T) { + requireMCPStack(t) + + t.Run("a correctly bound token is accepted", func(t *testing.T) { + token := signAPIDelegationToken(t, mcpTestUserID, []string{"phones:read"}, http.MethodGet, "/v1/phones") + + status, body := apiRequestWithBearer(t, http.MethodGet, "/v1/phones?skip=0&limit=10", token) + require.Equal(t, http.StatusOK, status, body) + }) + + t.Run("a token bound to another path is refused", func(t *testing.T) { + token := signAPIDelegationToken(t, mcpTestUserID, []string{"phones:read", "messages:read"}, http.MethodGet, "/v1/phones") + + status, body := apiRequestWithBearer(t, http.MethodGet, "/v1/messages/incoming?skip=0&limit=10", token) + assert.Equal(t, http.StatusForbidden, status, body) + assert.Contains(t, body, "MCP token cannot access this API operation") + }) + + t.Run("a token bound to a route outside the MCP catalog is refused", func(t *testing.T) { + token := signAPIDelegationToken(t, mcpTestUserID, []string{"messages:read"}, http.MethodGet, "/v1/messages/search") + + status, body := apiRequestWithBearer(t, http.MethodGet, "/v1/messages/search?skip=0&limit=10", token) + assert.Equal(t, http.StatusForbidden, status, body) + }) + + t.Run("a token missing the operation's scope is refused", func(t *testing.T) { + token := signAPIDelegationToken(t, mcpTestUserID, []string{"messages:read"}, http.MethodGet, "/v1/phones") + + status, body := apiRequestWithBearer(t, http.MethodGet, "/v1/phones?skip=0&limit=10", token) + assert.Equal(t, http.StatusForbidden, status, body) + }) + + t.Run("a wrong audience or issuer is not authenticated at all", func(t *testing.T) { + cases := map[string]func(jwt.MapClaims){ + "wrong audience": func(claims jwt.MapClaims) { claims["aud"] = "https://api.example.com" }, + "wrong issuer": func(claims jwt.MapClaims) { claims["iss"] = "https://evil.example.com" }, + "expired": func(claims jwt.MapClaims) { claims["exp"] = time.Now().Add(-time.Hour).Unix() }, + } + + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + token := signAPIDelegationToken(t, mcpTestUserID, []string{"phones:read"}, http.MethodGet, "/v1/phones", mutate) + + status, body := apiRequestWithBearer(t, http.MethodGet, "/v1/phones?skip=0&limit=10", token) + assert.Equal(t, http.StatusUnauthorized, status, body) + }) + } + }) +} + +// TestMCPIncomingIsNotCaptchaProtected asserts the design decision behind +// list_incoming_messages: the incoming route is reachable with a delegated MCP +// token, while the CAPTCHA-protected search route stays protected. +func TestMCPIncomingIsNotCaptchaProtected(t *testing.T) { + requireMCPStack(t) + + t.Run("the incoming route serves a delegated MCP token", func(t *testing.T) { + token := signAPIDelegationToken(t, mcpTestUserID, []string{"messages:read"}, http.MethodGet, "/v1/messages/incoming") + + status, body := apiRequestWithBearer(t, http.MethodGet, "/v1/messages/incoming?skip=0&limit=10", token) + require.Equal(t, http.StatusOK, status, redactSecrets(body)) + }) + + t.Run("the search route still requires a CAPTCHA token", func(t *testing.T) { + status, body := apiRequestWithAPIKey(t, http.MethodGet, "/v1/messages/search?skip=0&limit=10", mcpTestUserAPIKey) + require.Equal(t, http.StatusUnprocessableEntity, status, redactSecrets(body)) + assert.Contains(t, body, "token") + }) +} + +// TestMCPCreatePhoneAPIKey asserts create_phone_api_key mints a real, +// immediately usable phone API key and returns it exactly once, marked +// sensitive. +// +// It authenticates as the dedicated mcpPhoneAPIKeyUserID rather than the +// shared mcpTestUserID: create_phone_api_key's rate-limit bucket is spent by +// this test's one real call *and* by TestMCPToolScopes's missing-scope +// refusal of the same tool (the limiter charges every call before a tool +// handler ever inspects scope), so sharing mcpTestUserID's bucket between +// them would halve how many repeated full-suite runs this test tolerates +// within the hourly window. +func TestMCPCreatePhoneAPIKey(t *testing.T) { + requireMCPStack(t) + + ctx := context.Background() + tokens := completeOAuthCodeFlowAs(t, mcpPhoneAPIKeyUserID, mcpPhoneAPIKeyUserEmail, []string{"phone-api-keys:write", "phones:read"}) + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest) + + var output struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"api_key"` + Sensitive bool `json:"sensitive"` + } + result := callMCPTool(t, session, "create_phone_api_key", map[string]any{"name": "mcp-integration-" + uuid.NewString()}) + decodeToolOutput(t, result, &output) + + require.NotEmpty(t, output.ID) + assert.True(t, output.Sensitive, "a freshly minted key must be marked sensitive") + assert.True(t, strings.HasPrefix(output.APIKey, "pk_"), "phone API key %q must carry the pk_ prefix", firstChars(output.APIKey, 3)) + assert.Contains(t, toolResultText(result), "will not be shown again") + + // The minted key must actually authenticate a phone against the API. + phoneNumber := randomPhoneNumber() + fcmToken := "fcm-" + uuid.NewString() + requestJSONAs(ctx, t, http.MethodPut, "/v1/phones", mcpPhoneAPIKeyUserAPIKey, map[string]any{ + "phone_number": phoneNumber, + "fcm_token": fcmToken, + "messages_per_minute": 60, + "max_send_attempts": 2, + "message_expiration_seconds": 600, + "sim": "SIM1", + }, http.StatusOK, nil) + + // Binding the FCM token with the freshly minted key is what associates + // the key with this phone, exactly as the Android app does on setup. + requestJSONAs(ctx, t, http.MethodPut, "/v1/phones/fcm-token", output.APIKey, map[string]any{ + "phone_number": phoneNumber, + "fcm_token": fcmToken, + "sim": "SIM1", + }, http.StatusOK, nil) + + waitForPhoneAuthorization(ctx, t, output.APIKey, phoneNumber, 20*time.Second) + + t.Run("the secret never reaches the service logs", func(t *testing.T) { + logs, ok := mcpContainerLogs(t) + if !ok { + t.Skip("the Docker CLI is unavailable") + } + assertSecretNotLogged(t, logs, output.APIKey, "a minted phone API key") + assertSecretNotLogged(t, logs, tokens.AccessToken, "an access token") + assertSecretNotLogged(t, logs, tokens.RefreshToken, "a refresh token") + }) +} + +// mcpKeyCreatesPerHour mirrors KEY_CREATES_PER_HOUR in tests/docker-compose.yml. +// It is the exact per-user hourly budget for create_phone_api_key, so the call +// after it must be refused. The budget is deliberately generous: the hourly +// window it is counted in outlives a test run, so a budget small enough to +// exhaust quickly would also be small enough for TestMCPCreatePhoneAPIKey's one +// call per run to exhaust after a handful of repeated runs. This test spends +// its own brand-new user's budget instead, so a large number costs it only a +// couple of seconds. +const mcpKeyCreatesPerHour = 40 + +// TestMCPRateLimit asserts the per-user, per-tool budget is enforced before a +// tool executes, and that the rejection carries a retry hint. +// +// Every attempt authenticates as a brand-new Firebase UID, so the budget it +// exhausts is always its own untouched one: the test can never starve another +// test's tools, and it survives repeated runs against the same live stack +// without an hour-long wait or a Redis reset. Because the budget is counted in +// fixed UTC hour windows, the test also refuses to start with less than half a +// minute left in the current window and retries in a fresh one if the hour +// still rolls over mid-sequence -- the assertion itself is never relaxed. +func TestMCPRateLimit(t *testing.T) { + requireMCPStack(t) + + var ( + rateLimitErr error + calls int + completed bool + ) + + for round := 1; round <= 3 && !completed; round++ { + waitForRateLimitWindowHeadroom(t) + + windowStart := currentRateLimitWindow() + rateLimitErr, calls = exhaustKeyCreateBudget(t) + + if !currentRateLimitWindow().Equal(windowStart) { + t.Logf("the UTC hour rolled over during round %d; retrying the whole sequence in a fresh window", round) + continue + } + completed = true + } + + require.True(t, completed, "the UTC hour rolled over on every attempt") + require.Error(t, rateLimitErr, "call %d must be rate limited once the hourly budget of %d is spent", mcpKeyCreatesPerHour+1, mcpKeyCreatesPerHour) + assert.Equal(t, mcpKeyCreatesPerHour+1, calls, "the budget of %d creates per hour must not be exhausted early", mcpKeyCreatesPerHour) + assert.Contains(t, rateLimitErr.Error(), "rate limit exceeded") + + var wireError *jsonrpc.Error + require.True(t, errors.As(rateLimitErr, &wireError), "a rate-limit rejection must be a structured JSON-RPC error") + assert.EqualValues(t, -32029, wireError.Code) + + var data struct { + Tool string `json:"tool"` + RetryAfterSeconds int `json:"retry_after_seconds"` + } + require.NoError(t, json.Unmarshal(wireError.Data, &data)) + assert.Equal(t, "create_phone_api_key", data.Tool) + assert.Positive(t, data.RetryAfterSeconds) +} + +// exhaustKeyCreateBudget calls create_phone_api_key as a brand-new user until +// a call is refused, or until the budget plus one call have all succeeded. It +// returns the refusal (nil when nothing was refused) and how many calls were +// made, so the caller can assert the refusal happened on exactly the call +// after the budget. +// +// The user is seeded into CockroachDB (see seedRateLimitUser) before any +// call: create_phone_api_key's delegated token is only honored for a subject +// the API can load a real users row for, so an unseeded brand-new UID would +// fail every attempt with 401 -- consuming the whole rate-limit budget +// without ever minting a key, and only surfacing the *first* budget-exhausted +// call at the very end as a false pass. Every pre-budget call is asserted to +// have actually succeeded (never a tool-level error), and the number of +// phone_api_keys rows the seeded user ends up owning is asserted to match +// exactly, so a regression back to that failure mode is caught immediately +// rather than papered over by the rate limiter's own eventual rejection. +func exhaustKeyCreateBudget(t *testing.T) (error, int) { + t.Helper() + + userID, _ := seedRateLimitUser(t) + tokens := completeOAuthCodeFlowAs(t, userID, userID+"@httpsms.com", []string{"phone-api-keys:write"}) + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest) + + succeeded := 0 + for attempt := 1; attempt <= mcpKeyCreatesPerHour+1; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "create_phone_api_key", + Arguments: map[string]any{"name": fmt.Sprintf("rate-limit-%d-%s", attempt, uuid.NewString())}, + }) + cancel() + + if err != nil { + assert.Equal(t, mcpKeyCreatesPerHour, succeeded, "exactly %d calls must have succeeded before call %d is rate limited", mcpKeyCreatesPerHour, attempt) + assert.Equal(t, succeeded, countPhoneAPIKeys(t, userID), "every pre-budget create_phone_api_key call must have actually persisted a phone API key row for %s, not just reported success", userID) + return err, attempt + } + + require.NotNil(t, result, "call %d must return a result", attempt) + require.False(t, result.IsError, "call %d of the %d-call budget must succeed, not fail with a tool-level error: %s", attempt, mcpKeyCreatesPerHour, toolResultText(result)) + succeeded++ + } + + assert.Equal(t, succeeded, countPhoneAPIKeys(t, userID), "every create_phone_api_key call must have actually persisted a phone API key row for %s, not just reported success", userID) + + return nil, mcpKeyCreatesPerHour + 1 +} + +// TestMCPRotateUserAPIKey asserts the destructive rotation path end to end: +// an unconfirmed call never rotates, a legacy confirmation handle completes +// the rotation exactly once, the previous primary key stops working, the +// replacement key works, and a redeemed handle can never be replayed. +// +// It authenticates as the dedicated rotation user, which is the only user +// whose primary API key any test ever rotates, and it never assumes the +// seeded key is still current: it establishes a key it knows the value of by +// rotating once first. That is what makes it independent of test order and of +// how many times the suite has already run against this stack. +func TestMCPRotateUserAPIKeyLegacyConfirmation(t *testing.T) { + requireMCPStack(t) + + tokens := completeOAuthCodeFlowAs(t, mcpRotationUserID, mcpRotationUserEmail, []string{"user-api-key:rotate"}) + + previousAPIKey := establishRotationUserAPIKey(t, tokens) + + // MRTR is disabled so the confirmation prompt is returned to the test + // verbatim, which is exactly what a legacy client that cannot complete an + // elicitation sees. + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest, func(options *mcp.ClientOptions) { + options.MultiRoundTrip = &mcp.MultiRoundTripOptions{Disabled: true} + }) + + prompt := callMCPTool(t, session, "rotate_user_api_key", map[string]any{}) + require.False(t, prompt.IsError, "the first call must ask for confirmation, not fail: %s", toolResultText(prompt)) + require.NotEmpty(t, prompt.RequestState, "the first call must return a confirmation handle") + require.Contains(t, prompt.InputRequests, "confirm_rotation") + assert.Nil(t, prompt.StructuredContent, "an unconfirmed call must never rotate anything") + + assertAPIKeyAccepted(t, previousAPIKey, "the primary API key must survive an unconfirmed call") + + handle := prompt.RequestState + + var rotated struct { + User struct { + ID string `json:"id"` + APIKey string `json:"api_key"` + } `json:"user"` + Sensitive bool `json:"sensitive"` + Warning string `json:"warning"` + } + confirmed := callMCPTool(t, session, "rotate_user_api_key", map[string]any{"confirmation_handle": handle}) + decodeToolOutput(t, confirmed, &rotated) + + require.Equal(t, mcpRotationUserID, rotated.User.ID) + require.NotEmpty(t, rotated.User.APIKey) + assert.True(t, strings.HasPrefix(rotated.User.APIKey, "uk_"), "a rotated primary key must carry the uk_ prefix") + assert.False(t, rotated.User.APIKey == previousAPIKey, "the rotated primary key must differ from the previous one") + assert.True(t, rotated.Sensitive) + assert.Contains(t, rotated.Warning, "invalidated") + + assertAPIKeyRejected(t, previousAPIKey, "the previous primary API key must stop working") + assertAPIKeyAccepted(t, rotated.User.APIKey, "the replacement primary API key must work") + + t.Run("a redeemed confirmation handle cannot be replayed", func(t *testing.T) { + replay := callMCPTool(t, session, "rotate_user_api_key", map[string]any{"confirmation_handle": handle}) + require.True(t, replay.IsError, "a redeemed confirmation handle must be refused") + assert.Contains(t, toolResultText(replay), "confirmation") + + assertAPIKeyAccepted(t, rotated.User.APIKey, "a refused replay must never rotate anything") + }) + + t.Run("an unknown confirmation handle is refused", func(t *testing.T) { + unknown := callMCPTool(t, session, "rotate_user_api_key", map[string]any{"confirmation_handle": uuid.NewString()}) + require.True(t, unknown.IsError) + assert.Contains(t, toolResultText(unknown), "confirmation") + }) +} + +// TestMCPRotateUserAPIKeyMRTRConfirmation asserts the modern multi-round-trip +// confirmation path: an MRTR-capable client fulfills the elicitation and the +// rotation completes in a single CallTool, while a declined elicitation never +// rotates anything. +func TestMCPRotateUserAPIKeyMRTRConfirmation(t *testing.T) { + requireMCPStack(t) + + tokens := completeOAuthCodeFlowAs(t, mcpRotationUserID, mcpRotationUserEmail, []string{"user-api-key:rotate"}) + + previousAPIKey := establishRotationUserAPIKey(t, tokens) + + t.Run("a declined elicitation never rotates", func(t *testing.T) { + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest, func(options *mcp.ClientOptions) { + options.ElicitationHandler = func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "decline"}, nil + } + }) + + result := callMCPTool(t, session, "rotate_user_api_key", map[string]any{}) + require.True(t, result.IsError, "a declined confirmation must refuse the rotation") + assert.Contains(t, strings.ToLower(toolResultText(result)), "confirm") + + assertAPIKeyAccepted(t, previousAPIKey, "a declined confirmation must leave the primary API key intact") + }) + + t.Run("an accepted elicitation rotates exactly once", func(t *testing.T) { + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest, func(options *mcp.ClientOptions) { + options.ElicitationHandler = func(_ context.Context, request *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + assert.Contains(t, request.Params.Message, "invalidates") + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirmed": true}}, nil + } + }) + + var rotated struct { + User struct { + ID string `json:"id"` + APIKey string `json:"api_key"` + } `json:"user"` + Sensitive bool `json:"sensitive"` + } + decodeToolOutput(t, callMCPTool(t, session, "rotate_user_api_key", map[string]any{}), &rotated) + + require.Equal(t, mcpRotationUserID, rotated.User.ID) + require.True(t, strings.HasPrefix(rotated.User.APIKey, "uk_")) + assert.False(t, rotated.User.APIKey == previousAPIKey, "the rotated primary key must differ from the previous one") + assert.True(t, rotated.Sensitive) + + assertAPIKeyRejected(t, previousAPIKey, "the previous primary API key must stop working") + assertAPIKeyAccepted(t, rotated.User.APIKey, "the replacement primary API key must work") + + t.Run("the rotated secret never reaches the service logs", func(t *testing.T) { + logs, ok := mcpContainerLogs(t) + if !ok { + t.Skip("the Docker CLI is unavailable") + } + assertSecretNotLogged(t, logs, rotated.User.APIKey, "a rotated primary API key") + assertSecretNotLogged(t, logs, tokens.AccessToken, "an access token") + assertSecretNotLogged(t, logs, tokens.RefreshToken, "a refresh token") + }) + }) +} + +// establishRotationUserAPIKey rotates the rotation user's primary API key once +// through an accepted MRTR elicitation and returns the brand-new key. +// +// It is how every rotation test starts from a primary key whose value it +// knows, without depending on the seeded key still being current: the seeded +// key is only ever valid until the first rotation of the first run, so a suite +// that assumed it would fail on every subsequent run. Rotating to establish +// the baseline also proves the tool works before the test's own assertions +// begin, so a failure here is unambiguous. +func establishRotationUserAPIKey(t *testing.T, tokens tokenResponse) string { + t.Helper() + + session := newMCPClient(t, tokens.AccessToken, mcpProtocolLatest, func(options *mcp.ClientOptions) { + options.ElicitationHandler = func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirmed": true}}, nil + } + }) + + var established struct { + User struct { + ID string `json:"id"` + APIKey string `json:"api_key"` + } `json:"user"` + } + decodeToolOutput(t, callMCPTool(t, session, "rotate_user_api_key", map[string]any{}), &established) + + require.Equal(t, mcpRotationUserID, established.User.ID) + require.True(t, strings.HasPrefix(established.User.APIKey, "uk_"), "an established primary key must carry the uk_ prefix") + assertAPIKeyAccepted(t, established.User.APIKey, "the established primary API key must work") + + return established.User.APIKey +} + +// firstChars returns at most n characters of value, for error messages that +// must never echo a whole secret. +func firstChars(value string, n int) string { + if len(value) <= n { + return value + } + return value[:n] +} diff --git a/tests/seed.sql b/tests/seed.sql index 36d714d9..28df886b 100644 --- a/tests/seed.sql +++ b/tests/seed.sql @@ -25,6 +25,107 @@ VALUES ( NOW() ) ON CONFLICT (id) DO NOTHING; +-- MCP integration test user. Every functional MCP OAuth flow authenticates as +-- this Firebase UID. Its primary API key is never rotated by any test, so the +-- suite can be re-run against a live stack without re-seeding. +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'mcp-test-user-id', + 'mcp-test@httpsms.com', + 'mcp-test-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Dedicated user for TestMCPCreatePhoneAPIKey. It is the ONLY user that test +-- authenticates as, so its one create_phone_api_key call per run never +-- shares a rate-limit bucket with TestMCPToolScopes's missing-scope refusal +-- of the same tool against 'mcp-test-user-id'. +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'mcp-phone-api-key-user-id', + 'mcp-phone-api-key@httpsms.com', + 'mcp-phone-api-key-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Dedicated user for the MCP API-key rotation tests. It is the ONLY user whose +-- primary API key is ever rotated, so rotation can never invalidate a key any +-- other test authenticates with. The rotation tests never assume the seeded +-- value below is still current: they always derive the current key by rotating +-- once first, which is what makes the suite re-runnable without a reset. +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'mcp-rotation-user-id', + 'mcp-rotation@httpsms.com', + 'mcp-rotation-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Dedicated user for the MCP user-data isolation assertions. Nothing in the +-- suite ever creates a phone, thread, or message for this user, so "this user +-- sees nothing" is a stable, meaningful assertion on every run. +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'mcp-rate-limit-user-id', + 'mcp-rate-limit@httpsms.com', + 'mcp-rate-limit-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Immutable seeded MCP data owned by 'mcp-test-user-id'. No test ever mutates +-- or deletes these two rows: they are what the MCP user-data isolation test +-- asserts the MCP user can see and the isolated user cannot, and what the +-- suite's preflight validates before running any MCP test. The +1888555xxxx +-- range can never collide with the +1800555xxxx numbers randomPhoneNumber() +-- generates at runtime. +INSERT INTO phones ( + id, user_id, phone_number, messages_per_minute, sim, + max_send_attempts, message_expiration_seconds, unarchive_thread, + created_at, updated_at +) VALUES ( + 'a1b2c3d4-0000-4000-8000-000000000001', + 'mcp-test-user-id', + '+18885550101', + 60, + 'SIM1', + 2, + 600, + false, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO message_threads ( + id, user_id, owner, contact, is_archived, unread_count, + color, status, last_message_content, + created_at, updated_at, order_timestamp +) VALUES ( + 'a1b2c3d4-0000-4000-8000-000000000002', + 'mcp-test-user-id', + '+18885550101', + '+18885550202', + false, + 0, + 'indigo', + 'received', + 'Seeded MCP integration thread', + NOW(), + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + -- System user (for event queue auth) INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) VALUES (