From b00480cdbc23c43790e866cd65194800ff8a2162 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 19:56:30 +0300 Subject: [PATCH 01/25] docs(mcp): design hosted MCP server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-09-03-httpsms-mcp-server-design.md | 497 ++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-03-httpsms-mcp-server-design.md 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. From 4c99540a1ffe0030160b27d3b7addf319bc98811 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 20:05:31 +0300 Subject: [PATCH 02/25] docs(mcp): add implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plans/2026-09-03-httpsms-mcp-server.md | 1747 +++++++++++++++++ 1 file changed, 1747 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-httpsms-mcp-server.md 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. From 8a5a4c8250d8590778da6824520b7a6e3fd8854d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 20:23:39 +0300 Subject: [PATCH 03/25] feat(api): trust scoped MCP tokens Add delegated MCP-to-API JWT authentication: a route-, scope-, and subject-bound token verifier (pkg/auth) and a Fiber middleware (MCPDelegationAuth) that loads the existing user auth context from the token's Firebase-UID subject when the token is valid for the exact method/path/scope of the request. MCPDelegationAuth is registered before BearerAuth so a cryptographically valid but insufficiently-scoped or misbound MCP token is rejected with 403 directly, instead of falling through to Firebase ID token verification. BearerAuth now short-circuits when a prior middleware has already populated the auth context, and no longer logs the raw bearer token on verification failure. The verifier is wired into the DI container from MCP_AUTH_ISSUER, MCP_AUTH_AUDIENCE, and MCP_AUTH_JWKS_URL; it is disabled when all three are empty and container construction fails fast when only some are set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- api/pkg/auth/mcp_claims.go | 21 ++ api/pkg/auth/mcp_jwks.go | 181 ++++++++++ api/pkg/auth/mcp_token_verifier.go | 185 +++++++++++ api/pkg/auth/mcp_token_verifier_test.go | 308 ++++++++++++++++++ api/pkg/di/config.go | 33 ++ api/pkg/di/config_test.go | 73 +++++ api/pkg/di/container.go | 29 ++ api/pkg/middlewares/bearer_auth_middleware.go | 9 +- .../bearer_auth_middleware_test.go | 65 ++++ .../mcp_delegation_auth_middleware.go | 75 +++++ .../mcp_delegation_auth_middleware_test.go | 226 +++++++++++++ 11 files changed, 1204 insertions(+), 1 deletion(-) create mode 100644 api/pkg/auth/mcp_claims.go create mode 100644 api/pkg/auth/mcp_jwks.go create mode 100644 api/pkg/auth/mcp_token_verifier.go create mode 100644 api/pkg/auth/mcp_token_verifier_test.go create mode 100644 api/pkg/di/config_test.go create mode 100644 api/pkg/middlewares/bearer_auth_middleware_test.go create mode 100644 api/pkg/middlewares/mcp_delegation_auth_middleware.go create mode 100644 api/pkg/middlewares/mcp_delegation_auth_middleware_test.go 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..2a45f519 --- /dev/null +++ b/api/pkg/auth/mcp_jwks.go @@ -0,0 +1,181 @@ +package auth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "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 +) + +// 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". It refreshes the cache once when a requested "kid" +// cannot be found, and otherwise refreshes only after CacheTTL has elapsed. +type mcpJWKSCache struct { + url string + httpClient *http.Client + cacheTTL time.Duration + + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +// newMCPJWKSCache creates a new mcpJWKSCache for the given JWKS URL. +func newMCPJWKSCache(url string, httpClient *http.Client, cacheTTL 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 + } + + return &mcpJWKSCache{ + url: url, + httpClient: client, + cacheTTL: cacheTTL, + keys: map[string]*rsa.PublicKey{}, + } +} + +// key returns the cached RSA public key for kid, refreshing the JWKS document +// at most once per call when the cache is stale or the key is not yet known. +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.refresh(ctx); err != 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 +} + +// 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_token_verifier.go b/api/pkg/auth/mcp_token_verifier.go new file mode 100644 index 00000000..0d438265 --- /dev/null +++ b/api/pkg/auth/mcp_token_verifier.go @@ -0,0 +1,185 @@ +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 +} + +// 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), + }, 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) { + 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 +} + +// 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..b004376e --- /dev/null +++ b/api/pkg/auth/mcp_token_verifier_test.go @@ -0,0 +1,308 @@ +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, + }) + 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 ebe57662..96a2649c 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/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") + } +} From 97d978678c6c0e5e044ea37367d701df1354cde9 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 20:34:05 +0300 Subject: [PATCH 04/25] feat(api): add incoming message endpoint Add GET /v1/messages/incoming, scoped to messages:read, that reuses MessageService.SearchMessages while forcing types=[mobile-originated]. The endpoint has no CAPTCHA requirement, unlike /v1/messages/search which remains unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- api/docs/docs.go | 101 ++++++++++++ api/docs/swagger.json | 101 ++++++++++++ api/docs/swagger.yaml | 69 +++++++++ api/pkg/handlers/message_handler.go | 45 ++++++ .../handlers/message_handler_incoming_test.go | 145 ++++++++++++++++++ api/pkg/requests/message_incoming_request.go | 67 ++++++++ .../requests/message_incoming_request_test.go | 47 ++++++ .../validators/message_handler_validator.go | 38 +++++ .../message_handler_validator_test.go | 49 ++++++ 9 files changed, 662 insertions(+) create mode 100644 api/pkg/handlers/message_handler_incoming_test.go create mode 100644 api/pkg/requests/message_incoming_request.go create mode 100644 api/pkg/requests/message_incoming_request_test.go create mode 100644 api/pkg/validators/message_handler_validator_test.go diff --git a/api/docs/docs.go b/api/docs/docs.go index 96cf0e55..f8b7b0d9 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 4926b5e5..2c923f9e 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 9cfefbf5..9a273e00 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -2916,6 +2916,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/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/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")) +} From bf06243e90b9402c19b887045fcfac99ef9011c9 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 20:51:44 +0300 Subject: [PATCH 05/25] feat(mcp): add service foundation Bootstrap the mcp/ Go module: validated config.Load(), RSA KeySet signing/JWKS (auth.NewKeySet/SignMCPAccessToken/SignAPIDelegationToken/ JWKS), and an observability.New() logging/tracing bootstrap. API delegation tokens carry JSON fields scopes, http_method, and http_path, matching api/pkg/auth.MCPClaims exactly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/go.mod | 75 +++++ mcp/go.sum | 173 ++++++++++ mcp/internal/auth/claims.go | 55 ++++ mcp/internal/auth/keys.go | 250 +++++++++++++++ mcp/internal/auth/keys_test.go | 275 ++++++++++++++++ mcp/internal/config/config.go | 339 ++++++++++++++++++++ mcp/internal/config/config_test.go | 312 ++++++++++++++++++ mcp/internal/observability/observability.go | 100 ++++++ 8 files changed, 1579 insertions(+) create mode 100644 mcp/go.mod create mode 100644 mcp/go.sum create mode 100644 mcp/internal/auth/claims.go create mode 100644 mcp/internal/auth/keys.go create mode 100644 mcp/internal/auth/keys_test.go create mode 100644 mcp/internal/config/config.go create mode 100644 mcp/internal/config/config_test.go create mode 100644 mcp/internal/observability/observability.go diff --git a/mcp/go.mod b/mcp/go.mod new file mode 100644 index 00000000..1db555d6 --- /dev/null +++ b/mcp/go.mod @@ -0,0 +1,75 @@ +module github.com/NdoleStudio/httpsms/mcp + +go 1.25.0 + +require ( + firebase.google.com/go v3.13.0+incompatible + github.com/golang-jwt/jwt/v5 v5.3.1 + 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.71.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 ( + cel.dev/expr v0.25.2 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.23.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/firestore v1.25.0 // indirect + cloud.google.com/go/iam v1.12.0 // indirect + cloud.google.com/go/longrunning v1.2.0 // indirect + cloud.google.com/go/monitoring v1.30.0 // indirect + cloud.google.com/go/storage v1.66.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect + github.com/googleapis/gax-go/v2 v2.24.0 // 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // 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/sdk/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/crypto v0.55.0 // 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/api v0.297.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // 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..a8e370a3 --- /dev/null +++ b/mcp/go.sum @@ -0,0 +1,173 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo= +cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/firestore v1.25.0 h1:yY3rQKyQXNhnhETdseNayF6W1p4x0bdg9ZYS4hKJfOw= +cloud.google.com/go/firestore v1.25.0/go.mod h1:0PU6hj+r/QlhB6BLsRX+Kt/SYefTXrpYrBeHbYaSis8= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= +cloud.google.com/go/storage v1.66.0 h1:HwYx7m9Md/rzphAFshUeAWS3hNFsJQTgFrAu4RIRwpg= +cloud.google.com/go/storage v1.66.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM= +firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= +firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +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/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +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-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +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/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk= +github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg= +github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM= +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/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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +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/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +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/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE= +google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +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.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +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/keys.go b/mcp/internal/auth/keys.go new file mode 100644 index 00000000..bf6301be --- /dev/null +++ b/mcp/internal/auth/keys.go @@ -0,0 +1,250 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "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"` +} + +// 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 they are plain exported +// fields the caller sets after construction rather than constructor +// parameters. Signing methods use whatever value is set at call time. +type KeySet struct { + // Issuer is used as the `iss` claim for every token this KeySet mints. + // The wire contract with the httpSMS API requires this to be the MCP + // service's base URL (MCP_BASE_URL). + Issuer string + + // MCPAudience is the `aud` claim for MCP access tokens, e.g. + // "https://mcp.httpsms.com/mcp". + MCPAudience string + + // APIAudience is the `aud` claim for API delegation tokens, e.g. + // "https://api.httpsms.com". This must match the httpSMS API's + // configured API_AUDIENCE. + APIAudience string + + privateKey *rsa.PrivateKey + keyID string +} + +// 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 +} + +// 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 keys.MCPAudience and must never be accepted by +// the httpSMS API. +func (keys *KeySet) SignMCPAccessToken(principal Principal, clientID string, scopes []string, ttl time.Duration) (string, error) { + claims, err := keys.baseClaims(principal, keys.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 keys.APIAudience. +// +// The resulting JWT carries JSON fields `scopes`, `http_method`, and +// `http_path`; issuer keys.Issuer; audience keys.APIAudience; 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") + } + + claims, err := keys.baseClaims(principal, keys.APIAudience, scopes, ttl) + if err != nil { + return "", err + } + claims.Method = method + claims.Path = path + + return keys.sign(claims) +} + +// baseClaims builds the claims shared by every token this KeySet mints. +func (keys *KeySet) baseClaims(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 keys.Issuer == "" { + return nil, errors.New("auth: KeySet.Issuer must be set before signing tokens") + } + if audience == "" { + return nil, errors.New("auth: token audience 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: keys.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..ec31cf08 --- /dev/null +++ b/mcp/internal/auth/keys_test.go @@ -0,0 +1,275 @@ +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 set, 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) + + keys.Issuer = testMCPIssuer + keys.MCPAudience = testMCPAudience + keys.APIAudience = 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 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 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/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/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 +} From 043f66f37714fee70baed5f02507d04d48856eb4 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 21:05:41 +0300 Subject: [PATCH 06/25] fix(mcp): drop firebase SDK, make KeySet config one-shot Review round 1 fixes for Task 3: - Remove the unused firebase.google.com/go dependency and its entire transitive graph. It pulled a Go 1.26 floor via a transitive dep while this module must stay on Go 1.25; the approved Task 5 design uses a custom Firebase certificate/JWT verifier instead of the Admin SDK. go.mod remains `go 1.25.0`; the still-needed-later pins (modelcontextprotocol/go-sdk v1.7.0, redis/go-redis/v9, otelhttp) are preserved per the multi-task plan. - Replace KeySet's exported, freely-mutable Issuer/MCPAudience/APIAudience fields with a private atomic.Pointer[keySetConfig] published exactly once via a new Configure(issuer, mcpAudience, apiAudience string) error. Configure rejects empty values and a second call; signing methods fail closed until Configure has succeeded. Publishing the whole config behind a single CompareAndSwap (rather than a bool flag written before the fields) avoids a visibility race where a reader could see "configured" before the fields were set. - Add tests: unconfigured signing rejected, successful configuration, reconfiguration rejection (with slot-not-consumed-by-invalid-call and original-values-preserved checks), and a concurrent-Configure-calls test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/go.mod | 34 ---------- mcp/go.sum | 97 --------------------------- mcp/internal/auth/keys.go | 119 ++++++++++++++++++++++++--------- mcp/internal/auth/keys_test.go | 109 ++++++++++++++++++++++++++++-- 4 files changed, 191 insertions(+), 168 deletions(-) diff --git a/mcp/go.mod b/mcp/go.mod index 1db555d6..60a8befb 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -3,7 +3,6 @@ module github.com/NdoleStudio/httpsms/mcp go 1.25.0 require ( - firebase.google.com/go v3.13.0+incompatible github.com/golang-jwt/jwt/v5 v5.3.1 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/redis/go-redis/v9 v9.21.0 @@ -16,58 +15,25 @@ require ( ) require ( - cel.dev/expr v0.25.2 // indirect - cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.23.2 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/firestore v1.25.0 // indirect - cloud.google.com/go/iam v1.12.0 // indirect - cloud.google.com/go/longrunning v1.2.0 // indirect - cloud.google.com/go/monitoring v1.30.0 // indirect - cloud.google.com/go/storage v1.66.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect - github.com/googleapis/gax-go/v2 v2.24.0 // 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // 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/sdk/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/crypto v0.55.0 // 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/api v0.297.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // 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 diff --git a/mcp/go.sum b/mcp/go.sum index a8e370a3..5dbe3003 100644 --- a/mcp/go.sum +++ b/mcp/go.sum @@ -1,46 +1,9 @@ -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= -cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo= -cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/firestore v1.25.0 h1:yY3rQKyQXNhnhETdseNayF6W1p4x0bdg9ZYS4hKJfOw= -cloud.google.com/go/firestore v1.25.0/go.mod h1:0PU6hj+r/QlhB6BLsRX+Kt/SYefTXrpYrBeHbYaSis8= -cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= -cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= -cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= -cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= -cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= -cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= -cloud.google.com/go/storage v1.66.0 h1:HwYx7m9Md/rzphAFshUeAWS3hNFsJQTgFrAu4RIRwpg= -cloud.google.com/go/storage v1.66.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM= -firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= -firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= 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/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= -github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= 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-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 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= @@ -48,21 +11,12 @@ 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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk= -github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= -github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg= -github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM= 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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -71,23 +25,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= -github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= -go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= @@ -112,62 +57,20 @@ 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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE= -google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= -google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= 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.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= 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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 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/keys.go b/mcp/internal/auth/keys.go index bf6301be..b447794f 100644 --- a/mcp/internal/auth/keys.go +++ b/mcp/internal/auth/keys.go @@ -10,6 +10,7 @@ import ( "encoding/pem" "errors" "fmt" + "sync/atomic" "time" "github.com/golang-jwt/jwt/v5" @@ -36,31 +37,40 @@ 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 they are plain exported -// fields the caller sets after construction rather than constructor -// parameters. Signing methods use whatever value is set at call time. +// 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 { - // Issuer is used as the `iss` claim for every token this KeySet mints. - // The wire contract with the httpSMS API requires this to be the MCP - // service's base URL (MCP_BASE_URL). - Issuer string - - // MCPAudience is the `aud` claim for MCP access tokens, e.g. - // "https://mcp.httpsms.com/mcp". - MCPAudience string - - // APIAudience is the `aud` claim for API delegation tokens, e.g. - // "https://api.httpsms.com". This must match the httpSMS API's - // configured API_AUDIENCE. - APIAudience string - 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 @@ -107,6 +117,36 @@ func parseRSAPrivateKeyPEM(privateKeyPEM []byte) (*rsa.PrivateKey, error) { 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. @@ -121,10 +161,15 @@ func (keys *KeySet) KeyID() string { // 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 keys.MCPAudience and must never be accepted by -// the httpSMS API. +// 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) { - claims, err := keys.baseClaims(principal, keys.MCPAudience, scopes, ttl) + 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 } @@ -135,10 +180,11 @@ func (keys *KeySet) SignMCPAccessToken(principal Principal, clientID string, sco // 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 keys.APIAudience. +// (method, path). The token is audience-bound to the configured API +// audience. // // The resulting JWT carries JSON fields `scopes`, `http_method`, and -// `http_path`; issuer keys.Issuer; audience keys.APIAudience; subject +// `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. @@ -147,7 +193,12 @@ func (keys *KeySet) SignAPIDelegationToken(principal Principal, scopes []string, return "", errors.New("auth: API delegation token requires a non-empty method and path") } - claims, err := keys.baseClaims(principal, keys.APIAudience, scopes, ttl) + 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 } @@ -157,17 +208,21 @@ func (keys *KeySet) SignAPIDelegationToken(principal Principal, scopes []string, return keys.sign(claims) } +// 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(principal Principal, audience string, scopes []string, ttl time.Duration) (*AccessClaims, error) { +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 keys.Issuer == "" { - return nil, errors.New("auth: KeySet.Issuer must be set before signing tokens") - } - if audience == "" { - return nil, errors.New("auth: token audience must not be empty") - } if ttl <= 0 { return nil, errors.New("auth: token TTL must be positive") } @@ -182,7 +237,7 @@ func (keys *KeySet) baseClaims(principal Principal, audience string, scopes []st Email: principal.Email, Scopes: scopes, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: keys.Issuer, + Issuer: issuer, Subject: principal.UserID, Audience: jwt.ClaimStrings{audience}, IssuedAt: jwt.NewNumericDate(now), diff --git a/mcp/internal/auth/keys_test.go b/mcp/internal/auth/keys_test.go index ec31cf08..468433b8 100644 --- a/mcp/internal/auth/keys_test.go +++ b/mcp/internal/auth/keys_test.go @@ -55,17 +55,16 @@ func newTestPKCS8PrivateKeyPEM(t *testing.T) []byte { return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: bytes}) } -// newTestKeySet builds a KeySet with test issuer/audiences already set, as a -// production caller would after loading them from config.Config. +// 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) - keys.Issuer = testMCPIssuer - keys.MCPAudience = testMCPAudience - keys.APIAudience = testAPIAudience + require.NoError(t, keys.Configure(testMCPIssuer, testMCPAudience, testAPIAudience)) return keys } @@ -108,6 +107,106 @@ func TestNewKeySetAcceptsPKCS1AndPKCS8Encodings(t *testing.T) { 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) From 2e17e75d64a7cb5e061c4cf37054694677bbc779 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 21:24:27 +0300 Subject: [PATCH 07/25] feat(mcp): add OAuth state and metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/go.mod | 5 +- mcp/go.sum | 18 +- mcp/internal/oauth/clients.go | 496 ++++++++++++++++++++++++++++ mcp/internal/oauth/clients_test.go | 465 ++++++++++++++++++++++++++ mcp/internal/oauth/metadata.go | 90 +++++ mcp/internal/oauth/metadata_test.go | 100 ++++++ mcp/internal/oauth/store.go | 345 +++++++++++++++++++ mcp/internal/oauth/store_test.go | 279 ++++++++++++++++ 8 files changed, 1789 insertions(+), 9 deletions(-) create mode 100644 mcp/internal/oauth/clients.go create mode 100644 mcp/internal/oauth/clients_test.go create mode 100644 mcp/internal/oauth/metadata.go create mode 100644 mcp/internal/oauth/metadata_test.go create mode 100644 mcp/internal/oauth/store.go create mode 100644 mcp/internal/oauth/store_test.go diff --git a/mcp/go.mod b/mcp/go.mod index 60a8befb..71892503 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -3,12 +3,11 @@ 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/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.71.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 @@ -17,13 +16,13 @@ require ( 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/google/uuid v1.6.0 // 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/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 diff --git a/mcp/go.sum b/mcp/go.sum index 5dbe3003..b0c6bdb2 100644 --- a/mcp/go.sum +++ b/mcp/go.sum @@ -1,9 +1,13 @@ +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= @@ -19,22 +23,24 @@ 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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +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.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= 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= diff --git a/mcp/internal/oauth/clients.go b/mcp/internal/oauth/clients.go new file mode 100644 index 00000000..6e96ad6f --- /dev/null +++ b/mcp/internal/oauth/clients.go @@ -0,0 +1,496 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" +) + +// Sentinel errors returned by ClientResolver.Resolve and the DCR +// registration handler. Callers should use errors.Is against these rather +// than matching error strings. +var ( + // ErrUnsafeClientMetadataURL is returned when a client_id's scheme, + // host, or resolved address is not a safe target for a server-side + // fetch (non-HTTPS, or a private/loopback/link-local/otherwise + // non-public address). + ErrUnsafeClientMetadataURL = errors.New("oauth: unsafe client metadata document URL") + + // ErrClientMetadataRedirected is returned when fetching a client + // metadata document received a redirect response; redirects are never + // followed. + ErrClientMetadataRedirected = errors.New("oauth: client metadata document fetch was redirected") + + // ErrClientMetadataTooLarge is returned when a client metadata + // document response exceeds maxClientMetadataBytes. + ErrClientMetadataTooLarge = errors.New("oauth: client metadata document exceeds size limit") + + // ErrClientMetadataInvalid is returned when a client metadata document + // is not valid JSON or is missing a required field. + ErrClientMetadataInvalid = errors.New("oauth: client metadata document is invalid") + + // ErrClientIDMismatch is returned when a CIMD document's own + // "client_id" field does not exactly equal the URL used to fetch it. + ErrClientIDMismatch = errors.New("oauth: client metadata document client_id mismatch") + + // ErrUnsupportedGrantType is returned when a client's grant_types is + // empty, omits "authorization_code", or names an unsupported grant. + ErrUnsupportedGrantType = errors.New("oauth: unsupported client grant_types") + + // ErrUnsupportedResponseType is returned when a client's + // response_types is not exactly ["code"]. + ErrUnsupportedResponseType = errors.New("oauth: unsupported client response_types") + + // ErrUnsupportedAuthMethod is returned when a client's + // token_endpoint_auth_method is not "none". + ErrUnsupportedAuthMethod = errors.New("oauth: unsupported client token_endpoint_auth_method") + + // ErrInvalidRedirectURI is returned when a client's redirect_uris + // contains an entry that is not an absolute HTTPS URL, or an absolute + // HTTP URL with a loopback host. + ErrInvalidRedirectURI = errors.New("oauth: invalid client redirect_uri") +) + +// Limits and timeouts applied to every client metadata document fetch, per +// the design's SSRF hardening requirements. +const ( + maxClientMetadataBytes = 256 * 1024 + cimdFetchTimeout = 5 * time.Second + cimdCacheTTL = 15 * time.Minute + dynamicClientTTL = 24 * time.Hour + dynamicClientIDBytes = 24 +) + +// supportedGrantTypes are the only grant_types a client may declare. +var supportedGrantTypes = map[string]bool{ + "authorization_code": true, + "refresh_token": true, +} + +// ClientResolver resolves an OAuth client_id to its Client identity, either +// by fetching and validating a Client ID Metadata Document (CIMD) when +// clientID is an HTTPS URL, or by looking up a Dynamic Client Registration +// (DCR) record in store otherwise. +type ClientResolver struct { + httpClient *http.Client + store Store + + // lookupIP resolves host to its IP addresses for the SSRF safety + // check. It defaults to a wrapper around net.DefaultResolver and is + // only ever overridden in tests (same-package field access), letting + // tests simulate a client_id host resolving to a public address while + // the actual document fetch is still served locally and deterministically. + lookupIP func(ctx context.Context, host string) ([]net.IP, error) + + cacheMu sync.Mutex + cache map[string]cachedClient +} + +// cachedClient is a validated Client document cached for cimdCacheTTL. +type cachedClient struct { + client Client + expiresAt time.Time +} + +// NewClientResolver returns a ClientResolver that fetches Client ID +// Metadata Documents using httpClient (with redirects disabled) and falls +// back to store for Dynamic Client Registration lookups. +func NewClientResolver(httpClient *http.Client, store Store) *ClientResolver { + safeClient := *httpClient + safeClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + return &ClientResolver{ + httpClient: &safeClient, + store: store, + lookupIP: defaultLookupIP, + cache: make(map[string]cachedClient), + } +} + +// defaultLookupIP resolves host through net.DefaultResolver. +func defaultLookupIP(ctx context.Context, host string) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + ips := make([]net.IP, len(addrs)) + for i, addr := range addrs { + ips[i] = addr.IP + } + return ips, nil +} + +// Resolve returns the Client identified by clientID. When clientID is an +// absolute URL it is resolved as a Client ID Metadata Document; otherwise +// it is looked up as a Dynamic Client Registration record. +func (r *ClientResolver) Resolve(ctx context.Context, clientID string) (Client, error) { + parsed, err := url.Parse(clientID) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return r.store.GetDynamicClient(ctx, clientID) + } + return r.resolveCIMD(ctx, clientID, parsed) +} + +// resolveCIMD fetches and validates the Client ID Metadata Document at +// clientID, using a cached result when available. +func (r *ClientResolver) resolveCIMD(ctx context.Context, clientID string, parsed *url.URL) (Client, error) { + if parsed.Scheme != "https" { + return Client{}, fmt.Errorf("%w: client metadata document must use https, got %q", ErrUnsafeClientMetadataURL, parsed.Scheme) + } + + if err := r.validateHostIsPublic(ctx, parsed.Hostname()); err != nil { + return Client{}, err + } + + if client, ok := r.cached(clientID); ok { + return client, nil + } + + body, err := r.fetch(ctx, clientID) + if err != nil { + return Client{}, err + } + + client, err := parseCIMDDocument(clientID, body) + if err != nil { + return Client{}, err + } + + r.cacheClient(clientID, client) + return client, nil +} + +// validateHostIsPublic returns ErrUnsafeClientMetadataURL when host (a +// literal IP or a DNS name) does not resolve exclusively to public +// addresses. +func (r *ClientResolver) validateHostIsPublic(ctx context.Context, host string) error { + if ip := net.ParseIP(host); ip != nil { + if !isPublicIP(ip) { + return fmt.Errorf("%w: %q is not a public address", ErrUnsafeClientMetadataURL, host) + } + return nil + } + + ips, err := r.lookupIP(ctx, host) + if err != nil { + return fmt.Errorf("oauth: cannot resolve client metadata document host %q: %w", host, err) + } + if len(ips) == 0 { + return fmt.Errorf("%w: %q did not resolve to any address", ErrUnsafeClientMetadataURL, host) + } + for _, ip := range ips { + if !isPublicIP(ip) { + return fmt.Errorf("%w: %q resolves to a non-public address", ErrUnsafeClientMetadataURL, host) + } + } + return nil +} + +// fetch retrieves clientID's document body, rejecting redirects and +// limiting the response to maxClientMetadataBytes. +func (r *ClientResolver) fetch(ctx context.Context, clientID string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, cimdFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, clientID, nil) + if err != nil { + return nil, fmt.Errorf("oauth: cannot build client metadata document request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := r.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("oauth: cannot fetch client metadata document: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return nil, fmt.Errorf("%w: received status %d", ErrClientMetadataRedirected, resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: received status %d", ErrClientMetadataInvalid, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxClientMetadataBytes+1)) + if err != nil { + return nil, fmt.Errorf("oauth: cannot read client metadata document: %w", err) + } + if len(body) > maxClientMetadataBytes { + return nil, ErrClientMetadataTooLarge + } + return body, nil +} + +// cached returns the still-valid cached Client for clientID, if any. +func (r *ClientResolver) cached(clientID string) (Client, bool) { + r.cacheMu.Lock() + defer r.cacheMu.Unlock() + + entry, ok := r.cache[clientID] + if !ok || time.Now().After(entry.expiresAt) { + return Client{}, false + } + return entry.client, true +} + +// cacheClient caches client for clientID for cimdCacheTTL. +func (r *ClientResolver) cacheClient(clientID string, client Client) { + r.cacheMu.Lock() + defer r.cacheMu.Unlock() + + r.cache[clientID] = cachedClient{client: client, expiresAt: time.Now().Add(cimdCacheTTL)} +} + +// parseCIMDDocument decodes and validates a Client ID Metadata Document +// fetched from clientID, requiring its own "client_id" field to exactly +// equal clientID (the CIMD authentication mechanism). +func parseCIMDDocument(clientID string, body []byte) (Client, error) { + client, err := parseClientDocument(body) + if err != nil { + return Client{}, err + } + if client.ID != clientID { + return Client{}, fmt.Errorf("%w: document client_id %q does not match requested %q", ErrClientIDMismatch, client.ID, clientID) + } + return client, nil +} + +// parseClientDocument decodes body as a Client and validates every field +// required of both a CIMD document and a Dynamic Client Registration +// request, except the CIMD-only client_id/URL equality check. +func parseClientDocument(body []byte) (Client, error) { + var client Client + if err := json.Unmarshal(body, &client); err != nil { + return Client{}, fmt.Errorf("%w: %v", ErrClientMetadataInvalid, err) + } + + if client.Name == "" { + return Client{}, fmt.Errorf("%w: client_name is required", ErrClientMetadataInvalid) + } + + if len(client.RedirectURIs) == 0 { + return Client{}, fmt.Errorf("%w: redirect_uris is required", ErrClientMetadataInvalid) + } + for _, redirectURI := range client.RedirectURIs { + if err := validateRedirectURI(redirectURI); err != nil { + return Client{}, err + } + } + + if err := validateGrantTypes(client.GrantTypes); err != nil { + return Client{}, err + } + if err := validateResponseTypes(client.ResponseTypes); err != nil { + return Client{}, err + } + if client.TokenEndpointAuthMethod != "none" { + return Client{}, fmt.Errorf("%w: must be \"none\", got %q", ErrUnsupportedAuthMethod, client.TokenEndpointAuthMethod) + } + + return client, nil +} + +// validateRedirectURI requires redirectURI to be an absolute HTTPS URL, or +// an absolute HTTP URL whose host is a loopback address (RFC 8252 native +// app exception). No other scheme, and no non-loopback HTTP target, is +// permitted. +func validateRedirectURI(redirectURI string) error { + parsed, err := url.Parse(redirectURI) + if err != nil || !parsed.IsAbs() || parsed.Host == "" { + return fmt.Errorf("%w: %q is not an absolute URL", ErrInvalidRedirectURI, redirectURI) + } + + switch parsed.Scheme { + case "https": + return nil + case "http": + if isLoopbackHost(parsed.Hostname()) { + return nil + } + return fmt.Errorf("%w: %q uses http with a non-loopback host", ErrInvalidRedirectURI, redirectURI) + default: + return fmt.Errorf("%w: %q must use https, or http with a loopback host", ErrInvalidRedirectURI, redirectURI) + } +} + +// isLoopbackHost reports whether host is "localhost" or a literal loopback +// IP address. +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// validateGrantTypes requires a non-empty grant_types naming only +// supported grants and always including "authorization_code". +func validateGrantTypes(grantTypes []string) error { + if len(grantTypes) == 0 { + return fmt.Errorf("%w: grant_types is required", ErrUnsupportedGrantType) + } + + hasAuthorizationCode := false + for _, grantType := range grantTypes { + if !supportedGrantTypes[grantType] { + return fmt.Errorf("%w: %q is not supported", ErrUnsupportedGrantType, grantType) + } + if grantType == "authorization_code" { + hasAuthorizationCode = true + } + } + if !hasAuthorizationCode { + return fmt.Errorf("%w: must include \"authorization_code\"", ErrUnsupportedGrantType) + } + return nil +} + +// validateResponseTypes requires response_types to be exactly ["code"]. +func validateResponseTypes(responseTypes []string) error { + if len(responseTypes) != 1 || responseTypes[0] != "code" { + return fmt.Errorf("%w: must be [\"code\"], got %v", ErrUnsupportedResponseType, responseTypes) + } + return nil +} + +// isPublicIP reports whether ip is safe to connect to from the +// authorization server: neither loopback, private, link-local, multicast, +// unspecified, nor within the shared/carrier-grade NAT range +// (100.64.0.0/10), which is not covered by net.IP.IsPrivate. +func isPublicIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { + return false + } + if sharedAddressSpace.Contains(ip) { + return false + } + return true +} + +// sharedAddressSpace is the IPv4 carrier-grade NAT range (RFC 6598), +// commonly used for internal cloud/service-mesh networking and therefore +// excluded from "public" alongside RFC 1918 private space. +var sharedAddressSpace = mustParseCIDR("100.64.0.0/10") + +func mustParseCIDR(cidr string) *net.IPNet { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + return network +} + +// registrationRequest is the subset of RFC 7591 Dynamic Client Registration +// request fields this service accepts. Unlike a CIMD document, the client +// never supplies its own client_id: the server assigns one. +type registrationRequest struct { + 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"` +} + +// registrationError is the RFC 7591 error response body. +type registrationError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// NewRegistrationHandler returns an http.HandlerFunc implementing +// POST /oauth/register (RFC 7591 Dynamic Client Registration): it validates +// the submitted client metadata with the same rules enforced on a CIMD +// document (redirect URIs, grant/response types, and +// token_endpoint_auth_method=none), assigns a random client_id, stores the +// resulting record in store for 24 hours, and responds 201 Created with the +// stored Client. +func NewRegistrationHandler(store Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxClientMetadataBytes+1)) + if err != nil { + writeRegistrationError(w, http.StatusBadRequest, "invalid_client_metadata", "cannot read request body") + return + } + if len(body) > maxClientMetadataBytes { + writeRegistrationError(w, http.StatusBadRequest, "invalid_client_metadata", "request body exceeds size limit") + return + } + + var request registrationRequest + if err := json.Unmarshal(body, &request); err != nil { + writeRegistrationError(w, http.StatusBadRequest, "invalid_client_metadata", "request body is not valid JSON") + return + } + + clientID, err := newRandomToken(dynamicClientIDBytes) + if err != nil { + writeRegistrationError(w, http.StatusInternalServerError, "server_error", "cannot generate client_id") + return + } + + candidate := Client{ + ID: clientID, + Name: request.Name, + URI: request.URI, + RedirectURIs: request.RedirectURIs, + GrantTypes: request.GrantTypes, + ResponseTypes: request.ResponseTypes, + TokenEndpointAuthMethod: request.TokenEndpointAuthMethod, + } + + validated, err := parseClientDocument(mustMarshal(candidate)) + if err != nil { + writeRegistrationError(w, http.StatusBadRequest, "invalid_client_metadata", err.Error()) + return + } + validated.ID = clientID + + if err := store.PutDynamicClient(r.Context(), validated, dynamicClientTTL); err != nil { + writeRegistrationError(w, http.StatusInternalServerError, "server_error", "cannot store client registration") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(validated) + } +} + +// mustMarshal marshals v, panicking on failure. It is used only for values +// this package itself constructs (never for attacker-controlled input), +// where a marshal failure would indicate a programmer error. +func mustMarshal(v any) []byte { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} + +// writeRegistrationError writes an RFC 7591 error response. +func writeRegistrationError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(registrationError{Error: code, ErrorDescription: description}) +} diff --git a/mcp/internal/oauth/clients_test.go b/mcp/internal/oauth/clients_test.go new file mode 100644 index 00000000..cd17cc62 --- /dev/null +++ b/mcp/internal/oauth/clients_test.go @@ -0,0 +1,465 @@ +package oauth + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newClientsTestStore starts an in-memory miniredis-backed Store for this +// file's Dynamic Client Registration tests. +func newClientsTestStore(t *testing.T) Store { + t.Helper() + + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + return NewRedisStore(client) +} + +// newLocalizedHTTPClient returns an *http.Client whose Transport connects +// to server's real listener address regardless of the requested host, with +// TLS hostname verification disabled. This lets a test use a +// realistic-looking "https://client.example/..." client_id (satisfying the +// resolver's public-address SSRF check via a stubbed lookupIP) while the +// document bytes are actually served by a local httptest.Server. +func newLocalizedHTTPClient(t *testing.T, server *httptest.Server) *http.Client { + t.Helper() + + addr := server.Listener.Addr().String() + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, addr) + }, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test-only: bypasses hostname check for a locally redirected dial + } + t.Cleanup(transport.CloseIdleConnections) + + return &http.Client{Transport: transport} +} + +// publicLookupIP is a stub lookupIP reporting that any host resolves to a +// single public IP address, for tests whose client_id host is a fake +// domain served locally through newLocalizedHTTPClient. +func publicLookupIP(context.Context, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("93.184.216.34")}, nil +} + +func validTestClient(clientID string, redirectURIs []string) Client { + return Client{ + ID: clientID, + Name: "Test Client", + RedirectURIs: redirectURIs, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "none", + } +} + +func TestClientResolverRejectsPrivateMetadataTarget(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, newClientsTestStore(t)) + + _, err := resolver.Resolve(context.Background(), "https://127.0.0.1/client.json") + require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) +} + +func TestClientResolverRejectsLoopbackIPv6MetadataTarget(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, newClientsTestStore(t)) + + _, err := resolver.Resolve(context.Background(), "https://[::1]/client.json") + require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) +} + +func TestClientResolverRejectsNonHTTPSClientID(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, newClientsTestStore(t)) + + _, err := resolver.Resolve(context.Background(), "http://client.example/client.json") + require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) +} + +func TestClientResolverRejectsPrivateDNSResult(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, newClientsTestStore(t)) + resolver.lookupIP = func(context.Context, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.5")}, nil + } + + _, err := resolver.Resolve(context.Background(), "https://internal.example/client.json") + require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) +} + +func TestClientResolverRejectsLinkLocalDNSResult(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, newClientsTestStore(t)) + resolver.lookupIP = func(context.Context, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("169.254.1.1")}, nil + } + + _, err := resolver.Resolve(context.Background(), "https://link-local.example/client.json") + require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) +} + +func TestClientResolverAcceptsValidClientMetadataDocument(t *testing.T) { + const clientID = "https://client.example/client.json" + + var requests int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requests, 1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{ + "https://client.example/callback", + "http://127.0.0.1/callback", + })) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + client, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.Equal(t, clientID, client.ID) + assert.Equal(t, "Test Client", client.Name) + assert.ElementsMatch(t, []string{"authorization_code", "refresh_token"}, client.GrantTypes) + + // A second Resolve within the 15-minute cache window must not issue a + // second HTTP request. + _, err = resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.EqualValues(t, 1, atomic.LoadInt32(&requests)) +} + +func TestClientResolverRefetchesAfterCacheExpiry(t *testing.T) { + const clientID = "https://client.example/client.json" + + var requests int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requests, 1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{"https://client.example/callback"})) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.EqualValues(t, 1, atomic.LoadInt32(&requests)) + + // Force the cached entry to have already expired instead of waiting + // out the real 15-minute cache window. + resolver.cacheMu.Lock() + entry := resolver.cache[clientID] + entry.expiresAt = time.Now().Add(-time.Second) + resolver.cache[clientID] = entry + resolver.cacheMu.Unlock() + + _, err = resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.EqualValues(t, 2, atomic.LoadInt32(&requests)) +} + +func TestClientResolverRejectsResponseOverSizeLimit(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(bytes.Repeat([]byte("a"), maxClientMetadataBytes+1)) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrClientMetadataTooLarge) +} + +func TestClientResolverRejectsRedirect(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://attacker.example/client.json", http.StatusFound) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrClientMetadataRedirected) +} + +func TestClientResolverRejectsClientIDMismatch(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(validTestClient("https://client.example/different.json", []string{"https://client.example/callback"})) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrClientIDMismatch) +} + +func TestClientResolverRejectsUnsupportedGrantTypes(t *testing.T) { + testCases := map[string][]string{ + "empty": {}, + "unsupported": {"client_credentials"}, + "missing_authorization_code": {"refresh_token"}, + } + + for name, grantTypes := range testCases { + t.Run(name, func(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + client := validTestClient(clientID, []string{"https://client.example/callback"}) + client.GrantTypes = grantTypes + _ = json.NewEncoder(w).Encode(client) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrUnsupportedGrantType) + }) + } +} + +func TestClientResolverRejectsUnsupportedResponseTypes(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + client := validTestClient(clientID, []string{"https://client.example/callback"}) + client.ResponseTypes = []string{"token"} + _ = json.NewEncoder(w).Encode(client) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrUnsupportedResponseType) +} + +func TestClientResolverRejectsUnsupportedAuthMethod(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + client := validTestClient(clientID, []string{"https://client.example/callback"}) + client.TokenEndpointAuthMethod = "client_secret_basic" + _ = json.NewEncoder(w).Encode(client) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrUnsupportedAuthMethod) +} + +func TestClientResolverRejectsNonLoopbackHTTPRedirectURI(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{"http://attacker.example/callback"})) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrInvalidRedirectURI) +} + +func TestClientResolverAllowsLoopbackHTTPRedirectURI(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{ + "http://127.0.0.1:51820/callback", + "http://localhost:51820/callback", + })) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + client, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.Contains(t, client.RedirectURIs, "http://127.0.0.1:51820/callback") + assert.Contains(t, client.RedirectURIs, "http://localhost:51820/callback") +} + +func TestClientResolverResolvesDynamicallyRegisteredClient(t *testing.T) { + store := newClientsTestStore(t) + require.NoError(t, store.PutDynamicClient(context.Background(), Client{ + ID: "dcr-opaque-client-id", + Name: "DCR Client", + RedirectURIs: []string{"https://client.example/callback"}, + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "none", + }, dynamicClientTTL)) + + resolver := NewClientResolver(http.DefaultClient, store) + + client, err := resolver.Resolve(context.Background(), "dcr-opaque-client-id") + require.NoError(t, err) + assert.Equal(t, "DCR Client", client.Name) +} + +func TestClientResolverResolveUnknownDynamicClientNotFound(t *testing.T) { + resolver := NewClientResolver(http.DefaultClient, newClientsTestStore(t)) + + _, err := resolver.Resolve(context.Background(), "never-registered") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestRegistrationHandlerCreatesClientAndReturns201(t *testing.T) { + store := newClientsTestStore(t) + handler := NewRegistrationHandler(store) + + requestBody := `{ + "client_name": "Test Client", + "redirect_uris": ["https://client.example/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + }` + + req := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(requestBody)) + rec := httptest.NewRecorder() + handler(rec, req) + + require.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var created Client + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + assert.NotEmpty(t, created.ID) + assert.Equal(t, "Test Client", created.Name) + assert.ElementsMatch(t, []string{"authorization_code", "refresh_token"}, created.GrantTypes) + + stored, err := store.GetDynamicClient(context.Background(), created.ID) + require.NoError(t, err) + assert.Equal(t, created, stored) +} + +func TestRegistrationHandlerStoresRecordFor24Hours(t *testing.T) { + miniredisServer := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: miniredisServer.Addr()}) + t.Cleanup(func() { _ = redisClient.Close() }) + store := NewRedisStore(redisClient) + + handler := NewRegistrationHandler(store) + requestBody := `{ + "client_name": "Test Client", + "redirect_uris": ["https://client.example/callback"], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + }` + + req := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(requestBody)) + rec := httptest.NewRecorder() + handler(rec, req) + require.Equal(t, http.StatusCreated, rec.Code) + + keys := miniredisServer.Keys() + require.Len(t, keys, 1) + assert.True(t, strings.HasPrefix(keys[0], "httpsms:mcp:oauth:client:")) + + ttl := miniredisServer.TTL(keys[0]) + assert.Greater(t, ttl, 23*time.Hour) + assert.LessOrEqual(t, ttl, 24*time.Hour) +} + +func TestRegistrationHandlerRejectsInvalidMetadata(t *testing.T) { + handler := NewRegistrationHandler(newClientsTestStore(t)) + + requestBody := `{"client_name": "Missing Redirect URIs"}` + req := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(requestBody)) + rec := httptest.NewRecorder() + handler(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var body registrationError + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "invalid_client_metadata", body.Error) +} + +func TestRegistrationHandlerRejectsMalformedJSON(t *testing.T) { + handler := NewRegistrationHandler(newClientsTestStore(t)) + + req := httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader("not json")) + rec := httptest.NewRecorder() + handler(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestRegistrationHandlerRejectsNonPOST(t *testing.T) { + handler := NewRegistrationHandler(newClientsTestStore(t)) + + req := httptest.NewRequest(http.MethodGet, "/oauth/register", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestIsPublicIPRejectsNonPublicRanges(t *testing.T) { + testCases := []string{ + "127.0.0.1", + "::1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "169.254.1.1", + "224.0.0.1", + "0.0.0.0", + "100.64.0.1", + } + + for _, raw := range testCases { + t.Run(raw, func(t *testing.T) { + assert.False(t, isPublicIP(net.ParseIP(raw)), "%s must not be treated as public", raw) + }) + } +} + +func TestIsPublicIPAcceptsPublicAddress(t *testing.T) { + assert.True(t, isPublicIP(net.ParseIP("93.184.216.34"))) +} diff --git a/mcp/internal/oauth/metadata.go b/mcp/internal/oauth/metadata.go new file mode 100644 index 00000000..f2345c36 --- /dev/null +++ b/mcp/internal/oauth/metadata.go @@ -0,0 +1,90 @@ +package oauth + +import ( + "encoding/json" + "net/http" + "strings" +) + +// Scopes lists every OAuth scope this service issues, in the fixed order +// presented in discovery metadata and the consent screen. +var Scopes = []string{ + "phones:read", + "messages:read", + "messages:send", + "phone-api-keys:write", + "user-api-key:rotate", +} + +// protectedResourceMetadata is the RFC 9728 OAuth 2.0 Protected Resource +// Metadata document served for the MCP endpoint. +type protectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported"` +} + +// NewProtectedResourceMetadataHandler returns an http.HandlerFunc serving +// OAuth 2.0 Protected Resource Metadata (RFC 9728) for the MCP endpoint at +// baseURL+"/mcp". baseURL must not have a trailing slash requirement; any +// trailing slash is trimmed. +func NewProtectedResourceMetadataHandler(baseURL string) http.HandlerFunc { + root := strings.TrimRight(baseURL, "/") + + return newMetadataHandler(protectedResourceMetadata{ + Resource: root + "/mcp", + AuthorizationServers: []string{root}, + ScopesSupported: Scopes, + }) +} + +// authorizationServerMetadata is the RFC 8414 OAuth 2.0 Authorization +// Server Metadata document, extended with the CIMD support flag consumed +// by clients implementing the Client ID Metadata Document mechanism. +type authorizationServerMetadata 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"` + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` +} + +// NewAuthorizationServerMetadataHandler returns an http.HandlerFunc serving +// OAuth 2.0 Authorization Server Metadata (RFC 8414) rooted at baseURL. +func NewAuthorizationServerMetadataHandler(baseURL string) http.HandlerFunc { + root := strings.TrimRight(baseURL, "/") + + return newMetadataHandler(authorizationServerMetadata{ + Issuer: root, + AuthorizationEndpoint: root + "/oauth/authorize", + TokenEndpoint: root + "/oauth/token", + RegistrationEndpoint: root + "/oauth/register", + JWKSURI: root + "/.well-known/jwks.json", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + CodeChallengeMethodsSupported: []string{"S256"}, + ScopesSupported: Scopes, + ClientIDMetadataDocumentSupported: true, + }) +} + +// newMetadataHandler marshals body once and returns an http.HandlerFunc +// that serves it as "application/json" on every request. +func newMetadataHandler(body any) http.HandlerFunc { + data, err := json.Marshal(body) + + return func(w http.ResponseWriter, _ *http.Request) { + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + } +} diff --git a/mcp/internal/oauth/metadata_test.go b/mcp/internal/oauth/metadata_test.go new file mode 100644 index 00000000..0bd44a2d --- /dev/null +++ b/mcp/internal/oauth/metadata_test.go @@ -0,0 +1,100 @@ +package oauth_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" +) + +const testMCPBaseURL = "https://mcp.httpsms.com" + +func TestProtectedResourceMetadataHandlerServesExactFields(t *testing.T) { + handler := oauth.NewProtectedResourceMetadataHandler(testMCPBaseURL) + + req := httptest.NewRequest("GET", "/.well-known/oauth-protected-resource", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + require.Equal(t, 200, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + assert.Equal(t, "https://mcp.httpsms.com/mcp", body["resource"]) + assert.Equal(t, []any{"https://mcp.httpsms.com"}, body["authorization_servers"]) + assert.Equal(t, []any{ + "phones:read", + "messages:read", + "messages:send", + "phone-api-keys:write", + "user-api-key:rotate", + }, body["scopes_supported"]) + + // No other top-level fields are permitted by RFC 9728 for this + // service's minimal, exact document. + assert.Len(t, body, 3) +} + +func TestProtectedResourceMetadataHandlerTrimsTrailingSlash(t *testing.T) { + handler := oauth.NewProtectedResourceMetadataHandler(testMCPBaseURL + "/") + + req := httptest.NewRequest("GET", "/.well-known/oauth-protected-resource", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "https://mcp.httpsms.com/mcp", body["resource"]) +} + +func TestAuthorizationServerMetadataHandlerServesExactFields(t *testing.T) { + handler := oauth.NewAuthorizationServerMetadataHandler(testMCPBaseURL) + + req := httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + require.Equal(t, 200, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + assert.Equal(t, "https://mcp.httpsms.com", body["issuer"]) + assert.Equal(t, "https://mcp.httpsms.com/oauth/authorize", body["authorization_endpoint"]) + assert.Equal(t, "https://mcp.httpsms.com/oauth/token", body["token_endpoint"]) + assert.Equal(t, "https://mcp.httpsms.com/oauth/register", body["registration_endpoint"]) + assert.Equal(t, "https://mcp.httpsms.com/.well-known/jwks.json", body["jwks_uri"]) + assert.Equal(t, []any{"code"}, body["response_types_supported"]) + assert.Equal(t, []any{"authorization_code", "refresh_token"}, body["grant_types_supported"]) + assert.Equal(t, []any{"S256"}, body["code_challenge_methods_supported"]) + assert.Equal(t, []any{ + "phones:read", + "messages:read", + "messages:send", + "phone-api-keys:write", + "user-api-key:rotate", + }, body["scopes_supported"]) + assert.Equal(t, true, body["client_id_metadata_document_supported"]) + + assert.Len(t, body, 10) +} + +func TestScopesConstantOrderIsStable(t *testing.T) { + // Both metadata documents and the future consent screen depend on this + // exact, fixed order; a reordering would silently change the scopes + // list presented to users. + assert.Equal(t, []string{ + "phones:read", + "messages:read", + "messages:send", + "phone-api-keys:write", + "user-api-key:rotate", + }, oauth.Scopes) +} diff --git a/mcp/internal/oauth/store.go b/mcp/internal/oauth/store.go new file mode 100644 index 00000000..0383543d --- /dev/null +++ b/mcp/internal/oauth/store.go @@ -0,0 +1,345 @@ +// Package oauth implements the httpSMS MCP service's OAuth 2.1 +// authorization server: Redis-backed authorization/token state, Client ID +// Metadata Document (CIMD) resolution, Dynamic Client Registration (DCR) +// compatibility, and OAuth discovery metadata. +package oauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// Redis key namespaces. Every key is the fixed prefix followed by the +// hex-encoded SHA-256 hash of the record's public value (transaction ID, +// authorization code, refresh token, DCR client ID, or confirmation +// handle) -- never the raw value itself, so a Redis key listing or a log +// line that leaks a key name never leaks the bearer secret it protects. +const ( + keyPrefixTransaction = "httpsms:mcp:oauth:transaction:" + keyPrefixCode = "httpsms:mcp:oauth:code:" + keyPrefixRefresh = "httpsms:mcp:oauth:refresh:" + keyPrefixClient = "httpsms:mcp:oauth:client:" + keyPrefixConfirmation = "httpsms:mcp:confirmation:" +) + +// ErrNotFound is returned by every Get/Consume/Rotate method when the +// requested record does not exist, has already expired, or (for +// Consume/Rotate) has already been redeemed exactly once. +var ErrNotFound = errors.New("oauth: not found") + +// AuthorizationTransaction records a single in-flight OAuth authorization +// request from the moment its client, redirect URI, scopes, state, and PKCE +// challenge have been validated until the resulting authorization code is +// issued or the transaction expires unused. Unlike codes/tokens/handles it +// is read (not consumed) so it can be re-read across the Firebase-login +// redirect round trip. +type AuthorizationTransaction struct { + // ID is the random public value this transaction is looked up by. It + // is used only to derive the record's Redis key and is never persisted + // in the stored JSON value. + ID string `json:"-"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Scopes []string `json:"scopes"` + State string `json:"state"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + ResponseType string `json:"response_type"` + CreatedAt time.Time `json:"created_at"` +} + +// AuthorizationCode is a one-time, PKCE-bound authorization code issued +// after the user completes Firebase login and approves the requested +// scopes. +type AuthorizationCode struct { + // Code is the random public value the client redeems at the token + // endpoint. It is never persisted in the stored JSON value. + Code string `json:"-"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Scopes []string `json:"scopes"` + UserID string `json:"user_id"` + Email string `json:"email"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + CreatedAt time.Time `json:"created_at"` +} + +// RefreshGrant is a high-entropy opaque refresh token's server-side record, +// bound to the user, client, granted scopes, and token family (rotation +// lineage) that produced it. +type RefreshGrant struct { + // Token is the random public refresh-token value. It is never + // persisted in the stored JSON value. + Token string `json:"-"` + UserID string `json:"user_id"` + Email string `json:"email"` + ClientID string `json:"client_id"` + Scopes []string `json:"scopes"` + FamilyID string `json:"family_id"` + CreatedAt time.Time `json:"created_at"` +} + +// Client is an OAuth client identity resolved either from a Client ID +// Metadata Document (CIMD) fetched at authorization time or from a Dynamic +// Client Registration (DCR) record created through POST /oauth/register. +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"` +} + +// Confirmation is a short-lived, one-time confirmation handle used by the +// legacy (non-MRTR) primary API-key-rotation flow: the first tool call +// returns a handle and a second call must present it before rotation +// proceeds. +type Confirmation struct { + // Handle is the random public confirmation value. It is never + // persisted in the stored JSON value. + Handle string `json:"-"` + UserID string `json:"user_id"` + ClientID string `json:"client_id"` + Operation string `json:"operation"` + CreatedAt time.Time `json:"created_at"` +} + +// Store is the persistence boundary for every piece of OAuth server-side +// state: in-flight authorization transactions, one-time authorization +// codes, rotating refresh tokens, Dynamic Client Registration records, and +// one-time confirmation handles. +// +// Every Put method requires ttl > 0. Consume and Rotate methods redeem +// their record exactly once, atomically: a second call with the same +// public value returns ErrNotFound. +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) +} + +// rotateRefreshTokenScript atomically deletes the old refresh-token hash +// and creates the new one with a TTL, or does nothing and reports failure +// when the old hash is already gone (already rotated, replayed, or +// expired). A Lua script run through EVAL is the only way to make "check +// the old key exists, delete it, and create the new key" a single +// indivisible server-side operation: MULTI/EXEC alone cannot branch on the +// old key's existence, and WATCH-based optimistic locking would let a +// replayed rotation race the legitimate one instead of failing closed. +var rotateRefreshTokenScript = redis.NewScript(` +if redis.call("GET", KEYS[1]) == false then + return 0 +end +redis.call("DEL", KEYS[1]) +redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[2]) +return 1 +`) + +// RedisStore is the Redis-backed implementation of Store. +type RedisStore struct { + client redis.UniversalClient +} + +// NewRedisStore returns a Store backed by client. +func NewRedisStore(client redis.UniversalClient) *RedisStore { + return &RedisStore{client: client} +} + +// PutAuthorizationTransaction implements Store. +func (s *RedisStore) PutAuthorizationTransaction(ctx context.Context, transaction AuthorizationTransaction, ttl time.Duration) error { + if transaction.ID == "" { + return errors.New("oauth: authorization transaction ID must not be empty") + } + return putRecord(ctx, s.client, keyPrefixTransaction, transaction.ID, transaction, ttl) +} + +// GetAuthorizationTransaction implements Store. +func (s *RedisStore) GetAuthorizationTransaction(ctx context.Context, id string) (AuthorizationTransaction, error) { + var transaction AuthorizationTransaction + err := getRecord(ctx, s.client, keyPrefixTransaction, id, &transaction) + transaction.ID = id + return transaction, err +} + +// PutAuthorizationCode implements Store. +func (s *RedisStore) PutAuthorizationCode(ctx context.Context, code AuthorizationCode, ttl time.Duration) error { + if code.Code == "" { + return errors.New("oauth: authorization code value must not be empty") + } + return putRecord(ctx, s.client, keyPrefixCode, code.Code, code, ttl) +} + +// ConsumeAuthorizationCode implements Store. It atomically fetches and +// deletes the record so the same code can never be redeemed twice. +func (s *RedisStore) ConsumeAuthorizationCode(ctx context.Context, code string) (AuthorizationCode, error) { + var record AuthorizationCode + err := consumeRecord(ctx, s.client, keyPrefixCode, code, &record) + record.Code = code + return record, err +} + +// PutRefreshToken implements Store. +func (s *RedisStore) PutRefreshToken(ctx context.Context, grant RefreshGrant, ttl time.Duration) error { + if grant.Token == "" { + return errors.New("oauth: refresh token value must not be empty") + } + return putRecord(ctx, s.client, keyPrefixRefresh, grant.Token, grant, ttl) +} + +// RotateRefreshToken implements Store. It atomically deletes oldToken's +// record and creates newGrant's record with ttl; a second rotation attempt +// against the same oldToken (replay) returns ErrNotFound. +func (s *RedisStore) RotateRefreshToken(ctx context.Context, oldToken string, newGrant RefreshGrant, ttl time.Duration) error { + if oldToken == "" { + return errors.New("oauth: old refresh token value must not be empty") + } + if newGrant.Token == "" { + return errors.New("oauth: new refresh token value must not be empty") + } + if ttl <= 0 { + return errors.New("oauth: refresh token TTL must be positive") + } + + data, err := json.Marshal(newGrant) + if err != nil { + return fmt.Errorf("oauth: cannot marshal refresh grant: %w", err) + } + + oldKey := hashedKey(keyPrefixRefresh, oldToken) + newKey := hashedKey(keyPrefixRefresh, newGrant.Token) + + result, err := rotateRefreshTokenScript.Run(ctx, s.client, []string{oldKey, newKey}, data, ttl.Milliseconds()).Int64() + if err != nil { + return fmt.Errorf("oauth: cannot rotate refresh token: %w", err) + } + if result == 0 { + return ErrNotFound + } + return nil +} + +// PutDynamicClient implements Store. +func (s *RedisStore) PutDynamicClient(ctx context.Context, client Client, ttl time.Duration) error { + if client.ID == "" { + return errors.New("oauth: dynamic client ID must not be empty") + } + return putRecord(ctx, s.client, keyPrefixClient, client.ID, client, ttl) +} + +// GetDynamicClient implements Store. +func (s *RedisStore) GetDynamicClient(ctx context.Context, id string) (Client, error) { + var client Client + err := getRecord(ctx, s.client, keyPrefixClient, id, &client) + if err == nil { + client.ID = id + } + return client, err +} + +// PutConfirmation implements Store. +func (s *RedisStore) PutConfirmation(ctx context.Context, confirmation Confirmation, ttl time.Duration) error { + if confirmation.Handle == "" { + return errors.New("oauth: confirmation handle must not be empty") + } + return putRecord(ctx, s.client, keyPrefixConfirmation, confirmation.Handle, confirmation, ttl) +} + +// ConsumeConfirmation implements Store. It atomically fetches and deletes +// the record so the same handle can never be redeemed twice. +func (s *RedisStore) ConsumeConfirmation(ctx context.Context, handle string) (Confirmation, error) { + var record Confirmation + err := consumeRecord(ctx, s.client, keyPrefixConfirmation, handle, &record) + record.Handle = handle + return record, err +} + +// hashedKey returns the namespaced Redis key for publicValue under prefix: +// prefix followed by the hex-encoded SHA-256 hash of publicValue. The raw +// value is never used as key material. +func hashedKey(prefix, publicValue string) string { + sum := sha256.Sum256([]byte(publicValue)) + return prefix + hex.EncodeToString(sum[:]) +} + +// putRecord serializes value as JSON and stores it under prefix's hashed +// key for publicValue, expiring after ttl. +func putRecord(ctx context.Context, client redis.UniversalClient, prefix, publicValue string, value any, ttl time.Duration) error { + if ttl <= 0 { + return errors.New("oauth: record TTL must be positive") + } + + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("oauth: cannot marshal record: %w", err) + } + + if err := client.Set(ctx, hashedKey(prefix, publicValue), data, ttl).Err(); err != nil { + return fmt.Errorf("oauth: cannot store record: %w", err) + } + return nil +} + +// getRecord reads and JSON-decodes the record stored under prefix's hashed +// key for publicValue into dest, without deleting it. +func getRecord(ctx context.Context, client redis.UniversalClient, prefix, publicValue string, dest any) error { + raw, err := client.Get(ctx, hashedKey(prefix, publicValue)).Bytes() + if errors.Is(err, redis.Nil) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("oauth: cannot read record: %w", err) + } + if err := json.Unmarshal(raw, dest); err != nil { + return fmt.Errorf("oauth: cannot decode record: %w", err) + } + return nil +} + +// consumeRecord atomically reads and deletes (Redis GETDEL) the record +// stored under prefix's hashed key for publicValue into dest, so a second +// call for the same publicValue returns ErrNotFound. +func consumeRecord(ctx context.Context, client redis.UniversalClient, prefix, publicValue string, dest any) error { + raw, err := client.GetDel(ctx, hashedKey(prefix, publicValue)).Bytes() + if errors.Is(err, redis.Nil) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("oauth: cannot consume record: %w", err) + } + if err := json.Unmarshal(raw, dest); err != nil { + return fmt.Errorf("oauth: cannot decode record: %w", err) + } + return nil +} + +// newRandomToken returns a cryptographically random, URL-safe public value +// encoding numBytes of entropy, for use as an authorization code, refresh +// token, confirmation handle, transaction ID, or dynamically registered +// client ID. It is never used directly as Redis key material -- callers +// store only its SHA-256 hash (see hashedKey). +func newRandomToken(numBytes int) (string, error) { + buf := make([]byte, numBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("oauth: cannot generate random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/mcp/internal/oauth/store_test.go b/mcp/internal/oauth/store_test.go new file mode 100644 index 00000000..866a4762 --- /dev/null +++ b/mcp/internal/oauth/store_test.go @@ -0,0 +1,279 @@ +package oauth_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" +) + +// newTestStore starts an in-memory miniredis server and returns a Store +// backed by it along with the miniredis handle for direct key inspection. +func newTestStore(t *testing.T) (*oauth.RedisStore, *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 +} + +func TestRedisStorePutGetAuthorizationTransaction(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + transaction := oauth.AuthorizationTransaction{ + ID: "transaction-id", + ClientID: "https://client.example/metadata.json", + RedirectURI: "https://client.example/callback", + Scopes: []string{"phones:read", "messages:send"}, + State: "state-value", + CodeChallenge: "challenge", + CodeChallengeMethod: "S256", + ResponseType: "code", + CreatedAt: time.Now().UTC().Truncate(time.Second), + } + + require.NoError(t, store.PutAuthorizationTransaction(ctx, transaction, time.Minute)) + + got, err := store.GetAuthorizationTransaction(ctx, "transaction-id") + require.NoError(t, err) + assert.Equal(t, transaction, got) + + // A transaction is read, not consumed: it must still be readable a + // second time (needed across the Firebase-login redirect round trip). + got2, err := store.GetAuthorizationTransaction(ctx, "transaction-id") + require.NoError(t, err) + assert.Equal(t, transaction, got2) +} + +func TestRedisStoreGetAuthorizationTransactionNotFound(t *testing.T) { + store, _ := newTestStore(t) + + _, err := store.GetAuthorizationTransaction(context.Background(), "missing") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreConsumeAuthorizationCodeIsOneTimeUse(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + code := oauth.AuthorizationCode{ + Code: "test-authorization-code", + ClientID: "https://client.example/metadata.json", + RedirectURI: "https://client.example/callback", + Scopes: []string{"phones:read"}, + UserID: "firebase-uid", + Email: "user@example.com", + CodeChallenge: "challenge", + CodeChallengeMethod: "S256", + CreatedAt: time.Now().UTC().Truncate(time.Second), + } + require.NoError(t, store.PutAuthorizationCode(ctx, code, time.Minute)) + + first, err := store.ConsumeAuthorizationCode(ctx, "test-authorization-code") + require.NoError(t, err) + assert.Equal(t, code, first) + + _, err = store.ConsumeAuthorizationCode(ctx, "test-authorization-code") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreConsumeAuthorizationCodeNotFound(t *testing.T) { + store, _ := newTestStore(t) + + _, err := store.ConsumeAuthorizationCode(context.Background(), "never-issued") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreAuthorizationCodeExpires(t *testing.T) { + store, server := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.PutAuthorizationCode(ctx, oauth.AuthorizationCode{Code: "expiring-code"}, time.Minute)) + server.FastForward(2 * time.Minute) + + _, err := store.ConsumeAuthorizationCode(ctx, "expiring-code") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreRotateRefreshTokenReplacesOldWithNew(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.PutRefreshToken(ctx, oauth.RefreshGrant{ + Token: "old-refresh-token", + UserID: "firebase-uid", + ClientID: "client-id", + Scopes: []string{"messages:send"}, + FamilyID: "family-1", + }, time.Hour)) + + newGrant := oauth.RefreshGrant{ + Token: "new-refresh-token", + UserID: "firebase-uid", + ClientID: "client-id", + Scopes: []string{"messages:send"}, + FamilyID: "family-1", + } + require.NoError(t, store.RotateRefreshToken(ctx, "old-refresh-token", newGrant, time.Hour)) + + // The old refresh token must no longer rotate (it has been consumed). + err := store.RotateRefreshToken(ctx, "old-refresh-token", oauth.RefreshGrant{Token: "another-token"}, time.Hour) + require.ErrorIs(t, err, oauth.ErrNotFound) + + // The new refresh token must itself now be rotatable, proving it was + // actually written by the first rotation. + require.NoError(t, store.RotateRefreshToken(ctx, "new-refresh-token", oauth.RefreshGrant{ + Token: "newest-refresh-token", + UserID: "firebase-uid", + ClientID: "client-id", + FamilyID: "family-1", + }, time.Hour)) +} + +func TestRedisStoreRotateRefreshTokenRejectsReplay(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.PutRefreshToken(ctx, oauth.RefreshGrant{Token: "token-a"}, time.Hour)) + + require.NoError(t, store.RotateRefreshToken(ctx, "token-a", oauth.RefreshGrant{Token: "token-b"}, time.Hour)) + + // Replaying rotation with the already-consumed old token must fail even + // though a (different, unrelated) new token value is supplied. + err := store.RotateRefreshToken(ctx, "token-a", oauth.RefreshGrant{Token: "token-c"}, time.Hour) + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreRotateRefreshTokenUnknownOldTokenFails(t *testing.T) { + store, _ := newTestStore(t) + + err := store.RotateRefreshToken(context.Background(), "never-issued", oauth.RefreshGrant{Token: "new-token"}, time.Hour) + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStorePutGetDynamicClient(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + client := oauth.Client{ + ID: "dcr-client-id", + Name: "Test Client", + RedirectURIs: []string{"https://client.example/callback"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: "none", + } + require.NoError(t, store.PutDynamicClient(ctx, client, 24*time.Hour)) + + got, err := store.GetDynamicClient(ctx, "dcr-client-id") + require.NoError(t, err) + assert.Equal(t, client, got) +} + +func TestRedisStoreGetDynamicClientNotFound(t *testing.T) { + store, _ := newTestStore(t) + + _, err := store.GetDynamicClient(context.Background(), "missing-client") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreConsumeConfirmationIsOneTimeUse(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + confirmation := oauth.Confirmation{ + Handle: "confirmation-handle", + UserID: "firebase-uid", + ClientID: "client-id", + Operation: "rotate_user_api_key", + CreatedAt: time.Now().UTC().Truncate(time.Second), + } + require.NoError(t, store.PutConfirmation(ctx, confirmation, 5*time.Minute)) + + first, err := store.ConsumeConfirmation(ctx, "confirmation-handle") + require.NoError(t, err) + assert.Equal(t, confirmation, first) + + _, err = store.ConsumeConfirmation(ctx, "confirmation-handle") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreConsumeConfirmationNotFound(t *testing.T) { + store, _ := newTestStore(t) + + _, err := store.ConsumeConfirmation(context.Background(), "never-issued") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +// TestRedisStoreKeysAreNamespacedAndHashed asserts that every stored key +// uses the documented namespace prefix and never contains the raw public +// secret value as a substring -- only its SHA-256 hash may appear. +func TestRedisStoreKeysAreNamespacedAndHashed(t *testing.T) { + store, server := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.PutAuthorizationTransaction(ctx, oauth.AuthorizationTransaction{ID: "super-secret-transaction-id"}, time.Minute)) + require.NoError(t, store.PutAuthorizationCode(ctx, oauth.AuthorizationCode{Code: "super-secret-code"}, time.Minute)) + require.NoError(t, store.PutRefreshToken(ctx, oauth.RefreshGrant{Token: "super-secret-refresh-token"}, time.Hour)) + require.NoError(t, store.PutDynamicClient(ctx, oauth.Client{ID: "super-secret-client-id"}, time.Hour)) + require.NoError(t, store.PutConfirmation(ctx, oauth.Confirmation{Handle: "super-secret-handle"}, time.Minute)) + + keys := server.Keys() + require.Len(t, keys, 5) + + expectedPrefixes := []string{ + "httpsms:mcp:oauth:transaction:", + "httpsms:mcp:oauth:code:", + "httpsms:mcp:oauth:refresh:", + "httpsms:mcp:oauth:client:", + "httpsms:mcp:confirmation:", + } + + for _, key := range keys { + hasPrefix := false + for _, prefix := range expectedPrefixes { + if strings.HasPrefix(key, prefix) { + hasPrefix = true + // The remainder must be a 64-character hex-encoded SHA-256 + // digest, not the raw secret. + remainder := strings.TrimPrefix(key, prefix) + assert.Len(t, remainder, 64) + break + } + } + assert.True(t, hasPrefix, "key %q must use one of the documented namespace prefixes", key) + + assert.NotContains(t, key, "super-secret", "Redis key %q must not contain the raw secret value", key) + } +} + +func TestRedisStorePutRejectsNonPositiveTTL(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + err := store.PutAuthorizationCode(ctx, oauth.AuthorizationCode{Code: "code"}, 0) + require.Error(t, err) + + err = store.PutRefreshToken(ctx, oauth.RefreshGrant{Token: "token"}, -time.Second) + require.Error(t, err) +} + +func TestRedisStoreRotateRefreshTokenRejectsNonPositiveTTL(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.PutRefreshToken(ctx, oauth.RefreshGrant{Token: "token"}, time.Hour)) + + err := store.RotateRefreshToken(ctx, "token", oauth.RefreshGrant{Token: "new-token"}, 0) + require.Error(t, err) +} From a74840733056675804832a4f39d21d58130f51f8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 21:38:48 +0300 Subject: [PATCH 08/25] fix(mcp): harden OAuth metadata fetching - Pin the CIMD document fetch's actual TCP connection to the single IP address already validated as public (resolve exactly once), instead of letting the http.Transport dial its own second, unvalidated DNS resolution of the client_id host. Closes a DNS-rebinding/TOCTOU gap. Host header and TLS SNI still use the original hostname since only the dial address changes. - Reject CIMD responses whose Content-Type is not application/json (charset and other parameters are still allowed), wrapping ErrClientMetadataInvalid. - NewRedisStore now panics for a *redis.ClusterClient or *redis.Ring: RotateRefreshToken's Lua script touches two independently-hashed keys in one atomic EVAL, which the approved key format cannot guarantee share a Redis Cluster hash slot. This service requires a standalone Redis client (redis.NewClient); documented in RedisStore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/internal/oauth/clients.go | 170 +++++++++++++++++++++++++---- mcp/internal/oauth/clients_test.go | 119 ++++++++++++++++++++ mcp/internal/oauth/store.go | 24 +++- mcp/internal/oauth/store_test.go | 35 ++++++ 4 files changed, 327 insertions(+), 21 deletions(-) diff --git a/mcp/internal/oauth/clients.go b/mcp/internal/oauth/clients.go index 6e96ad6f..58e746a2 100644 --- a/mcp/internal/oauth/clients.go +++ b/mcp/internal/oauth/clients.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "mime" "net" "net/http" "net/url" @@ -33,9 +34,18 @@ var ( ErrClientMetadataTooLarge = errors.New("oauth: client metadata document exceeds size limit") // ErrClientMetadataInvalid is returned when a client metadata document - // is not valid JSON or is missing a required field. + // is not valid JSON, is missing a required field, or was not served + // with an application/json Content-Type. ErrClientMetadataInvalid = errors.New("oauth: client metadata document is invalid") + // errTransportCannotBePinned is returned when the *http.Client given + // to NewClientResolver uses a RoundTripper this package cannot pin a + // validated IP address into (anything other than *http.Transport or + // nil). Resolution fails closed instead of silently fetching through + // an unpinned transport, which would let the transport's own DNS + // resolution re-resolve the hostname a second, unvalidated time. + errTransportCannotBePinned = errors.New("oauth: http client transport does not support IP pinning") + // ErrClientIDMismatch is returned when a CIMD document's own // "client_id" field does not exactly equal the URL used to fetch it. ErrClientIDMismatch = errors.New("oauth: client metadata document client_id mismatch") @@ -89,6 +99,13 @@ type ClientResolver struct { // the actual document fetch is still served locally and deterministically. lookupIP func(ctx context.Context, host string) ([]net.IP, error) + // canPinTransport reports whether httpClient's Transport was + // successfully wrapped to honor a pinned IP address (see + // withPinnedIP). fetch refuses to proceed when this is false rather + // than silently falling back to letting the transport re-resolve the + // hostname itself. + canPinTransport bool + cacheMu sync.Mutex cache map[string]cachedClient } @@ -102,18 +119,92 @@ type cachedClient struct { // NewClientResolver returns a ClientResolver that fetches Client ID // Metadata Documents using httpClient (with redirects disabled) and falls // back to store for Dynamic Client Registration lookups. +// +// The document fetch resolves and validates the client_id's host exactly +// once per Resolve call: httpClient's Transport is wrapped so the actual +// TCP connection is pinned to the same IP address that was just validated +// as public, instead of letting the transport's own dialer re-resolve the +// hostname a second time (which a DNS-rebinding attacker could answer with +// a private address after passing validation). The wrap preserves the +// original hostname for the Host header and TLS SNI, since only the raw +// dial address changes -- the request URL is never rewritten. func NewClientResolver(httpClient *http.Client, store Store) *ClientResolver { safeClient := *httpClient safeClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + pinnedTransport, canPin := newPinnedTransport(safeClient.Transport) + safeClient.Transport = pinnedTransport + return &ClientResolver{ - httpClient: &safeClient, - store: store, - lookupIP: defaultLookupIP, - cache: make(map[string]cachedClient), + httpClient: &safeClient, + store: store, + lookupIP: defaultLookupIP, + canPinTransport: canPin, + cache: make(map[string]cachedClient), + } +} + +// pinnedIPContextKey is the context key under which fetch stashes the +// validated IP address that the pinned transport returned by +// newPinnedTransport must connect to. +type pinnedIPContextKey struct{} + +// withPinnedIP returns a context carrying ip as the address the pinned +// transport must dial for the request built from it, regardless of what +// the request's hostname would otherwise resolve to. +func withPinnedIP(ctx context.Context, ip net.IP) context.Context { + return context.WithValue(ctx, pinnedIPContextKey{}, ip) +} + +// pinnedIPFromContext returns the IP address stashed by withPinnedIP, if +// any. +func pinnedIPFromContext(ctx context.Context) (net.IP, bool) { + ip, ok := ctx.Value(pinnedIPContextKey{}).(net.IP) + return ip, ok +} + +// newPinnedTransport returns a RoundTripper that behaves exactly like base +// (or a fresh clone of http.DefaultTransport when base is nil), except +// that its dial address's host is replaced with the IP address stashed via +// withPinnedIP on the request's context, when present. The port and every +// other transport behavior (TLS config, proxies, timeouts, and -- in +// tests -- a stubbed DialContext that redirects to a local test server) +// are left untouched, so the request's Host header and TLS ServerName, +// both driven by the unmodified request URL, keep the original hostname. +// +// It reports ok=false when base is a RoundTripper this package cannot +// safely wrap (anything other than *http.Transport or nil); callers must +// then refuse to fetch rather than silently using an unpinned connection. +func newPinnedTransport(base http.RoundTripper) (transport http.RoundTripper, ok bool) { + var httpTransport *http.Transport + switch t := base.(type) { + case *http.Transport: + httpTransport = t.Clone() + case nil: + defaultTransport, isHTTPTransport := http.DefaultTransport.(*http.Transport) + if !isHTTPTransport { + return base, false + } + httpTransport = defaultTransport.Clone() + default: + return base, false + } + + originalDial := httpTransport.DialContext + if originalDial == nil { + originalDial = (&net.Dialer{}).DialContext + } + httpTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if ip, hasPin := pinnedIPFromContext(ctx); hasPin { + if _, port, splitErr := net.SplitHostPort(addr); splitErr == nil { + addr = net.JoinHostPort(ip.String(), port) + } + } + return originalDial(ctx, network, addr) } + return httpTransport, true } // defaultLookupIP resolves host through net.DefaultResolver. @@ -148,7 +239,15 @@ func (r *ClientResolver) resolveCIMD(ctx context.Context, clientID string, parse return Client{}, fmt.Errorf("%w: client metadata document must use https, got %q", ErrUnsafeClientMetadataURL, parsed.Scheme) } - if err := r.validateHostIsPublic(ctx, parsed.Hostname()); err != nil { + // Resolve and validate the host exactly once. pinnedIP is the single + // address every subsequent step trusts: validateHostIsPublic already + // proved it (and, for a DNS name, every other address the name + // resolved to) is not private/loopback/link-local, and fetch pins the + // actual connection to this same address so the transport's own + // dialer never gets a chance to re-resolve the hostname and receive a + // different (rebound) answer. + pinnedIP, err := r.validateHostIsPublic(ctx, parsed.Hostname()) + if err != nil { return Client{}, err } @@ -156,7 +255,7 @@ func (r *ClientResolver) resolveCIMD(ctx context.Context, clientID string, parse return client, nil } - body, err := r.fetch(ctx, clientID) + body, err := r.fetch(ctx, clientID, pinnedIP) if err != nil { return Client{}, err } @@ -172,35 +271,48 @@ func (r *ClientResolver) resolveCIMD(ctx context.Context, clientID string, parse // validateHostIsPublic returns ErrUnsafeClientMetadataURL when host (a // literal IP or a DNS name) does not resolve exclusively to public -// addresses. -func (r *ClientResolver) validateHostIsPublic(ctx context.Context, host string) error { +// addresses. On success it returns the single IP address the caller must +// pin its connection to: host itself when host is already a literal IP, or +// the first of host's resolved addresses (all of which were just proven +// public) when host is a DNS name. +func (r *ClientResolver) validateHostIsPublic(ctx context.Context, host string) (net.IP, error) { if ip := net.ParseIP(host); ip != nil { if !isPublicIP(ip) { - return fmt.Errorf("%w: %q is not a public address", ErrUnsafeClientMetadataURL, host) + return nil, fmt.Errorf("%w: %q is not a public address", ErrUnsafeClientMetadataURL, host) } - return nil + return ip, nil } ips, err := r.lookupIP(ctx, host) if err != nil { - return fmt.Errorf("oauth: cannot resolve client metadata document host %q: %w", host, err) + return nil, fmt.Errorf("oauth: cannot resolve client metadata document host %q: %w", host, err) } if len(ips) == 0 { - return fmt.Errorf("%w: %q did not resolve to any address", ErrUnsafeClientMetadataURL, host) + return nil, fmt.Errorf("%w: %q did not resolve to any address", ErrUnsafeClientMetadataURL, host) } - for _, ip := range ips { - if !isPublicIP(ip) { - return fmt.Errorf("%w: %q resolves to a non-public address", ErrUnsafeClientMetadataURL, host) + for _, candidate := range ips { + if !isPublicIP(candidate) { + return nil, fmt.Errorf("%w: %q resolves to a non-public address", ErrUnsafeClientMetadataURL, host) } } - return nil + return ips[0], nil } -// fetch retrieves clientID's document body, rejecting redirects and -// limiting the response to maxClientMetadataBytes. -func (r *ClientResolver) fetch(ctx context.Context, clientID string) ([]byte, error) { +// fetch retrieves clientID's document body, rejecting redirects, requiring +// an application/json response, and limiting the response to +// maxClientMetadataBytes. The connection is pinned to pinnedIP: the +// request's URL (and therefore its Host header and TLS ServerName) still +// names clientID's original hostname, but the raw TCP dial address's host +// is replaced with pinnedIP so the transport's dialer cannot resolve the +// hostname a second, unvalidated time. +func (r *ClientResolver) fetch(ctx context.Context, clientID string, pinnedIP net.IP) ([]byte, error) { + if !r.canPinTransport { + return nil, fmt.Errorf("%w: cannot safely fetch client metadata document", errTransportCannotBePinned) + } + ctx, cancel := context.WithTimeout(ctx, cimdFetchTimeout) defer cancel() + ctx = withPinnedIP(ctx, pinnedIP) req, err := http.NewRequestWithContext(ctx, http.MethodGet, clientID, nil) if err != nil { @@ -221,6 +333,10 @@ func (r *ClientResolver) fetch(ctx context.Context, clientID string) ([]byte, er return nil, fmt.Errorf("%w: received status %d", ErrClientMetadataInvalid, resp.StatusCode) } + if err := requireJSONContentType(resp.Header.Get("Content-Type")); err != nil { + return nil, err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxClientMetadataBytes+1)) if err != nil { return nil, fmt.Errorf("oauth: cannot read client metadata document: %w", err) @@ -231,6 +347,20 @@ func (r *ClientResolver) fetch(ctx context.Context, clientID string) ([]byte, er return body, nil } +// requireJSONContentType returns ErrClientMetadataInvalid when +// contentType's media type is not exactly "application/json". Parameters +// such as "; charset=utf-8" are permitted and ignored. +func requireJSONContentType(contentType string) error { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return fmt.Errorf("%w: unparseable content-type %q, want \"application/json\"", ErrClientMetadataInvalid, contentType) + } + if mediaType != "application/json" { + return fmt.Errorf("%w: unexpected content-type %q, want \"application/json\"", ErrClientMetadataInvalid, mediaType) + } + return nil +} + // cached returns the still-valid cached Client for clientID, if any. func (r *ClientResolver) cached(clientID string) (Client, bool) { r.cacheMu.Lock() diff --git a/mcp/internal/oauth/clients_test.go b/mcp/internal/oauth/clients_test.go index cd17cc62..763db89a 100644 --- a/mcp/internal/oauth/clients_test.go +++ b/mcp/internal/oauth/clients_test.go @@ -112,6 +112,125 @@ func TestClientResolverRejectsLinkLocalDNSResult(t *testing.T) { require.ErrorIs(t, err, ErrUnsafeClientMetadataURL) } +// TestClientResolverPinsConnectionToValidatedIPPreventingDNSRebinding is a +// regression test for a DNS-rebinding/TOCTOU gap: a naive implementation +// validates a hostname's resolved IP once (via lookupIP) but then lets the +// HTTP transport dial the request using its own, independent DNS +// resolution of the same hostname. Between those two lookups an attacker +// controlling the DNS answer for the client_id host can "rebind" it to a +// private/internal address, so the connection that is actually made is +// never the one that was validated. +// +// This test's stub transport records the host portion of every dial +// address it is asked to connect to (before honoring the test-only +// redirect-to-local-server behavior every other test in this file also +// relies on) and asserts it is exactly the validation-time IP -- never a +// live, second resolution of the hostname -- proving the resolver pins the +// real connection to the address it already proved public. +func TestClientResolverPinsConnectionToValidatedIPPreventingDNSRebinding(t *testing.T) { + const clientID = "https://client.example/client.json" + const validatedPublicIP = "203.0.113.10" // TEST-NET-3 (RFC 5737): public-looking, non-routable-in-practice. + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{"https://client.example/callback"})) + })) + defer server.Close() + + realServerAddr := server.Listener.Addr().String() + var dialedHost string + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + require.NoError(t, err) + dialedHost = host + + // Even though the real dial target below is the local test + // server (exactly like every other test in this file), the + // address this func was *asked* to dial is what matters here: + // it proves what host the resolver's own logic pinned, + // independent of however this stub chooses to actually + // satisfy the connection. + var dialer net.Dialer + return dialer.DialContext(ctx, network, realServerAddr) + }, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test-only: bypasses hostname check for a locally redirected dial + } + t.Cleanup(transport.CloseIdleConnections) + + resolver := NewClientResolver(&http.Client{Transport: transport}, newClientsTestStore(t)) + // Simulate the validation-time DNS answer for "client.example" being a + // safe public address. If the resolver later let the transport + // re-resolve "client.example" itself (the bug this test guards + // against), the dial address recorded above would be the literal + // hostname "client.example", not this IP -- and in a real + // DNS-rebinding attack, a second live lookup could answer with a + // private address instead. + resolver.lookupIP = func(context.Context, string) ([]net.IP, error) { + return []net.IP{net.ParseIP(validatedPublicIP)}, nil + } + + _, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.Equal(t, validatedPublicIP, dialedHost, + "the actual TCP connection must be pinned to the validated IP address, not a second, unvalidated resolution of the hostname") +} + +// TestClientResolverFetchFailsWhenTransportCannotBePinned proves the +// resolver fails closed -- rather than silently fetching through an +// unpinned (and therefore DNS-rebindable) connection -- when it is given +// an *http.Client whose Transport is not an *http.Transport it can wrap. +func TestClientResolverFetchFailsWhenTransportCannotBePinned(t *testing.T) { + unpinnable := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("RoundTrip must not be called: fetch must fail before attempting to use an unpinnable transport") + return nil, nil + })} + + resolver := NewClientResolver(unpinnable, newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), "https://client.example/client.json") + require.ErrorIs(t, err, errTransportCannotBePinned) +} + +// roundTripperFunc adapts a function to http.RoundTripper. +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestClientResolverRejectsNonJSONContentType(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{"https://client.example/callback"})) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + _, err := resolver.Resolve(context.Background(), clientID) + require.ErrorIs(t, err, ErrClientMetadataInvalid) +} + +func TestClientResolverAcceptsJSONContentTypeWithCharsetParameter(t *testing.T) { + const clientID = "https://client.example/client.json" + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(validTestClient(clientID, []string{"https://client.example/callback"})) + })) + defer server.Close() + + resolver := NewClientResolver(newLocalizedHTTPClient(t, server), newClientsTestStore(t)) + resolver.lookupIP = publicLookupIP + + client, err := resolver.Resolve(context.Background(), clientID) + require.NoError(t, err) + assert.Equal(t, clientID, client.ID) +} + func TestClientResolverAcceptsValidClientMetadataDocument(t *testing.T) { const clientID = "https://client.example/client.json" diff --git a/mcp/internal/oauth/store.go b/mcp/internal/oauth/store.go index 0383543d..4a6b69d2 100644 --- a/mcp/internal/oauth/store.go +++ b/mcp/internal/oauth/store.go @@ -155,12 +155,34 @@ return 1 `) // RedisStore is the Redis-backed implementation of Store. +// +// RedisStore requires a standalone Redis deployment (a client created with +// redis.NewClient), not a Redis Cluster or Ring client. The five key +// namespaces above are an approved, fixed format that must not change, and +// RotateRefreshToken's Lua script touches two keys derived from unrelated +// hashes (the old and new refresh-token hashes) in a single atomic EVAL -- +// Redis Cluster requires all keys touched by one command to hash to the +// same hash slot, and this key format gives no such guarantee, so the +// script would fail against a cluster with a CROSSSLOT error. This is an +// intentional constraint of this service, not an oversight: it is not +// safe to point RedisStore at a Redis Cluster or Ring client. type RedisStore struct { client redis.UniversalClient } -// NewRedisStore returns a Store backed by client. +// NewRedisStore returns a Store backed by client. client must be a +// standalone Redis client (redis.NewClient); NewRedisStore panics if given +// a *redis.ClusterClient or *redis.Ring, since RotateRefreshToken's +// cross-slot Lua script cannot run against a cluster (see the RedisStore +// doc comment). The constructor still accepts the redis.UniversalClient +// interface so callers can pass through *redis.Client without an +// unnecessary concrete-type dependency; only these two known-incompatible +// concrete types are rejected. func NewRedisStore(client redis.UniversalClient) *RedisStore { + switch client.(type) { + case *redis.ClusterClient, *redis.Ring: + panic("oauth: NewRedisStore requires a standalone Redis client (redis.NewClient); a Redis Cluster or Ring client cannot run the cross-slot refresh-token rotation script") + } return &RedisStore{client: client} } diff --git a/mcp/internal/oauth/store_test.go b/mcp/internal/oauth/store_test.go index 866a4762..76b930e7 100644 --- a/mcp/internal/oauth/store_test.go +++ b/mcp/internal/oauth/store_test.go @@ -277,3 +277,38 @@ func TestRedisStoreRotateRefreshTokenRejectsNonPositiveTTL(t *testing.T) { err := store.RotateRefreshToken(ctx, "token", oauth.RefreshGrant{Token: "new-token"}, 0) require.Error(t, err) } + +// TestNewRedisStorePanicsOnClusterClient asserts NewRedisStore fails fast +// for a *redis.ClusterClient: RotateRefreshToken's Lua script touches two +// keys (the old and new refresh-token hashes) that this service's approved +// key format gives no cross-key hash-slot guarantee for, so the script +// would fail against a real cluster with a CROSSSLOT error. This service +// is intentionally constrained to a standalone Redis deployment +// (redis.NewClient); the key format itself must not change to work around +// this, per the accepted design constraint. +func TestNewRedisStorePanicsOnClusterClient(t *testing.T) { + clusterClient := redis.NewClusterClient(&redis.ClusterOptions{Addrs: []string{"127.0.0.1:0"}}) + t.Cleanup(func() { _ = clusterClient.Close() }) + + assert.Panics(t, func() { oauth.NewRedisStore(clusterClient) }) +} + +// TestNewRedisStorePanicsOnRingClient is the Ring-client counterpart of +// TestNewRedisStorePanicsOnClusterClient: a Ring client also shards keys +// across independent Redis nodes by hash, so the same cross-slot rotation +// script cannot safely run against it either. +func TestNewRedisStorePanicsOnRingClient(t *testing.T) { + ringClient := redis.NewRing(&redis.RingOptions{Addrs: map[string]string{"shard0": "127.0.0.1:0"}}) + t.Cleanup(func() { _ = ringClient.Close() }) + + assert.Panics(t, func() { oauth.NewRedisStore(ringClient) }) +} + +// TestNewRedisStoreAcceptsStandaloneClient documents the supported, +// required configuration: a plain redis.NewClient must not panic. +func TestNewRedisStoreAcceptsStandaloneClient(t *testing.T) { + standaloneClient := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}) + t.Cleanup(func() { _ = standaloneClient.Close() }) + + assert.NotPanics(t, func() { oauth.NewRedisStore(standaloneClient) }) +} From dfcf5e21878c165c1c3dc4592e8e1fb7498e44c9 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 21:58:09 +0300 Subject: [PATCH 09/25] feat(mcp): add Firebase OAuth flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/internal/auth/firebase.go | 260 +++++++++ mcp/internal/auth/firebase_test.go | 329 +++++++++++ mcp/internal/oauth/authorize.go | 475 ++++++++++++++++ mcp/internal/oauth/authorize_test.go | 569 ++++++++++++++++++++ mcp/internal/oauth/store.go | 18 + mcp/internal/oauth/store_test.go | 40 ++ mcp/internal/oauth/templates/authorize.html | 59 ++ mcp/internal/oauth/token.go | 235 ++++++++ mcp/internal/oauth/token_test.go | 387 +++++++++++++ 9 files changed, 2372 insertions(+) create mode 100644 mcp/internal/auth/firebase.go create mode 100644 mcp/internal/auth/firebase_test.go create mode 100644 mcp/internal/oauth/authorize.go create mode 100644 mcp/internal/oauth/authorize_test.go create mode 100644 mcp/internal/oauth/templates/authorize.html create mode 100644 mcp/internal/oauth/token.go create mode 100644 mcp/internal/oauth/token_test.go diff --git a/mcp/internal/auth/firebase.go b/mcp/internal/auth/firebase.go new file mode 100644 index 00000000..7d3ef013 --- /dev/null +++ b/mcp/internal/auth/firebase.go @@ -0,0 +1,260 @@ +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 +) + +// 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") + +// 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"` + 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. +func NewFirebaseVerifier(projectID string, certsURL string, httpClient *http.Client, cacheTTL 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), + }, 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), 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 == "" { + return Principal{}, ErrInvalidIdentityToken + } + + return Principal{UserID: claims.Subject, Email: claims.Email}, nil +} + +// 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. It refreshes at most once per call when +// a requested "kid" is not (or no longer) cached, and otherwise refreshes +// only after cacheTTL has elapsed since the last successful fetch -- the +// same bounded refresh-on-missing-kid behavior used by the httpSMS API's +// delegated MCP token verifier (api/pkg/auth's JWKS cache), applied here to +// Google's certificate-map response shape instead of a JWKS document. +type firebaseCertCache struct { + url string + httpClient *http.Client + cacheTTL time.Duration + + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +// newFirebaseCertCache builds a firebaseCertCache for url. +func newFirebaseCertCache(url string, httpClient *http.Client, cacheTTL 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 + } + + return &firebaseCertCache{ + url: url, + httpClient: client, + cacheTTL: cacheTTL, + keys: map[string]*rsa.PublicKey{}, + } +} + +// key returns the cached RSA public key for kid, refreshing the +// certificate map at most once per call when the cache is stale or the key +// is not yet known. +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.refresh(ctx); err != 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 +} + +// 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..d4f2c31b --- /dev/null +++ b/mcp/internal/auth/firebase_test.go @@ -0,0 +1,329 @@ +package auth_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "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"` + 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", + 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. +func newTestVerifier(t *testing.T, certsURL string, client *http.Client) *auth.FirebaseVerifier { + t.Helper() + + verifier, err := auth.NewFirebaseVerifier(testFirebaseProjectID, certsURL, client, 0) + require.NoError(t, err) + + return verifier +} + +func TestNewFirebaseVerifierRequiresProjectID(t *testing.T) { + _, err := auth.NewFirebaseVerifier("", "https://example.com/certs", nil, 0) + require.Error(t, err) +} + +func TestNewFirebaseVerifierRequiresCertsURL(t *testing.T) { + _, err := auth.NewFirebaseVerifier("httpsms-test", "", nil, 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. +func TestFirebaseVerifierCachesCertificatesAndRefreshesOnRotation(t *testing.T) { + firstKey := testRSAKeyPair(t) + secondKey := testRSAKeyPair(t) + firstCert := selfSignedCertificatePEM(t, firstKey) + secondCert := selfSignedCertificatePEM(t, secondKey) + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if requestCount == 1 { + firebaseCertsHandler(map[string]string{"key-1": firstCert})(w, r) + return + } + firebaseCertsHandler(map[string]string{"key-2": secondCert})(w, r) + })) + defer server.Close() + + verifier := newTestVerifier(t, server.URL, server.Client()) + + firstToken := signFirebaseToken(t, firstKey, "key-1", validFirebaseClaims()) + _, err := verifier.Verify(context.Background(), firstToken) + require.NoError(t, err) + assert.Equal(t, 1, requestCount) + + // 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, 1, requestCount) + + // The cache only has "key-1"; a token signed with the newly rotated + // "key-2" forces exactly one bounded refresh before it can verify. + 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, 2, requestCount) +} + +// 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) + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + 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) + 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, 1, requestCount) + + time.Sleep(20 * time.Millisecond) + + _, err = verifier.Verify(context.Background(), raw) + require.NoError(t, err) + assert.Equal(t, 2, requestCount) +} diff --git a/mcp/internal/oauth/authorize.go b/mcp/internal/oauth/authorize.go new file mode 100644 index 00000000..d6a49422 --- /dev/null +++ b/mcp/internal/oauth/authorize.go @@ -0,0 +1,475 @@ +package oauth + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "net/http" + "net/url" + "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 +) + +//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() + + 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 codeChallenge == "" || codeChallengeMethod != "S256" { + s.redirectError(w, r, redirectURI, state, "invalid_request", "a S256 code_challenge 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). +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 err := r.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "cannot parse request body") + 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 { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "authorization transaction not found or expired") + return + } + + if r.PostFormValue("denied") != "" { + 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 { + writeOAuthError(w, http.StatusUnauthorized, "access_denied", "the identity token could not be verified") + 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, + }) + http.Redirect(w, r, target, http.StatusFound) +} + +// renderAuthorizePage writes the Firebase login/consent page for +// transaction and client. +func (s *Server) renderAuthorizePage(w http.ResponseWriter, transaction AuthorizationTransaction, client Client) { + data := struct { + FirebaseAPIKey string + FirebaseAuthDomain string + TransactionID string + ClientName string + Scopes []struct{ Value, Description string } + }{ + FirebaseAPIKey: s.config.FirebaseAPIKey, + FirebaseAuthDomain: s.config.FirebaseAuthDomain, + TransactionID: transaction.ID, + ClientName: client.Name, + } + 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") + w.WriteHeader(http.StatusOK) + _ = s.templates.ExecuteTemplate(w, "authorize.html", data) +} + +// 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, + }) + http.Redirect(w, r, target, http.StatusFound) +} + +// 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 and validates every entry against the fixed Scopes list, +// 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 + } + for _, field := range fields { + if !known[field] { + return nil, fmt.Errorf("unsupported scope %q", field) + } + } + return fields, 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..66aeec15 --- /dev/null +++ b/mcp/internal/oauth/authorize_test.go @@ -0,0 +1,569 @@ +package oauth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "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 +} + +// 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")) +} + +// postForm posts values to handler as an application/x-www-form-urlencoded +// request and returns the recorded response. +func postForm(t *testing.T, handler http.HandlerFunc, values url.Values) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.PostForm = values + req.Form = values + rec := httptest.NewRecorder() + + handler(rec, req) + + return rec +} diff --git a/mcp/internal/oauth/store.go b/mcp/internal/oauth/store.go index 4a6b69d2..674f32f5 100644 --- a/mcp/internal/oauth/store.go +++ b/mcp/internal/oauth/store.go @@ -51,6 +51,7 @@ type AuthorizationTransaction struct { RedirectURI string `json:"redirect_uri"` Scopes []string `json:"scopes"` State string `json:"state"` + Resource string `json:"resource"` CodeChallenge string `json:"code_challenge"` CodeChallengeMethod string `json:"code_challenge_method"` ResponseType string `json:"response_type"` @@ -69,6 +70,7 @@ type AuthorizationCode struct { Scopes []string `json:"scopes"` UserID string `json:"user_id"` Email string `json:"email"` + Resource string `json:"resource"` CodeChallenge string `json:"code_challenge"` CodeChallengeMethod string `json:"code_challenge_method"` CreatedAt time.Time `json:"created_at"` @@ -85,6 +87,7 @@ type RefreshGrant struct { Email string `json:"email"` ClientID string `json:"client_id"` Scopes []string `json:"scopes"` + Resource string `json:"resource"` FamilyID string `json:"family_id"` CreatedAt time.Time `json:"created_at"` } @@ -130,6 +133,7 @@ type Store interface { PutAuthorizationCode(context.Context, AuthorizationCode, time.Duration) error ConsumeAuthorizationCode(context.Context, string) (AuthorizationCode, error) PutRefreshToken(context.Context, RefreshGrant, time.Duration) error + GetRefreshToken(context.Context, string) (RefreshGrant, error) RotateRefreshToken(context.Context, string, RefreshGrant, time.Duration) error PutDynamicClient(context.Context, Client, time.Duration) error GetDynamicClient(context.Context, string) (Client, error) @@ -227,6 +231,20 @@ func (s *RedisStore) PutRefreshToken(ctx context.Context, grant RefreshGrant, tt return putRecord(ctx, s.client, keyPrefixRefresh, grant.Token, grant, ttl) } +// GetRefreshToken implements Store. Unlike RotateRefreshToken it does not +// consume the record: the token endpoint needs to read a refresh grant's +// bound user/client/scopes/resource before it can validate a refresh +// request and build the rotated replacement grant RotateRefreshToken then +// atomically swaps in. A refresh token that has already been rotated or +// has expired returns ErrNotFound, exactly as RotateRefreshToken's replay +// check would. +func (s *RedisStore) GetRefreshToken(ctx context.Context, token string) (RefreshGrant, error) { + var grant RefreshGrant + err := getRecord(ctx, s.client, keyPrefixRefresh, token, &grant) + grant.Token = token + return grant, err +} + // RotateRefreshToken implements Store. It atomically deletes oldToken's // record and creates newGrant's record with ttl; a second rotation attempt // against the same oldToken (replay) returns ErrNotFound. diff --git a/mcp/internal/oauth/store_test.go b/mcp/internal/oauth/store_test.go index 76b930e7..70441701 100644 --- a/mcp/internal/oauth/store_test.go +++ b/mcp/internal/oauth/store_test.go @@ -161,6 +161,46 @@ func TestRedisStoreRotateRefreshTokenUnknownOldTokenFails(t *testing.T) { require.ErrorIs(t, err, oauth.ErrNotFound) } +// TestRedisStoreGetRefreshTokenReadsWithoutConsuming asserts GetRefreshToken +// is a plain read: the token endpoint must be able to inspect a refresh +// grant's bound user/client/scopes/resource before rotating it, and the +// grant must still be present (and still rotatable) afterward. +func TestRedisStoreGetRefreshTokenReadsWithoutConsuming(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + grant := oauth.RefreshGrant{ + Token: "refresh-token", + UserID: "firebase-uid", + Email: "user@example.com", + ClientID: "https://client.example/metadata.json", + Scopes: []string{"phones:read", "messages:send"}, + Resource: "https://mcp.httpsms.com/mcp", + FamilyID: "family-1", + CreatedAt: time.Now().UTC().Truncate(time.Second), + } + require.NoError(t, store.PutRefreshToken(ctx, grant, time.Hour)) + + got, err := store.GetRefreshToken(ctx, "refresh-token") + require.NoError(t, err) + assert.Equal(t, grant, got) + + // Reading again must still succeed (not consumed). + got2, err := store.GetRefreshToken(ctx, "refresh-token") + require.NoError(t, err) + assert.Equal(t, grant, got2) + + // The grant must still be rotatable, proving Get did not delete it. + require.NoError(t, store.RotateRefreshToken(ctx, "refresh-token", oauth.RefreshGrant{Token: "rotated-token"}, time.Hour)) +} + +func TestRedisStoreGetRefreshTokenNotFound(t *testing.T) { + store, _ := newTestStore(t) + + _, err := store.GetRefreshToken(context.Background(), "never-issued") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + func TestRedisStorePutGetDynamicClient(t *testing.T) { store, _ := newTestStore(t) ctx := context.Background() diff --git a/mcp/internal/oauth/templates/authorize.html b/mcp/internal/oauth/templates/authorize.html new file mode 100644 index 00000000..11265289 --- /dev/null +++ b/mcp/internal/oauth/templates/authorize.html @@ -0,0 +1,59 @@ + + + + + + Authorize {{.ClientName}} - httpSMS + + + + +

