From 1b0caeb8930a284345b725d127771754e9f32d0a Mon Sep 17 00:00:00 2001 From: solomon2773 Date: Thu, 27 Aug 2026 15:38:21 -0500 Subject: [PATCH] feat: add OAuth-secured remote MCP transport (#10) --- .env.example | 4 + docs/api/oauth.md | 54 +++- docs/getting-started/environment.md | 1 + docs/mcp.md | 39 ++- mcp/server.mjs | 35 ++- .../migrations/z_13_oauth_mcp/migration.sql | 24 ++ prisma/schema.prisma | 54 ++-- .../oauth-authorization-server/route.ts | 8 + .../oauth-protected-resource/api/mcp/route.ts | 8 + .../oauth-protected-resource/route.ts | 8 + src/app/admin/oauth-clients/page.tsx | 30 ++- src/app/api/cron/route.ts | 15 +- src/app/api/mcp/route.ts | 123 ++++++++++ src/app/oauth/authorize/page.tsx | 50 +++- src/app/oauth/token/route.ts | 195 +++++++++++---- src/app/oauth/userinfo/route.ts | 20 +- src/lib/actions/oauth.ts | 51 +++- src/lib/api-auth.ts | 42 +++- src/lib/oauth-metadata.ts | 34 +++ src/lib/oauth-policy.test.ts | 63 +++++ src/lib/oauth-policy.ts | 132 ++++++++++ src/lib/oauth.ts | 56 +++++ tests/integration/oauth-mcp.test.ts | 232 ++++++++++++++++++ 23 files changed, 1161 insertions(+), 117 deletions(-) create mode 100644 prisma/migrations/z_13_oauth_mcp/migration.sql create mode 100644 src/app/.well-known/oauth-authorization-server/route.ts create mode 100644 src/app/.well-known/oauth-protected-resource/api/mcp/route.ts create mode 100644 src/app/.well-known/oauth-protected-resource/route.ts create mode 100644 src/app/api/mcp/route.ts create mode 100644 src/lib/oauth-metadata.ts create mode 100644 src/lib/oauth-policy.test.ts create mode 100644 src/lib/oauth-policy.ts create mode 100644 src/lib/oauth.ts create mode 100644 tests/integration/oauth-mcp.test.ts diff --git a/.env.example b/.env.example index 275378c..e0b99c9 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,7 @@ ATTACHMENT_STORAGE="database" # ATTACHMENT_S3_FORCE_PATH_STYLE="false" # ATTACHMENT_S3_PREFIX="attachments" # ATTACHMENT_S3_DOWNLOAD_TTL_SECONDS="300" + +# Optional browser origins allowed to call the remote MCP endpoint, separated +# by commas. Non-browser MCP clients normally omit Origin and need no entry. +# MCP_ALLOWED_ORIGINS="https://assistant.example.com" diff --git a/docs/api/oauth.md b/docs/api/oauth.md index 0a72612..77edcb8 100644 --- a/docs/api/oauth.md +++ b/docs/api/oauth.md @@ -1,8 +1,9 @@ # OAuth / SSO provider -OpenHosting can act as an **OAuth2 identity provider**, letting other +OpenHosting can act as an **OAuth 2.1 identity provider**, letting other applications sign users in with their OpenHosting account (single sign-on). This -is distinct from [API keys](rest-api.md), which authenticate *your* automation. +also secures the hosted [MCP endpoint](../mcp.md). It is distinct from API keys, +which authenticate local or server-owned automation. ## Registering a client @@ -10,25 +11,40 @@ Under **Admin → OAuth clients**, create a client with: - **Application name** - **Redirect URIs** — one per line, matched exactly +- **Client type** — public PKCE clients have no secret; confidential clients do +- **Allowed scopes** — standard identity scopes and/or exact + `mcp:tool:` permissions -You get a **client ID** and a **client secret** (shown once). The secret is -stored hashed. +You always get a **client ID**. A confidential client also gets a **client +secret** shown once and stored hashed. Public clients authenticate the code +exchange with PKCE rather than a secret. ## The flow (authorization code) -**1. Send the user to the authorize endpoint:** +**1. Generate a PKCE verifier and its S256 challenge.** The verifier must be +43–128 unreserved characters and stays inside the client. The challenge is +`BASE64URL(SHA256(verifier))`. + +**2. Send the user to the authorize endpoint:** ``` GET /oauth/authorize?response_type=code &client_id=ohc_… &redirect_uri=https://app.example.com/callback &state= + &scope=openid%20profile%20email + &code_challenge= + &code_challenge_method=S256 ``` The user sees a consent screen naming your app, and approves. OpenHosting redirects back to your `redirect_uri` with `?code=…&state=…`. -**2. Exchange the code for a token:** +For remote MCP, request one or more allowed `mcp:tool:*` scopes and also send +`resource=https://your-host/api/mcp`. OpenHosting displays every requested scope +on the consent screen. + +**3. Exchange the code for a token:** ```bash curl -X POST https://your-host/oauth/token \ @@ -36,16 +52,28 @@ curl -X POST https://your-host/oauth/token \ -d code= \ -d client_id=ohc_… \ -d client_secret=ohs_… \ - -d redirect_uri=https://app.example.com/callback + -d redirect_uri=https://app.example.com/callback \ + -d code_verifier= ``` Response: ```json -{ "access_token": "oht_…", "token_type": "Bearer", "expires_in": 2592000 } +{ + "access_token": "oht_…", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "ohr_…", + "scope": "openid profile email" +} ``` -**3. Fetch the user's profile:** +Public clients omit `client_secret`. MCP exchanges repeat the exact `resource` +parameter. Refresh with `grant_type=refresh_token`, the current refresh token, +client authentication, and the same resource; every refresh rotates both +tokens. + +**4. Fetch the user's profile:** ```bash curl https://your-host/oauth/userinfo \ @@ -66,6 +94,12 @@ curl https://your-host/oauth/userinfo \ ## Security notes - Authorization codes are single-use and expire after 10 minutes. +- Every authorization-code flow requires S256 PKCE. - Redirect URIs must match a registered value exactly. - Client secrets and tokens are stored as SHA-256 hashes. -- Access tokens are valid for 30 days. +- Access tokens last one hour; rotating refresh tokens last 30 days. +- MCP access tokens are bound to the exact `/api/mcp` resource and exact tool + scopes. They cannot be reused for a sibling tool or a different audience. +- Authorization Server Metadata is published at + `/.well-known/oauth-authorization-server`; MCP resource metadata is published + at `/.well-known/oauth-protected-resource`. diff --git a/docs/getting-started/environment.md b/docs/getting-started/environment.md index 540702f..c5d69bd 100644 --- a/docs/getting-started/environment.md +++ b/docs/getting-started/environment.md @@ -22,6 +22,7 @@ currency and SMTP is configured in the admin panel instead — see | `ATTACHMENT_S3_FORCE_PATH_STYLE` | | `true` for providers such as a local MinIO deployment that require path-style bucket URLs. | | `ATTACHMENT_S3_PREFIX` | | Object-key prefix. Defaults to `attachments`. | | `ATTACHMENT_S3_DOWNLOAD_TTL_SECONDS` | | Signed download lifetime from 60 to 604800 seconds. Defaults to 300. | +| `MCP_ALLOWED_ORIGINS` | | Comma-separated extra browser origins allowed to call `/api/mcp`. Non-browser clients normally omit `Origin`. | | `PORT` | | Port the server listens on (default `3000`). | | `NODE_ENV` | | Set to `production` in production (the image sets this). | | `SHADOW_DATABASE_URL` | | Only for generating new migrations locally — a spare database Prisma uses as a shadow. | diff --git a/docs/mcp.md b/docs/mcp.md index 3a50715..ba41a48 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -26,9 +26,10 @@ structured results it can reason over. ## Setup -The server needs a base URL and an API key (create one under -**Admin → API keys**). It runs over stdio, launched by the MCP client with -environment variables. +OpenHosting supports two transports: + +- local **stdio**, authenticated with an API key; and +- hosted **Streamable HTTP** at `/api/mcp`, authenticated through OAuth 2.1. ### Claude Desktop / Claude Code @@ -57,6 +58,29 @@ Restart the client; the OpenHosting tools appear in its tool list. Launch `node mcp/server.mjs` with the same environment variables. It speaks MCP over stdio (stdout is the protocol channel; logs go to stderr). +### Remote Streamable HTTP + +1. Under **Admin → OAuth clients**, create a public PKCE client (or a + confidential client when your MCP client can protect a secret). +2. Register the MCP client's exact redirect URI. +3. Allow only the `mcp:tool:` scopes it needs. For example, a + read-only customer lookup client might receive + `mcp:tool:list_users mcp:tool:get_user`. +4. Configure the client with `https://your-host/api/mcp`. + +The unauthenticated endpoint returns a `WWW-Authenticate` challenge pointing to +OAuth Protected Resource Metadata. Discovery documents are available at: + +- `/.well-known/oauth-protected-resource` +- `/.well-known/oauth-protected-resource/api/mcp` +- `/.well-known/oauth-authorization-server` + +Remote authorization requires S256 PKCE. MCP tokens are audience-bound to the +canonical `/api/mcp` resource, expire after one hour, and include a rotating +30-day refresh token. The HTTP server is stateless and returns JSON for ordinary +tool calls; it validates any browser `Origin` against the configured public +origin plus `MCP_ALLOWED_ORIGINS`. + ## Example prompts Once connected, you can ask the assistant things like: @@ -70,10 +94,11 @@ Once connected, you can ask the assistant things like: ## Permissions & safety -The server can only do what its API key's [scopes](api/rest-api.md) permit — -give it a read-only key to let an assistant investigate without being able to -change anything, or a scoped write key for the specific operations you want to -automate. +The stdio server can only do what its API key's [scopes](api/rest-api.md) +permit. Remote clients have a narrower boundary: only tools whose exact +`mcp:tool:` scopes were approved are registered for that request, and the +same token cannot use a sibling REST operation that shares a broader domain +permission. Destructive tools (`service_action` with `terminate`, `mark_invoice_paid`) carry warnings in their descriptions so the assistant treats them carefully, but diff --git a/mcp/server.mjs b/mcp/server.mjs index 4f2cb70..4bceeb6 100755 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -16,19 +16,33 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { pathToFileURL } from "node:url"; import { z } from "zod"; import { OpenHostingClient } from "../cli/client.mjs"; -const client = new OpenHostingClient(); +/** + * Build one MCP server for either stdio or an authenticated HTTP request. + * @param {{ url?: string, apiToken?: string, cronSecret?: string, allowedTools?: string[] }} [options] + */ +export function createOpenHostingMcpServer(options = {}) { +const client = new OpenHostingClient({ + url: options.url, + apiKey: options.apiToken, + cronSecret: options.cronSecret, +}); +const allowedTools = options.allowedTools + ? new Set(options.allowedTools) + : null; const server = new McpServer({ name: "openhosting", - version: "0.3.0", + version: "0.6.0", }); // Wrap a client call so its JSON result becomes MCP text content, and errors // become a readable tool error instead of crashing the connection. function tool(name, config, fn) { + if (allowedTools && !allowedTools.has(name)) return; server.registerTool(name, config, async (args) => { try { const result = await fn(args ?? {}); @@ -205,8 +219,17 @@ tool("run_billing_cron", { inputSchema: {}, }, () => client.runCron()); +return server; +} + // ── Start ──────────────────────────────────────────────────────────────────── -const transport = new StdioServerTransport(); -await server.connect(transport); -// stderr is safe for logs (stdout is the MCP channel) -console.error("OpenHosting MCP server running on stdio."); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const server = createOpenHostingMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + // stderr is safe for logs (stdout is the MCP channel) + console.error("OpenHosting MCP server running on stdio."); +} diff --git a/prisma/migrations/z_13_oauth_mcp/migration.sql b/prisma/migrations/z_13_oauth_mcp/migration.sql new file mode 100644 index 0000000..db6583b --- /dev/null +++ b/prisma/migrations/z_13_oauth_mcp/migration.sql @@ -0,0 +1,24 @@ +-- Authorization codes are short-lived and cannot be upgraded safely without +-- their original PKCE challenge, so invalidate any code issued before this +-- OAuth 2.1 migration. +DELETE FROM "OauthCode"; + +ALTER TABLE "OauthClient" +ALTER COLUMN "clientSecret" DROP NOT NULL, +ADD COLUMN "publicClient" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "allowedScopes" JSONB NOT NULL DEFAULT '["openid","profile","email"]'; + +ALTER TABLE "OauthCode" +ADD COLUMN "codeChallenge" TEXT NOT NULL, +ADD COLUMN "scopes" JSONB NOT NULL, +ADD COLUMN "resource" TEXT; + +ALTER TABLE "OauthToken" +ADD COLUMN "scopes" JSONB NOT NULL DEFAULT '["openid","profile","email"]', +ADD COLUMN "resource" TEXT, +ADD COLUMN "refreshTokenHash" TEXT, +ADD COLUMN "refreshExpiresAt" TIMESTAMP(3), +ADD COLUMN "revokedAt" TIMESTAMP(3); + +CREATE UNIQUE INDEX "OauthToken_refreshTokenHash_key" +ON "OauthToken"("refreshTokenHash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3299597..e6de773 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -707,34 +707,44 @@ model AffiliateCommission { // ── OAuth2 provider (authorization-code flow) ─────────────────────────────── model OauthClient { - id String @id @default(cuid()) - name String - clientId String @unique - clientSecret String // sha-256 hash; raw shown once at creation - redirectUris String // newline-separated exact-match URIs - createdAt DateTime @default(now()) - codes OauthCode[] - tokens OauthToken[] + id String @id @default(cuid()) + name String + clientId String @unique + clientSecret String? // sha-256 hash; null for a public PKCE client + publicClient Boolean @default(false) + allowedScopes Json @default("[\"openid\",\"profile\",\"email\"]") + redirectUris String // newline-separated exact-match URIs + createdAt DateTime @default(now()) + codes OauthCode[] + tokens OauthToken[] } model OauthCode { - id String @id @default(cuid()) - codeHash String @unique - clientId String - client OauthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) - userId String - redirectUri String - expiresAt DateTime + id String @id @default(cuid()) + codeHash String @unique + clientId String + client OauthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + userId String + redirectUri String + codeChallenge String + scopes Json + resource String? + expiresAt DateTime } model OauthToken { - id String @id @default(cuid()) - tokenHash String @unique - clientId String - client OauthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) - userId String - expiresAt DateTime - createdAt DateTime @default(now()) + id String @id @default(cuid()) + tokenHash String @unique + clientId String + client OauthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + userId String + scopes Json @default("[\"openid\",\"profile\",\"email\"]") + resource String? + refreshTokenHash String? @unique + refreshExpiresAt DateTime? + revokedAt DateTime? + expiresAt DateTime + createdAt DateTime @default(now()) } // ── Knowledgebase ─────────────────────────────────────────────────────────── diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 0000000..e8bfbb8 --- /dev/null +++ b/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { authorizationServerMetadata } from "@/lib/oauth-metadata"; + +export async function GET() { + return NextResponse.json(await authorizationServerMetadata(), { + headers: { "Cache-Control": "public, max-age=3600" }, + }); +} diff --git a/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts b/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts new file mode 100644 index 0000000..9546f27 --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { protectedResourceMetadata } from "@/lib/oauth-metadata"; + +export async function GET() { + return NextResponse.json(await protectedResourceMetadata(), { + headers: { "Cache-Control": "public, max-age=3600" }, + }); +} diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 0000000..9546f27 --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { protectedResourceMetadata } from "@/lib/oauth-metadata"; + +export async function GET() { + return NextResponse.json(await protectedResourceMetadata(), { + headers: { "Cache-Control": "public, max-age=3600" }, + }); +} diff --git a/src/app/admin/oauth-clients/page.tsx b/src/app/admin/oauth-clients/page.tsx index f744bca..6072144 100644 --- a/src/app/admin/oauth-clients/page.tsx +++ b/src/app/admin/oauth-clients/page.tsx @@ -3,6 +3,7 @@ import { db } from "@/lib/db"; import { formatDate } from "@/lib/format"; import { createOauthClient, deleteOauthClient } from "@/lib/actions/oauth"; import { ActionForm, SubmitButton } from "@/components/forms"; +import { MCP_TOOL_SCOPES } from "@/lib/oauth-policy"; export const metadata = { title: "OAuth clients" }; @@ -29,6 +30,7 @@ export default async function AdminOauthClientsPage() { Name Client ID + Type Tokens Created @@ -43,6 +45,7 @@ export default async function AdminOauthClientsPage() { {client.clientId} + {client.publicClient ? "Public PKCE" : "Confidential"} {client._count.tokens} {formatDate(client.createdAt)} @@ -60,7 +63,7 @@ export default async function AdminOauthClientsPage() { ))} {clients.length === 0 && ( - + No OAuth clients yet. @@ -76,6 +79,31 @@ export default async function AdminOauthClientsPage() { + +
+ +