mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 16:21:40 +00:00
oauth support
This commit is contained in:
+41
-13
@@ -4,9 +4,10 @@ An [MCP](https://modelcontextprotocol.io) server that runs on Cloudflare Workers
|
||||
and lets **anyone use their own Puter auth token** to drive their Puter account's
|
||||
filesystem and subdomains from an MCP client (Claude, Cursor, etc.).
|
||||
|
||||
The Worker stores **no credentials of its own**. Every request carries the
|
||||
caller's token via a standard `Authorization: Bearer <token>` header, and all
|
||||
operations run as that user.
|
||||
The Worker stores **no credentials of its own**. Every request runs as the
|
||||
caller, authenticated either by a `Authorization: Bearer <token>` header or by an
|
||||
OAuth "Sign in with Puter" flow the Worker hosts itself (see
|
||||
[Authentication](#authentication)).
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -76,8 +77,9 @@ registered onto the forked router as routes.
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| [`src/s2w-router.js`](src/s2w-router.js) | Forked router: builds `event.user.puter` from the bearer token (no `me.puter`). |
|
||||
| [`src/index.js`](src/index.js) | Entry: `initS2w()` + registers MCP routes. |
|
||||
| [`src/mcp.js`](src/mcp.js) | MCP JSON-RPC dispatch (initialize / tools.list / tools.call). |
|
||||
| [`src/index.js`](src/index.js) | Entry: `initS2w()` + registers MCP and OAuth routes. |
|
||||
| [`src/mcp.js`](src/mcp.js) | MCP JSON-RPC dispatch; 401 + `WWW-Authenticate` when unauthenticated. |
|
||||
| [`src/oauth.js`](src/oauth.js) | OAuth bridge: discovery, `/register`, `/authorize`→authme, `/oauth/callback`, `/token`. |
|
||||
| [`src/tools.js`](src/tools.js) | The 12 tools, calling real `puter.fs.*` / `puter.hosting.*`. |
|
||||
| [`template/puter-portable.template`](template/puter-portable.template) | Preamble template (defines `init_puter_portable`, inlines puter.js). |
|
||||
|
||||
@@ -95,18 +97,44 @@ npm run dev # builds, then wrangler dev — serves on http://localhost:87
|
||||
npm run deploy # builds, then wrangler deploy
|
||||
```
|
||||
|
||||
To target a self-hosted Puter instance, set `puter_endpoint` (uncomment the
|
||||
`[vars]` block in `wrangler.toml`). The router reads `globalThis.puter_endpoint`
|
||||
and defaults to `https://api.puter.com`.
|
||||
To target a self-hosted Puter instance, set `puter_endpoint` / `puter_gui_origin`
|
||||
(uncomment the `[vars]` block in `wrangler.toml`). They default to
|
||||
`https://api.puter.com` and `https://puter.com`.
|
||||
|
||||
## Getting a Puter token
|
||||
If you use the OAuth flow (below), also set the sealing secret in production:
|
||||
|
||||
In a browser logged into Puter, open the devtools console and run
|
||||
`puter.authToken`. Treat it like a password.
|
||||
```bash
|
||||
wrangler secret put OAUTH_SECRET
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Two ways, both running as the caller — the Worker holds no credentials of its own:
|
||||
|
||||
1. **OAuth "Sign in with Puter"** (no token to copy). For clients that support
|
||||
OAuth over HTTP (e.g. Claude Code), the Worker *is* the authorization server:
|
||||
on first use the client opens a browser, you sign into Puter and approve, and
|
||||
the Worker hands the client your Puter token. See
|
||||
[`src/oauth.js`](src/oauth.js). Under the hood it redirects to Puter's
|
||||
`?action=authme` page and catches the returned token on its `/oauth/callback`;
|
||||
the short-lived flow/code blobs are AES-GCM sealed with `OAUTH_SECRET`, so the
|
||||
Worker stays stateless.
|
||||
2. **Bearer token** (copy/paste). Get it from a logged-in Puter browser tab's
|
||||
devtools console: `puter.authToken`. Treat it like a password. Pass it as an
|
||||
`Authorization: Bearer <token>` header (or the `.mcpb` token field).
|
||||
|
||||
## Connecting a client
|
||||
|
||||
### Option A — one-click `.mcpb` bundle (Claude Desktop etc.)
|
||||
### Option A — Claude Code (OAuth, no token)
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http puter https://puter-mcp.<your-subdomain>.workers.dev/
|
||||
```
|
||||
|
||||
On first use Claude Code opens a browser to sign in with Puter; approve, and it's
|
||||
connected. (If you'd rather skip OAuth, add `-H "Authorization: Bearer YOUR_PUTER_TOKEN"`.)
|
||||
|
||||
### Option B — one-click `.mcpb` bundle (Claude Desktop etc.)
|
||||
|
||||
[`puter-mcp-connector.mcpb`](puter-mcp-connector.mcpb) is a prebuilt [MCP
|
||||
Bundle](https://github.com/anthropics/mcpb). Import it into a host that supports
|
||||
@@ -131,7 +159,7 @@ npm run pack:mcpb # -> puter-mcp-connector.mcpb
|
||||
The `.mcpb` is unsigned; hosts may warn it's from an unknown developer. To
|
||||
self-sign: `npx @anthropic-ai/mcpb sign --self-signed puter-mcp-connector.mcpb`.
|
||||
|
||||
### Option B — direct HTTP
|
||||
### Option C — direct HTTP
|
||||
|
||||
Point any MCP client that supports HTTP transport at the Worker URL and add your
|
||||
token as a bearer header. Example (`mcp.json`-style):
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import initS2w from './s2w-router.js';
|
||||
import registerMcpRoutes from './mcp.js';
|
||||
import registerOAuthRoutes from './oauth.js';
|
||||
|
||||
// Bring up the (forked) Puter worker router, then register the MCP routes on it.
|
||||
// Bring up the (forked) Puter worker router, then register the routes on it.
|
||||
initS2w();
|
||||
registerMcpRoutes(globalThis.router);
|
||||
// OAuth bridge — lets clients obtain the caller's Puter token via "Sign in with
|
||||
// Puter" instead of pasting it. The Authorization: Bearer path is unchanged.
|
||||
registerOAuthRoutes(globalThis.router);
|
||||
|
||||
@@ -148,6 +148,25 @@ function jsonResponse(body) {
|
||||
async function mcpPost(event) {
|
||||
const userPuter = event.user && event.user.puter;
|
||||
|
||||
// No bearer token: this is a protected resource, so reply 401 with a
|
||||
// WWW-Authenticate pointing at our resource metadata. That's the signal an
|
||||
// MCP client (e.g. Claude Code) uses to start the OAuth flow (/authorize).
|
||||
// Clients that pass Authorization: Bearer never hit this branch.
|
||||
if (!userPuter) {
|
||||
const origin = new URL(event.request.url).origin;
|
||||
return new Response(
|
||||
JSON.stringify(rpcError(null, INVALID_REQUEST, 'Authentication required')),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await event.request.json();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// OAuth 2.0 bridge: lets MCP clients (e.g. Claude Code) obtain the caller's
|
||||
// Puter token automatically instead of pasting it.
|
||||
//
|
||||
// The worker IS the authorization server. It never opens a browser — the MCP
|
||||
// client does, by navigating to /authorize. From there:
|
||||
//
|
||||
// client → GET /authorize -> 302 to puter.com/?action=authme&redirectURL=<worker>/oauth/callback?flow=…
|
||||
// (user logs in + approves on puter.com; Puter 302s back with ?token=…)
|
||||
// puter → GET /oauth/callback -> 302 to <client redirect_uri>?code=…&state=…
|
||||
// client → POST /token -> { access_token: <puter token> }
|
||||
// client → MCP calls with Authorization: Bearer <puter token> (the existing path)
|
||||
//
|
||||
// Stateless: the short-lived `flow` (authorize→callback) and `code`
|
||||
// (callback→token) blobs are AES-GCM sealed with a worker secret, so nothing is
|
||||
// persisted. PKCE (S256) is enforced when the client provides a challenge.
|
||||
//
|
||||
// Discovery: serves RFC 8414 (authorization-server) and RFC 9728
|
||||
// (protected-resource) metadata, plus RFC 7591 dynamic client registration.
|
||||
|
||||
const DEFAULT_GUI_ORIGIN = 'https://puter.com';
|
||||
const FLOW_TTL_MS = 10 * 60 * 1000; // authorize -> callback
|
||||
const CODE_TTL_MS = 5 * 60 * 1000; // callback -> token
|
||||
|
||||
const guiOrigin = () => globalThis.puter_gui_origin || DEFAULT_GUI_ORIGIN;
|
||||
|
||||
// A stable secret is required to seal/unseal across requests and isolates.
|
||||
// Set it in production: `wrangler secret put OAUTH_SECRET`. The dev fallback
|
||||
// keeps local `wrangler dev` working but must NOT be relied on in production.
|
||||
const secretString = () =>
|
||||
globalThis.OAUTH_SECRET || 'puter-mcp-dev-insecure-secret-change-me';
|
||||
|
||||
const originOf = (request) => new URL(request.url).origin;
|
||||
|
||||
// ---- base64url + AES-GCM sealing ------------------------------------------
|
||||
|
||||
function b64urlEncode(bytes) {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function b64urlDecode(str) {
|
||||
let s = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function aesKey() {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secretString()));
|
||||
return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
async function seal(obj) {
|
||||
const key = await aesKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const data = new TextEncoder().encode(JSON.stringify(obj));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data));
|
||||
const out = new Uint8Array(iv.length + ct.length);
|
||||
out.set(iv, 0);
|
||||
out.set(ct, iv.length);
|
||||
return b64urlEncode(out);
|
||||
}
|
||||
|
||||
async function unseal(blob) {
|
||||
const key = await aesKey();
|
||||
const raw = b64urlDecode(blob);
|
||||
const iv = raw.slice(0, 12);
|
||||
const ct = raw.slice(12);
|
||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
||||
return JSON.parse(new TextDecoder().decode(pt));
|
||||
}
|
||||
|
||||
async function sha256b64url(str) {
|
||||
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
return b64urlEncode(new Uint8Array(h));
|
||||
}
|
||||
|
||||
// ---- response helpers ------------------------------------------------------
|
||||
|
||||
function json(status, obj) {
|
||||
return new Response(JSON.stringify(obj), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
}
|
||||
|
||||
function redirect(location) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: location, 'Access-Control-Allow-Origin': '*' },
|
||||
});
|
||||
}
|
||||
|
||||
function redirectWithParams(baseUrl, params) {
|
||||
const u = new URL(baseUrl);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v);
|
||||
}
|
||||
return redirect(u.href);
|
||||
}
|
||||
|
||||
// ---- metadata (RFC 8414 / RFC 9728) ---------------------------------------
|
||||
|
||||
function authServerMetadata(event) {
|
||||
const origin = originOf(event.request);
|
||||
return json(200, {
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
registration_endpoint: `${origin}/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
scopes_supported: ['puter'],
|
||||
});
|
||||
}
|
||||
|
||||
function protectedResourceMetadata(event) {
|
||||
const origin = originOf(event.request);
|
||||
return json(200, {
|
||||
resource: origin,
|
||||
authorization_servers: [origin],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- endpoints -------------------------------------------------------------
|
||||
|
||||
// GET /authorize — kick off the flow by sending the browser to Puter's authme.
|
||||
async function authorize(event) {
|
||||
const p = new URL(event.request.url).searchParams;
|
||||
const responseType = p.get('response_type');
|
||||
const redirectUri = p.get('redirect_uri');
|
||||
const clientState = p.get('state') || '';
|
||||
const codeChallenge = p.get('code_challenge') || '';
|
||||
const codeChallengeMethod = p.get('code_challenge_method') || (codeChallenge ? 'plain' : '');
|
||||
|
||||
if (responseType !== 'code' || !redirectUri) {
|
||||
return json(400, {
|
||||
error: 'invalid_request',
|
||||
error_description: 'response_type=code and redirect_uri are required',
|
||||
});
|
||||
}
|
||||
|
||||
const flow = await seal({
|
||||
redirectUri,
|
||||
clientState,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
const callbackUrl = `${originOf(event.request)}/oauth/callback?flow=${encodeURIComponent(flow)}`;
|
||||
const dest = `${guiOrigin()}/?action=authme&redirectURL=${encodeURIComponent(callbackUrl)}`;
|
||||
return redirect(dest);
|
||||
}
|
||||
|
||||
// GET /oauth/callback — Puter redirected here with ?token=… ; mint an auth code.
|
||||
async function callback(event) {
|
||||
const params = new URL(event.request.url).searchParams;
|
||||
const flowBlob = params.get('flow');
|
||||
const token = params.get('token');
|
||||
|
||||
let flow;
|
||||
try {
|
||||
flow = await unseal(flowBlob);
|
||||
} catch {
|
||||
return json(400, { error: 'invalid_request', error_description: 'invalid or missing flow' });
|
||||
}
|
||||
if (!flow || Date.now() - flow.ts > FLOW_TTL_MS) {
|
||||
return json(400, { error: 'invalid_request', error_description: 'authorization flow expired' });
|
||||
}
|
||||
|
||||
// No token => user declined the authme consent (or Puter returned nothing).
|
||||
if (!token) {
|
||||
return redirectWithParams(flow.redirectUri, { error: 'access_denied', state: flow.clientState });
|
||||
}
|
||||
|
||||
const code = await seal({
|
||||
token,
|
||||
codeChallenge: flow.codeChallenge,
|
||||
codeChallengeMethod: flow.codeChallengeMethod,
|
||||
ts: Date.now(),
|
||||
});
|
||||
return redirectWithParams(flow.redirectUri, { code, state: flow.clientState });
|
||||
}
|
||||
|
||||
async function parseForm(request) {
|
||||
const ct = request.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const obj = await request.json().catch(() => ({}));
|
||||
return new URLSearchParams(Object.entries(obj).map(([k, v]) => [k, String(v)]));
|
||||
}
|
||||
return new URLSearchParams(await request.text());
|
||||
}
|
||||
|
||||
// POST /token — exchange the auth code (+PKCE) for the Puter token.
|
||||
async function token(event) {
|
||||
const form = await parseForm(event.request);
|
||||
if (form.get('grant_type') !== 'authorization_code') {
|
||||
return json(400, { error: 'unsupported_grant_type' });
|
||||
}
|
||||
|
||||
const code = form.get('code') || '';
|
||||
const verifier = form.get('code_verifier') || '';
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await unseal(code);
|
||||
} catch {
|
||||
return json(400, { error: 'invalid_grant', error_description: 'invalid authorization code' });
|
||||
}
|
||||
if (!payload || Date.now() - payload.ts > CODE_TTL_MS) {
|
||||
return json(400, { error: 'invalid_grant', error_description: 'authorization code expired' });
|
||||
}
|
||||
|
||||
// PKCE: enforce only if the client supplied a challenge at /authorize.
|
||||
if (payload.codeChallenge) {
|
||||
const ok = payload.codeChallengeMethod === 'S256'
|
||||
? (await sha256b64url(verifier)) === payload.codeChallenge
|
||||
: verifier === payload.codeChallenge;
|
||||
if (!ok) {
|
||||
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// The access token IS the Puter token — the bearer path consumes it as-is.
|
||||
return json(200, { access_token: payload.token, token_type: 'Bearer', scope: 'puter' });
|
||||
}
|
||||
|
||||
// POST /register — dynamic client registration (RFC 7591). We don't persist
|
||||
// clients; security rests on PKCE + the redirect_uri sealed into the flow.
|
||||
async function register(event) {
|
||||
const body = await event.request.json().catch(() => ({}));
|
||||
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
||||
return json(201, {
|
||||
client_id: `puter-mcp-${crypto.randomUUID()}`,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
/** Attach the OAuth routes to the (already-initialized) router. */
|
||||
export default function registerOAuthRoutes(router) {
|
||||
router.get('/.well-known/oauth-authorization-server', authServerMetadata);
|
||||
router.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
|
||||
// Path-suffixed variants some clients probe (resource = the /mcp endpoint).
|
||||
router.get('/.well-known/oauth-authorization-server/mcp', authServerMetadata);
|
||||
router.get('/.well-known/oauth-protected-resource/mcp', protectedResourceMetadata);
|
||||
router.post('/register', register);
|
||||
router.get('/authorize', authorize);
|
||||
router.get('/oauth/callback', callback);
|
||||
router.post('/token', token);
|
||||
}
|
||||
@@ -12,7 +12,15 @@ compatibility_date = "2025-01-01"
|
||||
# statement"). Uploading the script as-is keeps it a sloppy-mode service worker.
|
||||
no_bundle = true
|
||||
|
||||
# Optional: point the server at a self-hosted Puter API instead of the public
|
||||
# one. The router reads globalThis.puter_endpoint; bind it as a var to override.
|
||||
# Optional vars (exposed as globals in the service-worker script):
|
||||
# puter_endpoint - Puter API origin for fs/subdomain calls (default https://api.puter.com)
|
||||
# puter_gui_origin - Puter GUI origin for the OAuth "Sign in with Puter"
|
||||
# (authme) redirect (default https://puter.com)
|
||||
# [vars]
|
||||
# puter_endpoint = "https://api.puter.com"
|
||||
# puter_gui_origin = "https://puter.com"
|
||||
|
||||
# OAuth bridge secret — used to seal the stateless authorization-flow / code
|
||||
# blobs (AES-GCM). REQUIRED in production; set it as a secret, do NOT commit it:
|
||||
# wrangler secret put OAUTH_SECRET
|
||||
# (Local `wrangler dev` falls back to an insecure default if unset.)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from '../../../lib/path.js';
|
||||
import * as utils from '../../../lib/utils.js';
|
||||
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
|
||||
import { showUsageLimitDialog } from '../../../modules/UsageLimitDialog.js';
|
||||
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
|
||||
|
||||
/* eslint-disable */
|
||||
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
|
||||
@@ -13,6 +13,7 @@ const SIGNED_BATCH_CHUNK_PIPELINE_CONCURRENCY = 4;
|
||||
const SIGNED_BATCH_FILE_UPLOAD_CONCURRENCY = 8;
|
||||
const SIGNED_MULTIPART_PART_UPLOAD_CONCURRENCY = 8;
|
||||
const SIGNED_BATCH_WRITE_UNAVAILABLE_STATUSES = new Set([404, 405, 501]);
|
||||
const SIGNED_BATCH_SUPPORTED_ENVS = ['web', 'gui', 'app'];
|
||||
|
||||
const isLikelyImageFile = (file) => {
|
||||
if ( ! file ) return false;
|
||||
@@ -605,6 +606,7 @@ const upload = async function (items, dirPath, options = {}) {
|
||||
|
||||
|
||||
const shouldAttemptSignedBatchWrite = (
|
||||
SIGNED_BATCH_SUPPORTED_ENVS.includes(puter.env) &&
|
||||
!options.shortcutTo &&
|
||||
(files.length > 0 || signedDirectories.length > 0) &&
|
||||
signedBatchWriteAllowed
|
||||
|
||||
Reference in New Issue
Block a user