oauth support

This commit is contained in:
ProgrammerIn-wonderland
2026-06-02 21:37:51 -04:00
parent f109ca4591
commit bf4ab4dc47
6 changed files with 338 additions and 17 deletions
+41 -13
View File
@@ -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):
+5 -1
View File
@@ -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);
+19
View File
@@ -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();
+260
View File
@@ -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);
}
+10 -2
View File
@@ -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