Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
54 changes: 44 additions & 10 deletions docs/api/oauth.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,79 @@
# 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

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:<tool_name>` 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=<random>
&scope=openid%20profile%20email
&code_challenge=<s256-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 \
-d grant_type=authorization_code \
-d code=<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=<original-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 \
Expand All @@ -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`.
1 change: 1 addition & 0 deletions docs/getting-started/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
39 changes: 32 additions & 7 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:<tool_name>` 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:
Expand All @@ -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:<name>` 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
Expand Down
35 changes: 29 additions & 6 deletions mcp/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {});
Expand Down Expand Up @@ -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.");
}
24 changes: 24 additions & 0 deletions prisma/migrations/z_13_oauth_mcp/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
54 changes: 32 additions & 22 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down
8 changes: 8 additions & 0 deletions src/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}
8 changes: 8 additions & 0 deletions src/app/.well-known/oauth-protected-resource/api/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}
8 changes: 8 additions & 0 deletions src/app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
}
Loading
Loading