{{.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..2bdc8220 --- /dev/null +++ b/mcp/internal/oauth/token.go @@ -0,0 +1,235 @@ +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"` +} + +// 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). 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 err := r.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "cannot parse request body") + 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 { + writeOAuthError(w, http.StatusBadRequest, "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 { + writeOAuthError(w, http.StatusBadRequest, "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 { + writeOAuthError(w, http.StatusBadRequest, "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..c3f3585f --- /dev/null +++ b/mcp/internal/oauth/token_test.go @@ -0,0 +1,387 @@ +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")) +} From e4ab70fb9dff84d69a49c346877954c022967183 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 22:26:51 +0300 Subject: [PATCH 10/25] fix(mcp): harden OAuth authorization flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/internal/auth/firebase.go | 159 +++++- mcp/internal/auth/firebase_test.go | 214 ++++++++- mcp/internal/oauth/authorize.go | 167 ++++++- mcp/internal/oauth/authorize_test.go | 508 +++++++++++++++++++- mcp/internal/oauth/metadata.go | 46 +- mcp/internal/oauth/metadata_test.go | 39 +- mcp/internal/oauth/store.go | 20 +- mcp/internal/oauth/store_test.go | 40 ++ mcp/internal/oauth/templates/authorize.html | 62 ++- mcp/internal/oauth/token.go | 34 +- mcp/internal/oauth/token_test.go | 129 +++++ 11 files changed, 1316 insertions(+), 102 deletions(-) diff --git a/mcp/internal/auth/firebase.go b/mcp/internal/auth/firebase.go index 7d3ef013..c2e7de56 100644 --- a/mcp/internal/auth/firebase.go +++ b/mcp/internal/auth/firebase.go @@ -21,6 +21,20 @@ 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 @@ -30,6 +44,10 @@ const ( // 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. @@ -43,6 +61,12 @@ type IdentityVerifier interface { 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 } @@ -64,7 +88,10 @@ type FirebaseVerifier struct { // 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. -func NewFirebaseVerifier(projectID string, certsURL string, httpClient *http.Client, cacheTTL time.Duration) (*FirebaseVerifier, error) { +// 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") } @@ -74,14 +101,15 @@ func NewFirebaseVerifier(projectID string, certsURL string, httpClient *http.Cli return &FirebaseVerifier{ projectID: projectID, - certs: newFirebaseCertCache(certsURL, httpClient, cacheTTL), + 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), and carrying a non-empty subject. +// 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( @@ -93,13 +121,28 @@ func (v *FirebaseVerifier) Verify(ctx context.Context, raw string) (Principal, e jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), ) - if err != nil || !token.Valid || claims.Subject == "" { + 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 { @@ -121,24 +164,43 @@ func (v *FirebaseVerifier) keyfunc(ctx context.Context) jwt.Keyfunc { // 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. It refreshes at most once per call when -// a requested "kid" is not (or no longer) cached, and otherwise refreshes -// only after cacheTTL has elapsed since the last successful fetch -- the -// same bounded refresh-on-missing-kid behavior used by the httpSMS API's -// delegated MCP token verifier (api/pkg/auth's JWKS cache), applied here to -// Google's certificate-map response shape instead of a JWKS document. +// 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 + 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 +} - mu sync.Mutex - keys map[string]*rsa.PublicKey - fetchedAt time.Time +// 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) *firebaseCertCache { +func newFirebaseCertCache(url string, httpClient *http.Client, cacheTTL time.Duration, minRefreshInterval time.Duration) *firebaseCertCache { if httpClient == nil { httpClient = http.DefaultClient } @@ -153,18 +215,23 @@ func newFirebaseCertCache(url string, httpClient *http.Client, cacheTTL time.Dur if cacheTTL <= 0 { cacheTTL = firebaseCertsDefaultCacheTTL } + if minRefreshInterval <= 0 { + minRefreshInterval = firebaseCertsDefaultMinRefreshInterval + } return &firebaseCertCache{ - url: url, - httpClient: client, - cacheTTL: cacheTTL, - keys: map[string]*rsa.PublicKey{}, + 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 at most once per call when the cache is stale or the key -// is not yet known. +// 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] @@ -175,7 +242,14 @@ func (cache *firebaseCertCache) key(ctx context.Context, kid string) (*rsa.Publi return key, nil } - if err := cache.refresh(ctx); err != 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) } @@ -189,6 +263,43 @@ func (cache *firebaseCertCache) key(ctx context.Context, kid string) (*rsa.Publi 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) diff --git a/mcp/internal/auth/firebase_test.go b/mcp/internal/auth/firebase_test.go index d4f2c31b..59dfd8f0 100644 --- a/mcp/internal/auth/firebase_test.go +++ b/mcp/internal/auth/firebase_test.go @@ -8,9 +8,12 @@ import ( "crypto/x509/pkix" "encoding/json" "encoding/pem" + "fmt" "math/big" "net/http" "net/http/httptest" + "sync" + "sync/atomic" "testing" "time" @@ -30,8 +33,9 @@ const ( // 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"` + Email string `json:"email,omitempty"` + UserID string `json:"user_id,omitempty"` + AuthTime *jwt.NumericDate `json:"auth_time,omitempty"` jwt.RegisteredClaims } @@ -40,8 +44,9 @@ type firebaseTestClaims struct { func validFirebaseClaims() firebaseTestClaims { now := time.Now() return firebaseTestClaims{ - Email: "user@example.com", - UserID: "user-id", + Email: "user@example.com", + UserID: "user-id", + AuthTime: jwt.NewNumericDate(now.Add(-time.Minute)), RegisteredClaims: jwt.RegisteredClaims{ Issuer: testFirebaseIssuer, Subject: "user-id", @@ -106,23 +111,24 @@ func signFirebaseToken(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt } // newTestVerifier builds a FirebaseVerifier pointed at a test certificate -// endpoint. +// 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) + 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) + _, 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) + _, err := auth.NewFirebaseVerifier("httpsms-test", "", nil, 0, 0) require.Error(t, err) } @@ -259,17 +265,17 @@ func TestFirebaseVerifierRejectsTokenWhenCertsEndpointUnavailable(t *testing.T) // 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. +// 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) - requestCount := 0 + var requestCount atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - if requestCount == 1 { + if requestCount.Add(1) == 1 { firebaseCertsHandler(map[string]string{"key-1": firstCert})(w, r) return } @@ -277,26 +283,29 @@ func TestFirebaseVerifierCachesCertificatesAndRefreshesOnRotation(t *testing.T) })) defer server.Close() - verifier := newTestVerifier(t, server.URL, server.Client()) + 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) + _, err = verifier.Verify(context.Background(), firstToken) require.NoError(t, err) - assert.Equal(t, 1, requestCount) + 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, 1, requestCount) + 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. + // "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, 2, requestCount) + assert.Equal(t, int64(2), requestCount.Load()) } // TestFirebaseVerifierRefreshesAfterCacheTTLExpires asserts the cache also @@ -305,25 +314,184 @@ func TestFirebaseVerifierRefreshesAfterCacheTTLExpires(t *testing.T) { key := testRSAKeyPair(t) certPEM := selfSignedCertificatePEM(t, key) - requestCount := 0 + var requestCount atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ + 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) + 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, 1, requestCount) + 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, 2, requestCount) + 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/oauth/authorize.go b/mcp/internal/oauth/authorize.go index d6a49422..618df097 100644 --- a/mcp/internal/oauth/authorize.go +++ b/mcp/internal/oauth/authorize.go @@ -6,8 +6,10 @@ import ( "errors" "fmt" "html/template" + "mime" "net/http" "net/url" + "regexp" "strings" "time" @@ -29,8 +31,46 @@ const ( // 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 @@ -176,6 +216,11 @@ func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) { 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") @@ -207,8 +252,8 @@ func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) { codeChallenge := query.Get("code_challenge") codeChallengeMethod := query.Get("code_challenge_method") - if codeChallenge == "" || codeChallengeMethod != "S256" { - s.redirectError(w, r, redirectURI, state, "invalid_request", "a S256 code_challenge is required") + 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 } @@ -258,7 +303,15 @@ func (s *Server) HandleAuthorize(w http.ResponseWriter, r *http.Request) { // 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). +// 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) @@ -266,8 +319,12 @@ func (s *Server) HandleFirebaseComplete(w http.ResponseWriter, r *http.Request) return } - if err := r.ParseForm(); err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "cannot parse request body") + 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 } @@ -279,11 +336,15 @@ func (s *Server) HandleFirebaseComplete(w http.ResponseWriter, r *http.Request) transaction, err := s.store.GetAuthorizationTransaction(r.Context(), transactionID) if err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "authorization transaction not found or expired") + 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 } @@ -295,11 +356,22 @@ func (s *Server) HandleFirebaseComplete(w http.ResponseWriter, r *http.Request) } principal, err := s.verifier.Verify(r.Context(), idToken) - if err != nil { + 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") @@ -334,7 +406,7 @@ func (s *Server) HandleFirebaseComplete(w http.ResponseWriter, r *http.Request) "state": transaction.State, "iss": s.config.Issuer, }) - http.Redirect(w, r, target, http.StatusFound) + s.redirect(w, r, target) } // renderAuthorizePage writes the Firebase login/consent page for @@ -361,10 +433,29 @@ func (s *Server) renderAuthorizePage(w http.ResponseWriter, transaction Authoriz } 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", "frame-ancestors 'none'") + w.Header().Set("Referrer-Policy", "no-referrer") w.WriteHeader(http.StatusOK) _ = s.templates.ExecuteTemplate(w, "authorize.html", data) } +// 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. @@ -375,7 +466,52 @@ func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, redirectU "state": state, "iss": s.config.Issuer, }) - http.Redirect(w, r, target, http.StatusFound) + 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 @@ -411,7 +547,8 @@ func containsExact(list []string, value string) bool { } // parseRequestedScopes splits raw (an OAuth "scope" parameter) on -// whitespace and validates every entry against the fixed Scopes list, +// 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) @@ -423,12 +560,20 @@ func parseRequestedScopes(raw string) ([]string, error) { 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 fields, nil + return scopes, nil } // intersectApprovedScopes returns the entries of approved that were also diff --git a/mcp/internal/oauth/authorize_test.go b/mcp/internal/oauth/authorize_test.go index 66aeec15..d7ac906a 100644 --- a/mcp/internal/oauth/authorize_test.go +++ b/mcp/internal/oauth/authorize_test.go @@ -8,10 +8,13 @@ import ( "crypto/x509" "encoding/base64" "encoding/pem" + "errors" "net/http" "net/http/httptest" "net/url" "regexp" + "strings" + "sync" "testing" "time" @@ -41,6 +44,73 @@ 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{ @@ -553,14 +623,442 @@ func TestHandleFirebaseCompleteRedirectsAccessDeniedWhenNoScopeApproved(t *testi assert.Equal(t, "access_denied", parseLocation(t, rec).Query().Get("error")) } -// postForm posts values to handler as an application/x-www-form-urlencoded -// request and returns the recorded response. +// 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, "frame-ancestors 'none'", rec.Header().Get("Content-Security-Policy")) + assert.Equal(t, "no-referrer", rec.Header().Get("Referrer-Policy")) +} + +// TestHandleAuthorizeRendersEveryFirebaseProvider asserts the consent page +// offers all three identity providers the httpSMS web app supports -- +// Google, GitHub, and email/password -- and that every path posts the ID +// token through the hidden form body, never a URL. +func TestHandleAuthorizeRendersEveryFirebaseProvider(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) + + body := rec.Body.String() + assert.Contains(t, body, "GoogleAuthProvider") + assert.Contains(t, body, "GithubAuthProvider") + assert.Contains(t, body, "signInWithEmailAndPassword") + + // Every provider funnels into the same hidden-field completion path. + assert.Contains(t, body, ``) + assert.Contains(t, body, `method="POST" action="/oauth/firebase/complete"`) + assert.Contains(t, body, "completeWithUser") + + // The email/password credentials must not live inside the form that is + // posted to this service. + formStart := strings.Index(body, `
") + require.Greater(t, formEnd, 0) + authorizeForm := body[formStart : formStart+formEnd] + assert.NotContains(t, authorizeForm, `type="password"`) + assert.NotContains(t, authorizeForm, `id="httpsms-email"`) + + // The consent page never carries a token in a URL. + assert.NotContains(t, body, "id_token=") +} + +// TestHandleAuthorizeRedirectsAreNotCacheable asserts an authorization +// error redirect -- which carries the client's state and the "iss" +// parameter -- is not storable by an intermediary or the browser cache. +func TestHandleAuthorizeRedirectsAreNotCacheable(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, "no-store", rec.Header().Get("Cache-Control")) + assert.Equal(t, "no-cache", rec.Header().Get("Pragma")) +} + +// TestHandleFirebaseCompleteRejectsUnsupportedContentType asserts a body +// that is not form-encoded is rejected with the OAuth "invalid_request" +// error rather than silently parsed as an empty form. +func TestHandleFirebaseCompleteRejectsUnsupportedContentType(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + body := url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read"}, + }.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.HandleFirebaseComplete, 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 never touched the transaction, and the same + // body succeeds once it is correctly labelled. + accepted := postBody(t, server.HandleFirebaseComplete, "application/x-www-form-urlencoded; charset=UTF-8", body) + require.Equal(t, http.StatusFound, accepted.Code) +} + +func TestHandleFirebaseCompleteRejectsMissingContentType(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + + rec := postBody(t, server.HandleFirebaseComplete, "", "transaction_id=x") + + 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") +} + +// TestHandleFirebaseCompleteRejectsOversizeBody asserts a body larger than +// the 64 KiB bound is refused instead of being buffered into memory. +func TestHandleFirebaseCompleteRejectsOversizeBody(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + oversize := url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "padding": {strings.Repeat("a", maxFormBodyBytes+1)}, + } + + rec := postForm(t, server.HandleFirebaseComplete, oversize) + + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, rec).Error) +} + +// TestHandleFirebaseCompleteRejectsDuplicateParameters asserts a repeated +// single-valued parameter (here, two transaction IDs) is refused. +func TestHandleFirebaseCompleteRejectsDuplicateParameters(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, "another-transaction"}, + "id_token": {"good-token"}, + }) + + require.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, rec).Error) +} + +// TestHandleFirebaseCompleteConsumesTransactionOnSuccess asserts a +// completed authorization is one-time: replaying the exact same consent +// POST cannot mint a second authorization code. +func TestHandleFirebaseCompleteConsumesTransactionOnSuccess(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + values := url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read", "messages:send"}, + } + + first := postForm(t, server.HandleFirebaseComplete, values) + require.Equal(t, http.StatusFound, first.Code) + require.NotEmpty(t, parseLocation(t, first).Query().Get("code")) + assert.Equal(t, "no-store", first.Header().Get("Cache-Control")) + + replay := postForm(t, server.HandleFirebaseComplete, values) + require.Equal(t, http.StatusBadRequest, replay.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, replay).Error) + + _, err := store.GetAuthorizationTransaction(context.Background(), transactionID) + require.ErrorIs(t, err, ErrNotFound) +} + +// TestHandleFirebaseCompleteConsumesTransactionOnDenial asserts a denial +// is equally final: the denied transaction cannot then be approved. +func TestHandleFirebaseCompleteConsumesTransactionOnDenial(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + denial := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "denied": {"1"}, + }) + require.Equal(t, http.StatusFound, denial.Code) + assert.Equal(t, "access_denied", parseLocation(t, denial).Query().Get("error")) + assert.Equal(t, "no-store", denial.Header().Get("Cache-Control")) + + approval := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read"}, + }) + require.Equal(t, http.StatusBadRequest, approval.Code) + assert.Equal(t, "invalid_request", decodeOAuthError(t, approval).Error) +} + +// TestHandleFirebaseCompleteIsAtomicUnderConcurrentCompletions asserts +// that when the same consent POST arrives many times at once, exactly one +// of them can consume the transaction and issue a code -- the +// one-time-use guarantee is enforced by the store's atomic consume, not by +// request ordering. +func TestHandleFirebaseCompleteIsAtomicUnderConcurrentCompletions(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + values := url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read", "messages:send"}, + } + + const callers = 12 + var wg sync.WaitGroup + codes := make([]string, callers) + statuses := make([]int, callers) + for i := 0; i < callers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + server.HandleFirebaseComplete(rec, req) + + statuses[index] = rec.Code + if location, err := url.Parse(rec.Header().Get("Location")); err == nil { + codes[index] = location.Query().Get("code") + } + }(i) + } + wg.Wait() + + issued := 0 + for index, status := range statuses { + if status == http.StatusFound && codes[index] != "" { + issued++ + continue + } + assert.Equal(t, http.StatusBadRequest, status, "a losing completion must fail closed") + } + assert.Equal(t, 1, issued, "exactly one concurrent completion may issue an authorization code") +} + +// TestHandleFirebaseCompleteAllowsRetryAfterFailedVerification asserts a +// failed Firebase sign-in does not burn the transaction: nothing was +// issued, so the user may simply try again in the same browser tab. +func TestHandleFirebaseCompleteAllowsRetryAfterFailedVerification(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, &failThenSucceedVerifier{}) + transactionID := startAuthorization(t, server, nil) + + values := url.Values{ + "transaction_id": {transactionID}, + "id_token": {"first-attempt"}, + "approved_scopes": {"phones:read", "messages:send"}, + } + + failed := postForm(t, server.HandleFirebaseComplete, values) + require.Equal(t, http.StatusUnauthorized, failed.Code) + + retry := postForm(t, server.HandleFirebaseComplete, values) + require.Equal(t, http.StatusFound, retry.Code) + assert.NotEmpty(t, parseLocation(t, retry).Query().Get("code")) +} + +// TestHandleFirebaseCompleteRejectsEmptyVerifiedSubject asserts a verifier +// that returns success but no user ID can never lead to a code bound to an +// empty subject. +func TestHandleFirebaseCompleteRejectsEmptyVerifiedSubject(t *testing.T) { + store := newClientsTestStore(t) + registerTestClient(t, store, testClientID, []string{testRedirect}) + server := newTestOAuthServer(t, store, stubVerifier{principal: auth.Principal{Email: testUserEmail}}) + transactionID := startAuthorization(t, server, nil) + + rec := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"subjectless-token"}, + "approved_scopes": {"phones:read"}, + }) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "access_denied", decodeOAuthError(t, rec).Error) + + // No code was issued, and the transaction was not consumed. + _, err := store.GetAuthorizationTransaction(context.Background(), transactionID) + require.NoError(t, err) +} + +// TestHandleFirebaseCompleteReturnsServerErrorOnStoreFailure asserts an +// infrastructure failure is reported as a 500 "server_error", never as a +// client-side "invalid_request". +func TestHandleFirebaseCompleteReturnsServerErrorOnStoreFailure(t *testing.T) { + base := newClientsTestStore(t) + registerTestClient(t, base, testClientID, []string{testRedirect}) + failing := &errorStore{Store: base} + server := newTestOAuthServer(t, failing, approvingVerifier()) + transactionID := startAuthorization(t, server, nil) + + failing.failGetTransaction = true + lookupFailure := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + }) + require.Equal(t, http.StatusInternalServerError, lookupFailure.Code) + assert.Equal(t, "server_error", decodeOAuthError(t, lookupFailure).Error) + + failing.failGetTransaction = false + failing.failConsumeTransaction = true + consumeFailure := postForm(t, server.HandleFirebaseComplete, url.Values{ + "transaction_id": {transactionID}, + "id_token": {"good-token"}, + "approved_scopes": {"phones:read"}, + }) + require.Equal(t, http.StatusInternalServerError, consumeFailure.Code) + assert.Equal(t, "server_error", decodeOAuthError(t, consumeFailure).Error) +} + +// postForm posts values to handler as a real application/x-www-form-urlencoded +// request: the body is encoded on the wire and parsed by the handler's own +// ParseForm call, so body-size and Content-Type enforcement are exercised +// exactly as they are in production (never by pre-populating PostForm). func postForm(t *testing.T, handler http.HandlerFunc, values url.Values) *httptest.ResponseRecorder { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.PostForm = values - req.Form = values + return postBody(t, handler, "application/x-www-form-urlencoded", values.Encode()) +} + +// postBody posts a raw body with an explicit Content-Type to handler. +func postBody(t *testing.T, handler http.HandlerFunc, contentType, body string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } else { + req.Header.Del("Content-Type") + } rec := httptest.NewRecorder() handler(rec, req) diff --git a/mcp/internal/oauth/metadata.go b/mcp/internal/oauth/metadata.go index f2345c36..13e87725 100644 --- a/mcp/internal/oauth/metadata.go +++ b/mcp/internal/oauth/metadata.go @@ -42,16 +42,17 @@ func NewProtectedResourceMetadataHandler(baseURL string) http.HandlerFunc { // Server Metadata document, extended with the CIMD support flag consumed // by clients implementing the Client ID Metadata Document mechanism. type authorizationServerMetadata 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"` - ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` + 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"` + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported"` + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` } // NewAuthorizationServerMetadataHandler returns an http.HandlerFunc serving @@ -60,16 +61,21 @@ func NewAuthorizationServerMetadataHandler(baseURL string) http.HandlerFunc { root := strings.TrimRight(baseURL, "/") return newMetadataHandler(authorizationServerMetadata{ - Issuer: root, - AuthorizationEndpoint: root + "/oauth/authorize", - TokenEndpoint: root + "/oauth/token", - RegistrationEndpoint: root + "/oauth/register", - JWKSURI: root + "/.well-known/jwks.json", - ResponseTypesSupported: []string{"code"}, - GrantTypesSupported: []string{"authorization_code", "refresh_token"}, - CodeChallengeMethodsSupported: []string{"S256"}, - ScopesSupported: Scopes, - ClientIDMetadataDocumentSupported: true, + Issuer: root, + AuthorizationEndpoint: root + "/oauth/authorize", + TokenEndpoint: root + "/oauth/token", + RegistrationEndpoint: root + "/oauth/register", + JWKSURI: root + "/.well-known/jwks.json", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + CodeChallengeMethodsSupported: []string{"S256"}, + ScopesSupported: Scopes, + // Every authorization response this server issues -- success or + // error, from the authorization endpoint or the Firebase + // completion endpoint -- carries the RFC 9207 "iss" parameter, so + // clients can and should enforce it. + AuthorizationResponseIssParameterSupported: true, + ClientIDMetadataDocumentSupported: true, }) } diff --git a/mcp/internal/oauth/metadata_test.go b/mcp/internal/oauth/metadata_test.go index 0bd44a2d..966517ef 100644 --- a/mcp/internal/oauth/metadata_test.go +++ b/mcp/internal/oauth/metadata_test.go @@ -82,12 +82,47 @@ func TestAuthorizationServerMetadataHandlerServesExactFields(t *testing.T) { "user-api-key:rotate", }, body["scopes_supported"]) assert.Equal(t, true, body["client_id_metadata_document_supported"]) + assert.Equal(t, true, body["authorization_response_iss_parameter_supported"]) - assert.Len(t, body, 10) + assert.Len(t, body, 11) +} + +// TestAuthorizationServerMetadataHandlerServesExactJSONDocument pins the +// exact bytes of the metadata document, including +// "authorization_response_iss_parameter_supported": true -- clients rely on +// that flag to know they must enforce the RFC 9207 "iss" parameter this +// server sends on every authorization response. +func TestAuthorizationServerMetadataHandlerServesExactJSONDocument(t *testing.T) { + handler := oauth.NewAuthorizationServerMetadataHandler(testMCPBaseURL) + + req := httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + require.Equal(t, 200, rec.Code) + assert.JSONEq(t, `{ + "issuer": "https://mcp.httpsms.com", + "authorization_endpoint": "https://mcp.httpsms.com/oauth/authorize", + "token_endpoint": "https://mcp.httpsms.com/oauth/token", + "registration_endpoint": "https://mcp.httpsms.com/oauth/register", + "jwks_uri": "https://mcp.httpsms.com/.well-known/jwks.json", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": [ + "phones:read", + "messages:read", + "messages:send", + "phone-api-keys:write", + "user-api-key:rotate" + ], + "authorization_response_iss_parameter_supported": true, + "client_id_metadata_document_supported": true + }`, rec.Body.String()) } func TestScopesConstantOrderIsStable(t *testing.T) { - // Both metadata documents and the future consent screen depend on this + // Both metadata documents and the consent screen depend on this // exact, fixed order; a reordering would silently change the scopes // list presented to users. assert.Equal(t, []string{ diff --git a/mcp/internal/oauth/store.go b/mcp/internal/oauth/store.go index 674f32f5..e78fa48b 100644 --- a/mcp/internal/oauth/store.go +++ b/mcp/internal/oauth/store.go @@ -39,9 +39,10 @@ var ErrNotFound = errors.New("oauth: not found") // AuthorizationTransaction records a single in-flight OAuth authorization // request from the moment its client, redirect URI, scopes, state, and PKCE // challenge have been validated until the resulting authorization code is -// issued or the transaction expires unused. Unlike codes/tokens/handles it -// is read (not consumed) so it can be re-read across the Firebase-login -// redirect round trip. +// issued or the transaction expires unused. It is read (not consumed) while +// the browser is still completing Firebase login, and consumed exactly once +// by the completion or denial that ends it (see +// ConsumeAuthorizationTransaction). type AuthorizationTransaction struct { // ID is the random public value this transaction is looked up by. It // is used only to derive the record's Redis key and is never persisted @@ -130,6 +131,7 @@ type Confirmation struct { type Store interface { PutAuthorizationTransaction(context.Context, AuthorizationTransaction, time.Duration) error GetAuthorizationTransaction(context.Context, string) (AuthorizationTransaction, error) + ConsumeAuthorizationTransaction(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 @@ -206,6 +208,18 @@ func (s *RedisStore) GetAuthorizationTransaction(ctx context.Context, id string) return transaction, err } +// ConsumeAuthorizationTransaction implements Store. It atomically fetches +// and deletes the record, so exactly one completion (an approved login or +// an explicit denial) can ever end a given authorization transaction: a +// second attempt -- a replayed consent POST, or a concurrent one that lost +// the race -- returns ErrNotFound. +func (s *RedisStore) ConsumeAuthorizationTransaction(ctx context.Context, id string) (AuthorizationTransaction, error) { + var transaction AuthorizationTransaction + err := consumeRecord(ctx, s.client, keyPrefixTransaction, id, &transaction) + transaction.ID = id + return transaction, err +} + // PutAuthorizationCode implements Store. func (s *RedisStore) PutAuthorizationCode(ctx context.Context, code AuthorizationCode, ttl time.Duration) error { if code.Code == "" { diff --git a/mcp/internal/oauth/store_test.go b/mcp/internal/oauth/store_test.go index 70441701..74809801 100644 --- a/mcp/internal/oauth/store_test.go +++ b/mcp/internal/oauth/store_test.go @@ -62,6 +62,46 @@ func TestRedisStoreGetAuthorizationTransactionNotFound(t *testing.T) { require.ErrorIs(t, err, oauth.ErrNotFound) } +// TestRedisStoreConsumeAuthorizationTransactionIsOneTimeUse asserts the +// completion (or denial) that ends an authorization transaction consumes it +// atomically, so a replayed consent POST cannot end it a second time. +func TestRedisStoreConsumeAuthorizationTransactionIsOneTimeUse(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + transaction := oauth.AuthorizationTransaction{ + ID: "one-time-transaction", + ClientID: "https://client.example/metadata.json", + RedirectURI: "https://client.example/callback", + Scopes: []string{"phones:read", "messages:send"}, + State: "state-value", + Resource: "https://mcp.httpsms.com/mcp", + CodeChallenge: "challenge", + CodeChallengeMethod: "S256", + ResponseType: "code", + CreatedAt: time.Now().UTC().Truncate(time.Second), + } + require.NoError(t, store.PutAuthorizationTransaction(ctx, transaction, time.Minute)) + + first, err := store.ConsumeAuthorizationTransaction(ctx, "one-time-transaction") + require.NoError(t, err) + assert.Equal(t, transaction, first) + + _, err = store.ConsumeAuthorizationTransaction(ctx, "one-time-transaction") + require.ErrorIs(t, err, oauth.ErrNotFound) + + // It is also gone for the non-consuming reader. + _, err = store.GetAuthorizationTransaction(ctx, "one-time-transaction") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + +func TestRedisStoreConsumeAuthorizationTransactionNotFound(t *testing.T) { + store, _ := newTestStore(t) + + _, err := store.ConsumeAuthorizationTransaction(context.Background(), "missing") + require.ErrorIs(t, err, oauth.ErrNotFound) +} + func TestRedisStoreConsumeAuthorizationCodeIsOneTimeUse(t *testing.T) { store, _ := newTestStore(t) ctx := context.Background() diff --git a/mcp/internal/oauth/templates/authorize.html b/mcp/internal/oauth/templates/authorize.html index 11265289..a200c4c3 100644 --- a/mcp/internal/oauth/templates/authorize.html +++ b/mcp/internal/oauth/templates/authorize.html @@ -3,6 +3,7 @@ + Authorize {{.ClientName}} - httpSMS @@ -14,13 +15,34 @@

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

{{range .Scopes}}
  • {{.Description}}
  • {{end}} + {{range .Scopes}}{{end}} -
    +
    + + + +
    + + + + + +
    +
    + + +
    @@ -43,16 +65,40 @@

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

    // submitted via a normal POST body -- it must never be appended to a // URL (query string, fragment, or otherwise), where it could leak // through browser history, a Referer header, or server access logs. - document.getElementById("httpsms-allow-button").addEventListener("click", function () { - var provider = new firebase.auth.GoogleAuthProvider(); - firebase.auth().signInWithPopup(provider).then(function (result) { - return result.user.getIdToken(); - }).then(function (idToken) { + // Every sign-in method below (Google, GitHub, and email/password) + // funnels through this single completion path. + function completeWithUser(user) { + return user.getIdToken().then(function (idToken) { document.getElementById("httpsms-id-token").value = idToken; document.getElementById("httpsms-authorize-form").submit(); - }).catch(function (error) { - alert("Sign-in failed: " + error.message); }); + } + + function reportError(error) { + document.getElementById("httpsms-error").textContent = "Sign-in failed: " + error.message; + } + + function signInWithProvider(provider) { + firebase.auth().signInWithPopup(provider).then(function (result) { + return completeWithUser(result.user); + }).catch(reportError); + } + + document.getElementById("httpsms-google-button").addEventListener("click", function () { + signInWithProvider(new firebase.auth.GoogleAuthProvider()); + }); + + document.getElementById("httpsms-github-button").addEventListener("click", function () { + signInWithProvider(new firebase.auth.GithubAuthProvider()); + }); + + document.getElementById("httpsms-password-button").addEventListener("click", function () { + var email = document.getElementById("httpsms-email").value; + var password = document.getElementById("httpsms-password").value; + + firebase.auth().signInWithEmailAndPassword(email, password).then(function (result) { + return completeWithUser(result.user); + }).catch(reportError); }); diff --git a/mcp/internal/oauth/token.go b/mcp/internal/oauth/token.go index 2bdc8220..8961de7c 100644 --- a/mcp/internal/oauth/token.go +++ b/mcp/internal/oauth/token.go @@ -31,10 +31,25 @@ type tokenResponse struct { 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). Every error response is an +// 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) { @@ -44,8 +59,12 @@ func (s *Server) HandleToken(w http.ResponseWriter, r *http.Request) { return } - if err := r.ParseForm(); err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "cannot parse request body") + 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 } @@ -81,7 +100,7 @@ func (s *Server) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Req record, err := s.store.ConsumeAuthorizationCode(r.Context(), code) if err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "the authorization code is invalid, expired, or already used") + writeStoreError(w, err, "invalid_grant", "the authorization code is invalid, expired, or already used") return } @@ -115,7 +134,7 @@ func (s *Server) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) grant, err := s.store.GetRefreshToken(r.Context(), refreshToken) if err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "the refresh token is invalid, expired, or already used") + writeStoreError(w, err, "invalid_grant", "the refresh token is invalid, expired, or already used") return } @@ -188,7 +207,10 @@ func (s *Server) issueTokens(w http.ResponseWriter, ctx context.Context, princip storeErr = s.store.RotateRefreshToken(ctx, rotateOldToken, newGrant, s.config.RefreshTokenTTL) } if storeErr != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "the refresh token is invalid, expired, or already used") + // 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 } diff --git a/mcp/internal/oauth/token_test.go b/mcp/internal/oauth/token_test.go index c3f3585f..e9012467 100644 --- a/mcp/internal/oauth/token_test.go +++ b/mcp/internal/oauth/token_test.go @@ -385,3 +385,132 @@ func TestTokenEndpointRefreshRejectsMissingClientID(t *testing.T) { 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) +} From 505ecb2dfe4b7169a07ce7bc62c3f6eecf855c49 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 22:43:48 +0300 Subject: [PATCH 11/25] feat(mcp): add httpSMS API client Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/go.mod | 4 +- mcp/go.sum | 4 + mcp/internal/httpsms/client.go | 385 +++++++++++++++++++++++++ mcp/internal/httpsms/client_test.go | 432 ++++++++++++++++++++++++++++ mcp/internal/httpsms/models.go | 187 ++++++++++++ 5 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 mcp/internal/httpsms/client.go create mode 100644 mcp/internal/httpsms/client_test.go create mode 100644 mcp/internal/httpsms/models.go diff --git a/mcp/go.mod b/mcp/go.mod index 71892503..4fc3589d 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -5,9 +5,11 @@ 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/uuid v1.6.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 @@ -16,9 +18,9 @@ require ( 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/google/uuid v1.6.0 // 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 diff --git a/mcp/go.sum b/mcp/go.sum index b0c6bdb2..4e15ad36 100644 --- a/mcp/go.sum +++ b/mcp/go.sum @@ -8,6 +8,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x 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= @@ -41,6 +43,8 @@ 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= diff --git a/mcp/internal/httpsms/client.go b/mcp/internal/httpsms/client.go new file mode 100644 index 00000000..d6a2fc1f --- /dev/null +++ b/mcp/internal/httpsms/client.go @@ -0,0 +1,385 @@ +package httpsms + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "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 + + 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) +} + +// client is the Client implementation calling the httpSMS HTTP API. +type client struct { + baseURL string + httpClient *http.Client +} + +var _ Client = (*client)(nil) + +// NewClient returns a Client calling baseURL (for example +// "https://api.httpsms.com"). The returned client is bounded and makes a +// single attempt per call: an explicit request timeout, a size-limited +// connection pool, OpenTelemetry context propagation through +// otelhttp.Transport, 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. +func NewClient(baseURL string) *client { + transport := &http.Transport{ + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + IdleConnTimeout: idleConnTimeout, + } + + return &client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: requestTimeout, + Transport: otelhttp.NewTransport(transport), + }, + } +} + +// ListPhones calls GET /v1/phones. +func (c *client) 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 *client) 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 *client) 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 *client) 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 *client) 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 *client) 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 *client) 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 *client) 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..65af4a64 --- /dev/null +++ b/mcp/internal/httpsms/client_test.go @@ -0,0 +1,432 @@ +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" +) + +// 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) +} + +func readAll(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + return io.ReadAll(r.Body) +} 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 +} From 5e4d7fcfc9d4ca4903d868315f8f9584aeb8cb6d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 23:01:19 +0300 Subject: [PATCH 12/25] fix(mcp): redact API query traces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/internal/httpsms/client.go | 76 +++++++++++++------- mcp/internal/httpsms/client_test.go | 108 ++++++++++++++++++++++++++++ mcp/internal/httpsms/transport.go | 89 +++++++++++++++++++++++ 3 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 mcp/internal/httpsms/transport.go diff --git a/mcp/internal/httpsms/client.go b/mcp/internal/httpsms/client.go index d6a2fc1f..e61946ad 100644 --- a/mcp/internal/httpsms/client.go +++ b/mcp/internal/httpsms/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "strconv" @@ -34,6 +35,24 @@ const ( // 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 @@ -66,40 +85,49 @@ type Client interface { RotateUserAPIKey(ctx context.Context, token string, userID string) (User, error) } -// client is the Client implementation calling the httpSMS HTTP API. -type client struct { +// HTTPClient is the Client implementation calling the httpSMS HTTP API. +type HTTPClient struct { baseURL string httpClient *http.Client } -var _ Client = (*client)(nil) +var _ Client = (*HTTPClient)(nil) -// NewClient returns a Client calling baseURL (for example +// 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 request timeout, a size-limited -// connection pool, OpenTelemetry context propagation through -// otelhttp.Transport, 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. -func NewClient(baseURL string) *client { +// 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. +func NewClient(baseURL string) *HTTPClient { transport := &http.Transport{ - MaxIdleConns: maxIdleConns, - MaxIdleConnsPerHost: maxIdleConnsPerHost, - IdleConnTimeout: idleConnTimeout, + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + IdleConnTimeout: idleConnTimeout, + TLSHandshakeTimeout: tlsHandshakeTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, + DialContext: (&net.Dialer{ + Timeout: dialTimeout, + }).DialContext, } - return &client{ + instrumented := otelhttp.NewTransport(&queryRestoringTransport{base: transport}) + + return &HTTPClient{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{ Timeout: requestTimeout, - Transport: otelhttp.NewTransport(transport), + Transport: &queryRedactingTransport{next: instrumented}, }, } } // ListPhones calls GET /v1/phones. -func (c *client) ListPhones(ctx context.Context, token string, params ListPhonesParams) ([]Phone, error) { +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) @@ -126,7 +154,7 @@ type messageSendRequest struct { } // SendSMS calls POST /v1/messages/send. -func (c *client) SendSMS(ctx context.Context, token string, params SendSMSParams) (Message, error) { +func (c *HTTPClient) SendSMS(ctx context.Context, token string, params SendSMSParams) (Message, error) { body := messageSendRequest{ From: params.From, To: params.To, @@ -145,7 +173,7 @@ func (c *client) SendSMS(ctx context.Context, token string, params SendSMSParams } // ListMessageThreads calls GET /v1/message-threads. -func (c *client) ListMessageThreads(ctx context.Context, token string, params ListMessageThreadsParams) ([]MessageThread, error) { +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) @@ -162,7 +190,7 @@ func (c *client) ListMessageThreads(ctx context.Context, token string, params Li } // ListThreadMessages calls GET /v1/messages. -func (c *client) ListThreadMessages(ctx context.Context, token string, params ListThreadMessagesParams) ([]Message, error) { +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) @@ -178,7 +206,7 @@ func (c *client) ListThreadMessages(ctx context.Context, token string, params Li } // ListIncomingMessages calls GET /v1/messages/incoming. -func (c *client) ListIncomingMessages(ctx context.Context, token string, params ListIncomingMessagesParams) ([]Message, error) { +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) @@ -204,7 +232,7 @@ type phoneAPIKeyStoreRequest struct { } // CreatePhoneAPIKey calls POST /v1/phone-api-keys. -func (c *client) CreatePhoneAPIKey(ctx context.Context, token string, params CreatePhoneAPIKeyParams) (PhoneAPIKey, error) { +func (c *HTTPClient) CreatePhoneAPIKey(ctx context.Context, token string, params CreatePhoneAPIKeyParams) (PhoneAPIKey, error) { body := phoneAPIKeyStoreRequest{Name: params.Name} var key PhoneAPIKey @@ -217,7 +245,7 @@ func (c *client) CreatePhoneAPIKey(ctx context.Context, token string, params Cre // 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 *client) RotateUserAPIKey(ctx context.Context, token string, userID string) (User, error) { +func (c *HTTPClient) RotateUserAPIKey(ctx context.Context, token string, userID string) (User, error) { path := "/v1/users/" + url.PathEscape(userID) + "/api-keys" var user User @@ -239,7 +267,7 @@ func (c *client) RotateUserAPIKey(ctx context.Context, token string, userID stri // 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 *client) do( +func (c *HTTPClient) do( ctx context.Context, token string, method string, diff --git a/mcp/internal/httpsms/client_test.go b/mcp/internal/httpsms/client_test.go index 65af4a64..ff587506 100644 --- a/mcp/internal/httpsms/client_test.go +++ b/mcp/internal/httpsms/client_test.go @@ -13,6 +13,10 @@ import ( "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 @@ -424,6 +428,110 @@ func TestClient_PropagatesContextCancellation(t *testing.T) { 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 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) +} From 86afa17f4920a16463a33a46065cd159a7e792a8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 23:25:08 +0300 Subject: [PATCH 13/25] feat(mcp): add messaging tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/go.mod | 8 + mcp/go.sum | 18 + mcp/internal/auth/keys.go | 42 ++ mcp/internal/auth/keys_test.go | 66 +++ mcp/internal/auth/middleware.go | 112 ++++ mcp/internal/auth/middleware_test.go | 170 +++++++ mcp/internal/tools/messages.go | 324 ++++++++++++ mcp/internal/tools/messages_test.go | 733 +++++++++++++++++++++++++++ mcp/internal/tools/phones.go | 75 +++ mcp/internal/tools/register.go | 83 +++ 10 files changed, 1631 insertions(+) create mode 100644 mcp/internal/auth/middleware.go create mode 100644 mcp/internal/auth/middleware_test.go create mode 100644 mcp/internal/tools/messages.go create mode 100644 mcp/internal/tools/messages_test.go create mode 100644 mcp/internal/tools/phones.go create mode 100644 mcp/internal/tools/register.go diff --git a/mcp/go.mod b/mcp/go.mod index 4fc3589d..bf1657ba 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -5,7 +5,9 @@ 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 @@ -24,6 +26,9 @@ require ( 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 @@ -33,8 +38,11 @@ require ( 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 diff --git a/mcp/go.sum b/mcp/go.sum index 4e15ad36..5aa24a4a 100644 --- a/mcp/go.sum +++ b/mcp/go.sum @@ -21,6 +21,8 @@ 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= @@ -31,12 +33,20 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP 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= @@ -69,11 +79,19 @@ 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= diff --git a/mcp/internal/auth/keys.go b/mcp/internal/auth/keys.go index b447794f..0473f7cd 100644 --- a/mcp/internal/auth/keys.go +++ b/mcp/internal/auth/keys.go @@ -208,6 +208,48 @@ func (keys *KeySet) SignAPIDelegationToken(principal Principal, scopes []string, 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) { diff --git a/mcp/internal/auth/keys_test.go b/mcp/internal/auth/keys_test.go index 468433b8..03dd3f63 100644 --- a/mcp/internal/auth/keys_test.go +++ b/mcp/internal/auth/keys_test.go @@ -329,6 +329,72 @@ func TestKeySetJWKSPublishesOnlyThePublicKey(t *testing.T) { 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) diff --git a/mcp/internal/auth/middleware.go b/mcp/internal/auth/middleware.go new file mode 100644 index 00000000..5f07ae92 --- /dev/null +++ b/mcp/internal/auth/middleware.go @@ -0,0 +1,112 @@ +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 +} + +// 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..a811f6e9 --- /dev/null +++ b/mcp/internal/auth/middleware_test.go @@ -0,0 +1,170 @@ +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") + + w.Header().Set("X-Test-User-ID", info.UserID) + w.Header().Set("X-Test-Principal-Email", principal.Email) + 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")) +} + +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 TestRequireScopeReturnsErrorWithoutToken(t *testing.T) { + _, err := auth.RequireScope(t.Context(), auth.ScopePhonesRead) + require.Error(t, err) +} 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..23849326 --- /dev/null +++ b/mcp/internal/tools/messages_test.go @@ -0,0 +1,733 @@ +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/golang-jwt/jwt/v5" + mcpauth "github.com/modelcontextprotocol/go-sdk/auth" + "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/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} + +// --- 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 +} + +// 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, string, httpsms.CreatePhoneAPIKeyParams) (httpsms.PhoneAPIKey, error) { + panic("CreatePhoneAPIKey is not part of the task-7 messaging tool catalog and must not be called") +} + +func (s *stubClient) RotateUserAPIKey(context.Context, string, string) (httpsms.User, error) { + panic("RotateUserAPIKey is not part of the task-7 messaging tool catalog and must not be called") +} + +// 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) +} + +// --- 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 +} + +// newSession registers every messaging 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. +func newSession(t *testing.T, ctx context.Context, keys *auth.KeySet, api httpsms.Client) *mcp.ClientSession { + t.Helper() + + server := mcp.NewServer(&mcp.Implementation{Name: "httpsms-mcp-test", Version: "test"}, nil) + tools.Register(server, keys, api, testAPITokenTTL) + + 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 TestRegisterRegistersExactlyTheFiveMessagingTools(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{ + "list_incoming_messages", + "list_message_threads", + "list_phones", + "list_thread_messages", + "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..b84ab71a --- /dev/null +++ b/mcp/internal/tools/register.go @@ -0,0 +1,83 @@ +// Package tools registers and implements the httpSMS MCP messaging tool +// catalog: list_phones, send_sms, list_message_threads, +// list_thread_messages, and list_incoming_messages. +// +// 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. +package tools + +import ( + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NdoleStudio/httpsms/mcp/internal/auth" + "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" +) + +// Register adds every messaging tool to server, in the order approved by +// design: phones, send, threads, thread messages, incoming messages. 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. +func Register(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL 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) +} + +// 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, + } +} + +// 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 +} From a13bc7d431702ff685dbaac1d4f8aed74fc1b35e Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 23:57:59 +0300 Subject: [PATCH 14/25] feat(mcp): add API key tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/internal/auth/middleware.go | 16 + mcp/internal/auth/middleware_test.go | 10 + mcp/internal/oauth/store.go | 15 + mcp/internal/oauth/store_test.go | 25 ++ mcp/internal/tools/api_keys.go | 333 ++++++++++++++ mcp/internal/tools/api_keys_test.go | 641 +++++++++++++++++++++++++++ mcp/internal/tools/messages_test.go | 78 +++- mcp/internal/tools/register.go | 50 ++- 8 files changed, 1147 insertions(+), 21 deletions(-) create mode 100644 mcp/internal/tools/api_keys.go create mode 100644 mcp/internal/tools/api_keys_test.go diff --git a/mcp/internal/auth/middleware.go b/mcp/internal/auth/middleware.go index 5f07ae92..5da6bb8a 100644 --- a/mcp/internal/auth/middleware.go +++ b/mcp/internal/auth/middleware.go @@ -87,6 +87,22 @@ func PrincipalFromContext(ctx context.Context) (Principal, bool) { 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 diff --git a/mcp/internal/auth/middleware_test.go b/mcp/internal/auth/middleware_test.go index a811f6e9..d909e0ad 100644 --- a/mcp/internal/auth/middleware_test.go +++ b/mcp/internal/auth/middleware_test.go @@ -36,8 +36,12 @@ func newMiddlewareTestServer(t *testing.T, keys *auth.KeySet, requiredScopes []s 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) }) @@ -145,6 +149,7 @@ func TestRequireBearerTokenAcceptsValidTokenAndStoresTokenInfo(t *testing.T) { 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 { @@ -164,6 +169,11 @@ func TestPrincipalFromContextReturnsFalseWithoutToken(t *testing.T) { 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/oauth/store.go b/mcp/internal/oauth/store.go index e78fa48b..b56e532b 100644 --- a/mcp/internal/oauth/store.go +++ b/mcp/internal/oauth/store.go @@ -326,6 +326,21 @@ func (s *RedisStore) ConsumeConfirmation(ctx context.Context, handle string) (Co return record, err } +// confirmationHandleBytes is the amount of crypto/rand entropy (see +// newRandomToken) encoded into a rotation confirmation handle. +const confirmationHandleBytes = 32 + +// NewConfirmationHandle returns a new cryptographically random, one-time +// confirmation handle for the primary-API-key-rotation confirmation flow +// (see Confirmation, PutConfirmation, and ConsumeConfirmation). Callers +// store it with PutConfirmation and hand it to the client -- as +// mcp.CallToolResult.RequestState for MRTR-capable clients, or as plain +// tool output text for legacy clients that must echo it back explicitly -- +// and later redeem it exactly once with ConsumeConfirmation. +func NewConfirmationHandle() (string, error) { + return newRandomToken(confirmationHandleBytes) +} + // hashedKey returns the namespaced Redis key for publicValue under prefix: // prefix followed by the hex-encoded SHA-256 hash of publicValue. The raw // value is never used as key material. diff --git a/mcp/internal/oauth/store_test.go b/mcp/internal/oauth/store_test.go index 74809801..762e35a5 100644 --- a/mcp/internal/oauth/store_test.go +++ b/mcp/internal/oauth/store_test.go @@ -267,6 +267,31 @@ func TestRedisStoreGetDynamicClientNotFound(t *testing.T) { require.ErrorIs(t, err, oauth.ErrNotFound) } +// TestNewConfirmationHandleIsRandomAndURLSafe asserts NewConfirmationHandle +// returns a fresh, non-empty, URL-safe value on every call (never a fixed +// or predictable value), and that the handle it returns actually works +// end-to-end with PutConfirmation/ConsumeConfirmation. +func TestNewConfirmationHandleIsRandomAndURLSafe(t *testing.T) { + first, err := oauth.NewConfirmationHandle() + require.NoError(t, err) + assert.NotEmpty(t, first) + assert.NotRegexp(t, `[^A-Za-z0-9_-]`, first, "confirmation handle must be URL-safe base64") + + second, err := oauth.NewConfirmationHandle() + require.NoError(t, err) + assert.NotEqual(t, first, second, "two generated handles must never collide") + + store, _ := newTestStore(t) + ctx := context.Background() + + confirmation := oauth.Confirmation{Handle: first, UserID: "firebase-uid", ClientID: "client-id", Operation: "rotate_user_api_key"} + require.NoError(t, store.PutConfirmation(ctx, confirmation, time.Minute)) + + got, err := store.ConsumeConfirmation(ctx, first) + require.NoError(t, err) + assert.Equal(t, confirmation.UserID, got.UserID) +} + func TestRedisStoreConsumeConfirmationIsOneTimeUse(t *testing.T) { store, _ := newTestStore(t) ctx := context.Background() diff --git a/mcp/internal/tools/api_keys.go b/mcp/internal/tools/api_keys.go new file mode 100644 index 00000000..6dc825e2 --- /dev/null +++ b/mcp/internal/tools/api_keys.go @@ -0,0 +1,333 @@ +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"` + // 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 + } + + return nil, &RotateUserAPIKeyOutput{ + User: user, + 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, or a declined/malformed +// elicitation response). Every such handle 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) { + 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..67ed958a --- /dev/null +++ b/mcp/internal/tools/api_keys_test.go @@ -0,0 +1,641 @@ +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.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}) +} + +// 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") +} + +// --- 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_test.go b/mcp/internal/tools/messages_test.go index 23849326..b1b5d0bc 100644 --- a/mcp/internal/tools/messages_test.go +++ b/mcp/internal/tools/messages_test.go @@ -13,14 +13,17 @@ import ( "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" ) @@ -37,7 +40,13 @@ const ( // 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} +var allScopes = []string{ + auth.ScopePhonesRead, + auth.ScopeMessagesRead, + auth.ScopeMessagesSend, + auth.ScopePhoneAPIKeysWrite, + auth.ScopeUserAPIKeyRotate, +} // --- test doubles ----------------------------------------------------- @@ -65,6 +74,14 @@ type stubClient struct { 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. @@ -100,12 +117,14 @@ func (s *stubClient) ListIncomingMessages(_ context.Context, token string, param return s.listIncomingResult, s.listIncomingErr } -func (s *stubClient) CreatePhoneAPIKey(context.Context, string, httpsms.CreatePhoneAPIKeyParams) (httpsms.PhoneAPIKey, error) { - panic("CreatePhoneAPIKey is not part of the task-7 messaging tool catalog and must not be called") +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, string, string) (httpsms.User, error) { - panic("RotateUserAPIKey is not part of the task-7 messaging tool catalog and must not be called") +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 @@ -113,7 +132,8 @@ func (s *stubClient) RotateUserAPIKey(context.Context, string, string) (httpsms. // 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.listThreadMessagesCalls) + len(s.listIncomingCalls) + + len(s.createKeyCalls) + len(s.rotateCalls) } // --- test fixtures ------------------------------------------------------ @@ -176,15 +196,47 @@ func contextFromBearerToken(t *testing.T, keys *auth.KeySet, raw string) context return captured } -// newSession registers every messaging 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. +// 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) + tools.Register(server, keys, api, testAPITokenTTL, store, confirmationTTL) t1, t2 := mcp.NewInMemoryTransports() _, err := server.Connect(ctx, t1, nil) @@ -287,7 +339,7 @@ func schemaRequired(t *testing.T, schema any) []string { // --- registration --------------------------------------------------------- -func TestRegisterRegistersExactlyTheFiveMessagingTools(t *testing.T) { +func TestRegisterRegistersExactlySevenTools(t *testing.T) { keys := newTestKeySet(t) ctx := contextWithPrincipal(t, keys, allScopes) session := newSession(t, ctx, keys, &stubClient{}) @@ -300,10 +352,12 @@ func TestRegisterRegistersExactlyTheFiveMessagingTools(t *testing.T) { 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) } diff --git a/mcp/internal/tools/register.go b/mcp/internal/tools/register.go index b84ab71a..a3ed4da2 100644 --- a/mcp/internal/tools/register.go +++ b/mcp/internal/tools/register.go @@ -1,6 +1,6 @@ -// Package tools registers and implements the httpSMS MCP messaging tool -// catalog: list_phones, send_sms, list_message_threads, -// list_thread_messages, and list_incoming_messages. +// 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: // @@ -18,6 +18,10 @@ // 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 ( @@ -27,19 +31,24 @@ import ( "github.com/NdoleStudio/httpsms/mcp/internal/auth" "github.com/NdoleStudio/httpsms/mcp/internal/httpsms" + "github.com/NdoleStudio/httpsms/mcp/internal/oauth" ) -// Register adds every messaging tool to server, in the order approved by -// design: phones, send, threads, thread messages, incoming messages. 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. -func Register(server *mcp.Server, keys *auth.KeySet, api httpsms.Client, apiTokenTTL time.Duration) { +// 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 @@ -75,6 +84,29 @@ func sendAnnotations() *mcp.ToolAnnotations { } } +// 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). From 70617c211b72148465b8e8310e23f43aa48798ba Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Fri, 4 Sep 2026 00:10:43 +0300 Subject: [PATCH 15/25] fix(mcp): mark rotated keys sensitive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/internal/tools/api_keys.go | 37 ++++++++-- mcp/internal/tools/api_keys_test.go | 103 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/mcp/internal/tools/api_keys.go b/mcp/internal/tools/api_keys.go index 6dc825e2..8d5db939 100644 --- a/mcp/internal/tools/api_keys.go +++ b/mcp/internal/tools/api_keys.go @@ -116,6 +116,10 @@ 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"` @@ -175,8 +179,18 @@ func newRotateUserAPIKeyHandler(keys *auth.KeySet, api httpsms.Client, apiTokenT return toolError(err), nil, nil } - return nil, &RotateUserAPIKeyOutput{ - User: user, + 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.", @@ -252,11 +266,22 @@ func rotateConfirmationElicitParams() *mcp.ElicitParams { // // 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, or a declined/malformed -// elicitation response). Every such handle 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. +// 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 == "" { diff --git a/mcp/internal/tools/api_keys_test.go b/mcp/internal/tools/api_keys_test.go index 67ed958a..cd618903 100644 --- a/mcp/internal/tools/api_keys_test.go +++ b/mcp/internal/tools/api_keys_test.go @@ -226,6 +226,7 @@ func TestRotateUserAPIKeyMRTRAcceptedConfirmationRotatesExactlyOnce(t *testing.T 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) @@ -233,6 +234,41 @@ func TestRotateUserAPIKeyMRTRAcceptedConfirmationRotatesExactlyOnce(t *testing.T 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 @@ -359,6 +395,73 @@ func TestRotateUserAPIKeyMRTRReplayIsRejected(t *testing.T) { 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) { From edec15fb10db42666efe79fb6f38351a471c55e3 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Fri, 4 Sep 2026 00:33:45 +0300 Subject: [PATCH 16/25] feat(mcp): assemble hosted server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/cmd/server/main.go | 211 +++++++++ mcp/cmd/server/main_test.go | 119 +++++ mcp/internal/server/rate_limit.go | 168 +++++++ mcp/internal/server/rate_limit_test.go | 109 +++++ mcp/internal/server/server.go | 496 +++++++++++++++++++ mcp/internal/server/server_test.go | 628 +++++++++++++++++++++++++ 6 files changed, 1731 insertions(+) create mode 100644 mcp/cmd/server/main.go create mode 100644 mcp/cmd/server/main_test.go create mode 100644 mcp/internal/server/rate_limit.go create mode 100644 mcp/internal/server/rate_limit_test.go create mode 100644 mcp/internal/server/server.go create mode 100644 mcp/internal/server/server_test.go diff --git a/mcp/cmd/server/main.go b/mcp/cmd/server/main.go new file mode 100644 index 00000000..c742313f --- /dev/null +++ b/mcp/cmd/server/main.go @@ -0,0 +1,211 @@ +// 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() { + 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, + } + + serveErr := make(chan error, 1) + go func() { + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + return + } + serveErr <- nil + }() + + select { + case <-ctx.Done(): + log.Info().Msg("received shutdown signal") + case err := <-serveErr: + if err != nil { + log.Error().Err(err).Msg("HTTP server stopped unexpectedly") + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("shut down HTTP server") + } + if err := shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("shut down MCP server dependencies") + } +} + +// 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) + } + + apiClient := httpsms.NewClient(cfg.APIURL.String()) + + 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..6396275b --- /dev/null +++ b/mcp/cmd/server/main_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "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) +} diff --git a/mcp/internal/server/rate_limit.go b/mcp/internal/server/rate_limit.go new file mode 100644 index 00000000..c96225d3 --- /dev/null +++ b/mcp/internal/server/rate_limit.go @@ -0,0 +1,168 @@ +// 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, per user, per + // rolling one-hour window. + KeyRotationsPerHour int +} + +// 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 +} + +// buckets maps every rate-limited MCP tool name to the budget that bounds +// it. A tool 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}, + } +} + +// 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..30494f39 --- /dev/null +++ b/mcp/internal/server/rate_limit_test.go @@ -0,0 +1,109 @@ +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) +} diff --git a/mcp/internal/server/server.go b/mcp/internal/server/server.go new file mode 100644 index 00000000..8bbd05b7 --- /dev/null +++ b/mcp/internal/server/server.go @@ -0,0 +1,496 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "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" + 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" +) + +// 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 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 nil + } +} + +// 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) + + mux.Handle("GET "+protectedResourceMetadataPath, withPublicCORS(oauth.NewProtectedResourceMetadataHandler(baseURL))) + mux.Handle("GET "+authorizationServerMetadataPath, withPublicCORS(oauth.NewAuthorizationServerMetadataHandler(baseURL))) + mux.Handle("GET "+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))) + + handler := requestIDMiddleware( + recoveryMiddleware(deps.Logger)( + secureHeadersMiddleware( + otelhttp.NewHandler( + loggingMiddleware(deps.Logger)(mux), + "httpsms-mcp", + ), + ), + ), + ) + + return handler, nil +} + +// 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) + mcpServer.AddReceivingMiddleware(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. +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) + } + + if err := limiter.Allow(ctx, principal.UserID, callReq.Params.Name); 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) + } + } +} + +// 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 present), 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 := 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)) + }) +} + +// 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) + }) + } +} + +// secureHeadersMiddleware sets a baseline of defensive HTTP response +// headers on every response, regardless of route. +func secureHeadersMiddleware(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") + 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) +} + +// 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. +func withPublicCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +// 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. +func (w *noStoreWriter) Flush() { + w.setNoStore() + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/mcp/internal/server/server_test.go b/mcp/internal/server/server_test.go new file mode 100644 index 00000000..13c8ef5d --- /dev/null +++ b/mcp/internal/server/server_test.go @@ -0,0 +1,628 @@ +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() + + 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: stubAPIClient{}, + 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, + Version: "test", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "Resource") + require.Contains(t, err.Error(), "MCPAudience") +} + +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, + Version: "test", + } + + 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 = "" }}, + } + + 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) + }) + } +} From 178694144e95414c1bd7020a5e8ddcd67b4fc90a Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Fri, 4 Sep 2026 01:17:41 +0300 Subject: [PATCH 17/25] fix(mcp): harden server assembly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ff1e38a-b018-4cf7-a5e9-5044a2efd03c --- mcp/cmd/server/main.go | 64 ++- mcp/cmd/server/main_test.go | 82 ++++ mcp/internal/httpsms/client.go | 44 +- mcp/internal/httpsms/client_test.go | 39 ++ mcp/internal/oauth/authorize.go | 82 +++- mcp/internal/oauth/authorize_test.go | 73 +++- mcp/internal/oauth/clients.go | 14 +- mcp/internal/oauth/clients_test.go | 49 +++ mcp/internal/oauth/templates/authorize.html | 6 +- mcp/internal/server/rate_limit.go | 20 + mcp/internal/server/server.go | 368 ++++++++++++++-- mcp/internal/server/server_test.go | 444 +++++++++++++++++++- mcp/internal/server/stream_internal_test.go | 217 ++++++++++ 13 files changed, 1433 insertions(+), 69 deletions(-) create mode 100644 mcp/internal/server/stream_internal_test.go diff --git a/mcp/cmd/server/main.go b/mcp/cmd/server/main.go index c742313f..8c572495 100644 --- a/mcp/cmd/server/main.go +++ b/mcp/cmd/server/main.go @@ -41,21 +41,48 @@ var Version = "dev" 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 { - log.Fatal().Err(err).Msg("load configuration") + return fmt.Errorf("load configuration: %w", err) } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - handler, shutdown, err := build(ctx, cfg, Version) + handler, shutdown, err := build(ctx, cfg, version) if err != nil { - log.Fatal().Err(err).Msg("build MCP server") + 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: ":" + cfg.Port, + Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, @@ -65,19 +92,20 @@ func main() { serveErr := make(chan error, 1) go func() { - if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - serveErr <- err - return + err := httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil } - serveErr <- nil + serveErr <- err }() + var errs []error select { case <-ctx.Done(): log.Info().Msg("received shutdown signal") case err := <-serveErr: if err != nil { - log.Error().Err(err).Msg("HTTP server stopped unexpectedly") + errs = append(errs, fmt.Errorf("serve HTTP on %q: %w", addr, err)) } } @@ -85,11 +113,13 @@ func main() { defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("shut down HTTP server") + errs = append(errs, fmt.Errorf("shut down HTTP server: %w", err)) } if err := shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("shut down MCP server dependencies") + 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 @@ -170,7 +200,11 @@ func build(ctx context.Context, cfg config.Config, version string) (http.Handler return nil, nil, fmt.Errorf("build OAuth server: %w", err) } - apiClient := httpsms.NewClient(cfg.APIURL.String()) + // 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, diff --git a/mcp/cmd/server/main_test.go b/mcp/cmd/server/main_test.go index 6396275b..bd85395b 100644 --- a/mcp/cmd/server/main_test.go +++ b/mcp/cmd/server/main_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "net/http" "net/http/httptest" "os" @@ -117,3 +118,84 @@ func TestBuildFailsFastOnInvalidConfiguration(t *testing.T) { 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/internal/httpsms/client.go b/mcp/internal/httpsms/client.go index e61946ad..6d79ff58 100644 --- a/mcp/internal/httpsms/client.go +++ b/mcp/internal/httpsms/client.go @@ -93,6 +93,36 @@ type HTTPClient struct { 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 @@ -103,13 +133,21 @@ var _ Client = (*HTTPClient)(nil) // 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. -func NewClient(baseURL string) *HTTPClient { +// +// 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: responseHeaderTimeout, + ResponseHeaderTimeout: min(responseHeaderTimeout, options.timeout), DialContext: (&net.Dialer{ Timeout: dialTimeout, }).DialContext, @@ -120,7 +158,7 @@ func NewClient(baseURL string) *HTTPClient { return &HTTPClient{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{ - Timeout: requestTimeout, + Timeout: options.timeout, Transport: &queryRedactingTransport{next: instrumented}, }, } diff --git a/mcp/internal/httpsms/client_test.go b/mcp/internal/httpsms/client_test.go index ff587506..1a4ca3cd 100644 --- a/mcp/internal/httpsms/client_test.go +++ b/mcp/internal/httpsms/client_test.go @@ -538,3 +538,42 @@ func readAll(r *http.Request) ([]byte, error) { } 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/oauth/authorize.go b/mcp/internal/oauth/authorize.go index 618df097..b2c51dcf 100644 --- a/mcp/internal/oauth/authorize.go +++ b/mcp/internal/oauth/authorize.go @@ -412,17 +412,25 @@ func (s *Server) HandleFirebaseComplete(w http.ResponseWriter, r *http.Request) // 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] @@ -441,12 +449,84 @@ func (s *Server) renderAuthorizePage(w http.ResponseWriter, transaction Authoriz 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", "frame-ancestors 'none'") + 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. diff --git a/mcp/internal/oauth/authorize_test.go b/mcp/internal/oauth/authorize_test.go index d7ac906a..bb5efee0 100644 --- a/mcp/internal/oauth/authorize_test.go +++ b/mcp/internal/oauth/authorize_test.go @@ -716,8 +716,79 @@ func TestHandleAuthorizeSetsConsentPageProtections(t *testing.T) { 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, "frame-ancestors 'none'", rec.Header().Get("Content-Security-Policy")) 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

    @@ -49,7 +49,7 @@

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

    -