webcake-landing-mcp 1.0.76 → 1.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * A THIN OAuth 2.1 Authorization Server, embedded in the MCP server itself.
3
+ *
4
+ * Why this exists: to be listed in the Claude Connectors Directory (and ChatGPT
5
+ * App Directory) the remote MCP must be an OAuth 2.1 *protected resource* — each
6
+ * user completes a real consent/login flow and the connector gets a per-user
7
+ * access token. The Webcake backend (landing_page_backend) has no OAuth endpoints,
8
+ * so instead of building a full OAuth server in Elixir we wrap the login that
9
+ * ALREADY exists: the browser "connect" page (builderx_spa `/mcp-connect`) that
10
+ * hands back the user's landing JWT (`ljwt`). See ../auth/login.ts for that flow.
11
+ *
12
+ * The shape we implement (minimal but spec-conformant for the MCP clients):
13
+ * - Dynamic Client Registration (POST /register) — open, public clients
14
+ * - Authorization Code + PKCE S256 (GET /authorize) — code_challenge required
15
+ * - Token endpoint (POST /token) authorization_code + refresh_token
16
+ * - Authorization Server + Protected Resource metadata (the /.well-known docs)
17
+ *
18
+ * Access tokens are OPAQUE random strings mapped to the user's `ljwt` in the
19
+ * store (so they can be revoked and the ljwt never leaves the server). The HTTP
20
+ * layer resolves a Bearer access token to its ljwt and injects it as the normal
21
+ * `x-webcake-jwt` header, so the rest of the server (persistence/config.ts) is
22
+ * UNCHANGED and the legacy `?jwt=` / `x-webcake-jwt` paths keep working untouched.
23
+ *
24
+ * STORE: Postgres when DATABASE_URL is set (tokens survive a `serve` restart and
25
+ * are shared across instances behind a load balancer), else in-memory maps
26
+ * (single-instance `serve`, stdio/`npx`, offline tests). Both implement the same
27
+ * async `OAuthStore` interface; the rest of this module is backend-agnostic. All
28
+ * exported state functions are async — callers (src/http.ts) `await` them.
29
+ */
30
+ import { randomBytes, createHash } from "node:crypto";
31
+ import { getPg, ensureOAuthSchema } from "../persistence/postgres.js";
32
+ // ---- TTLs (override via env where useful) ---------------------------------
33
+ const TEN_MIN = 10 * 60 * 1000;
34
+ const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
35
+ const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
36
+ const CODE_TTL = TEN_MIN;
37
+ const PENDING_TTL = TEN_MIN;
38
+ function now() {
39
+ return Date.now();
40
+ }
41
+ function token(bytes = 32) {
42
+ return randomBytes(bytes).toString("base64url");
43
+ }
44
+ // ---- In-memory store (default; no DATABASE_URL) ---------------------------
45
+ class MemoryStore {
46
+ clients = new Map();
47
+ pending = new Map();
48
+ codes = new Map();
49
+ access = new Map();
50
+ refresh = new Map();
51
+ /** Lazy sweep of anything expired — cheap, called on the hot paths. */
52
+ sweep() {
53
+ const t = now();
54
+ for (const [k, v] of this.pending)
55
+ if (v.expiresAt < t)
56
+ this.pending.delete(k);
57
+ for (const [k, v] of this.codes)
58
+ if (v.expiresAt < t)
59
+ this.codes.delete(k);
60
+ for (const [k, v] of this.access)
61
+ if (v.expiresAt < t)
62
+ this.access.delete(k);
63
+ for (const [k, v] of this.refresh)
64
+ if (v.expiresAt < t)
65
+ this.refresh.delete(k);
66
+ }
67
+ async putClient(c) {
68
+ this.clients.set(c.client_id, c);
69
+ }
70
+ async getClient(id) {
71
+ return this.clients.get(id);
72
+ }
73
+ async putPending(state, p) {
74
+ this.sweep();
75
+ this.pending.set(state, p);
76
+ }
77
+ async takePending(state) {
78
+ this.sweep();
79
+ const p = this.pending.get(state);
80
+ this.pending.delete(state);
81
+ return p && p.expiresAt >= now() ? p : undefined;
82
+ }
83
+ async putCode(code, c) {
84
+ this.codes.set(code, c);
85
+ }
86
+ async takeCode(code) {
87
+ this.sweep();
88
+ const c = this.codes.get(code);
89
+ this.codes.delete(code);
90
+ return c && c.expiresAt >= now() ? c : undefined;
91
+ }
92
+ async putAccess(t, a) {
93
+ this.access.set(t, a);
94
+ }
95
+ async getAccess(t) {
96
+ const a = this.access.get(t);
97
+ if (a && a.expiresAt < now()) {
98
+ this.access.delete(t);
99
+ return undefined;
100
+ }
101
+ return a;
102
+ }
103
+ async putRefresh(t, r) {
104
+ this.refresh.set(t, r);
105
+ }
106
+ async takeRefresh(t) {
107
+ this.sweep();
108
+ const r = this.refresh.get(t);
109
+ this.refresh.delete(t);
110
+ return r && r.expiresAt >= now() ? r : undefined;
111
+ }
112
+ async revoke(t) {
113
+ this.access.delete(t);
114
+ this.refresh.delete(t);
115
+ }
116
+ }
117
+ // ---- Postgres store (when DATABASE_URL is set) ----------------------------
118
+ class PgStore {
119
+ pool;
120
+ constructor(pool) {
121
+ this.pool = pool;
122
+ }
123
+ async putClient(c) {
124
+ await this.pool.query(`INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
125
+ VALUES ($1,$2,$3,$4)
126
+ ON CONFLICT (client_id) DO UPDATE SET client_name=$2, redirect_uris=$3`, [c.client_id, c.client_name ?? null, JSON.stringify(c.redirect_uris), c.created_at]);
127
+ }
128
+ async getClient(id) {
129
+ const { rows } = await this.pool.query(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id=$1`, [id]);
130
+ const r = rows[0];
131
+ if (!r)
132
+ return undefined;
133
+ return {
134
+ client_id: r.client_id,
135
+ client_name: r.client_name ?? undefined,
136
+ redirect_uris: Array.isArray(r.redirect_uris) ? r.redirect_uris : JSON.parse(r.redirect_uris),
137
+ created_at: Number(r.created_at),
138
+ };
139
+ }
140
+ async putPending(state, p) {
141
+ await this.pool.query(`INSERT INTO oauth_pending (state, client_id, redirect_uri, code_challenge, client_state, scope, expires_at)
142
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`, [state, p.client_id, p.redirect_uri, p.code_challenge, p.state ?? null, p.scope ?? null, p.expiresAt]);
143
+ }
144
+ async takePending(state) {
145
+ const { rows } = await this.pool.query(`DELETE FROM oauth_pending WHERE state=$1 RETURNING client_id, redirect_uri, code_challenge, client_state, scope, expires_at`, [state]);
146
+ const r = rows[0];
147
+ if (!r || Number(r.expires_at) < now())
148
+ return undefined;
149
+ return {
150
+ client_id: r.client_id,
151
+ redirect_uri: r.redirect_uri,
152
+ code_challenge: r.code_challenge,
153
+ state: r.client_state ?? undefined,
154
+ scope: r.scope ?? undefined,
155
+ expiresAt: Number(r.expires_at),
156
+ };
157
+ }
158
+ async putCode(code, c) {
159
+ await this.pool.query(`INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, scope, ljwt, expires_at)
160
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`, [code, c.client_id, c.redirect_uri, c.code_challenge, c.scope ?? null, c.ljwt, c.expiresAt]);
161
+ }
162
+ async takeCode(code) {
163
+ const { rows } = await this.pool.query(`DELETE FROM oauth_codes WHERE code=$1 RETURNING client_id, redirect_uri, code_challenge, scope, ljwt, expires_at`, [code]);
164
+ const r = rows[0];
165
+ if (!r || Number(r.expires_at) < now())
166
+ return undefined;
167
+ return {
168
+ client_id: r.client_id,
169
+ redirect_uri: r.redirect_uri,
170
+ code_challenge: r.code_challenge,
171
+ scope: r.scope ?? undefined,
172
+ ljwt: r.ljwt,
173
+ expiresAt: Number(r.expires_at),
174
+ };
175
+ }
176
+ async putAccess(t, a) {
177
+ await this.pool.query(`INSERT INTO oauth_access_tokens (token, ljwt, scope, expires_at) VALUES ($1,$2,$3,$4)`, [t, a.ljwt, a.scope ?? null, a.expiresAt]);
178
+ }
179
+ async getAccess(t) {
180
+ const { rows } = await this.pool.query(`SELECT ljwt, scope, expires_at FROM oauth_access_tokens WHERE token=$1`, [t]);
181
+ const r = rows[0];
182
+ if (!r)
183
+ return undefined;
184
+ if (Number(r.expires_at) < now()) {
185
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
186
+ return undefined;
187
+ }
188
+ return { ljwt: r.ljwt, scope: r.scope ?? undefined, expiresAt: Number(r.expires_at) };
189
+ }
190
+ async putRefresh(t, r) {
191
+ await this.pool.query(`INSERT INTO oauth_refresh_tokens (token, ljwt, client_id, scope, expires_at) VALUES ($1,$2,$3,$4,$5)`, [t, r.ljwt, r.client_id, r.scope ?? null, r.expiresAt]);
192
+ }
193
+ async takeRefresh(t) {
194
+ const { rows } = await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1 RETURNING ljwt, client_id, scope, expires_at`, [t]);
195
+ const r = rows[0];
196
+ if (!r || Number(r.expires_at) < now())
197
+ return undefined;
198
+ return {
199
+ ljwt: r.ljwt,
200
+ client_id: r.client_id,
201
+ scope: r.scope ?? undefined,
202
+ expiresAt: Number(r.expires_at),
203
+ };
204
+ }
205
+ async revoke(t) {
206
+ await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
207
+ await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1`, [t]);
208
+ }
209
+ }
210
+ // ---- Backend selection (memoized) -----------------------------------------
211
+ const memoryStore = new MemoryStore();
212
+ let storePromise;
213
+ /** Resolve the active store ONCE: Postgres if configured + schema ready, else memory. */
214
+ function getStore() {
215
+ if (storePromise)
216
+ return storePromise;
217
+ storePromise = (async () => {
218
+ const pool = getPg();
219
+ if (pool && (await ensureOAuthSchema(pool)))
220
+ return new PgStore(pool);
221
+ return memoryStore;
222
+ })().catch((e) => {
223
+ console.error("[oauth] store init failed, using in-memory:", e?.message ?? e);
224
+ return memoryStore;
225
+ });
226
+ return storePromise;
227
+ }
228
+ // ---- PKCE -----------------------------------------------------------------
229
+ /** base64url( SHA256(verifier) ) — the S256 transform. */
230
+ export function s256(verifier) {
231
+ return createHash("sha256").update(verifier).digest("base64url");
232
+ }
233
+ export function verifyPkce(verifier, challenge) {
234
+ if (!verifier || !challenge)
235
+ return false;
236
+ // Constant-time-ish compare on equal-length base64url strings.
237
+ const a = s256(verifier);
238
+ if (a.length !== challenge.length)
239
+ return false;
240
+ let diff = 0;
241
+ for (let i = 0; i < a.length; i++)
242
+ diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
243
+ return diff === 0;
244
+ }
245
+ export async function registerClient(body) {
246
+ const uris = Array.isArray(body?.redirect_uris)
247
+ ? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
248
+ : [];
249
+ if (uris.length === 0) {
250
+ return { ok: false, error: "invalid_redirect_uri", error_description: "redirect_uris must contain at least one absolute http(s) URI." };
251
+ }
252
+ const client = {
253
+ client_id: token(16),
254
+ client_name: typeof body?.client_name === "string" ? body.client_name : undefined,
255
+ redirect_uris: uris,
256
+ created_at: now(),
257
+ };
258
+ (await getStore()).putClient(client).catch((e) => console.error("[oauth] putClient:", e?.message ?? e));
259
+ return { ok: true, client };
260
+ }
261
+ export async function getClient(clientId) {
262
+ if (!clientId)
263
+ return undefined;
264
+ return (await getStore()).getClient(clientId);
265
+ }
266
+ /**
267
+ * Validate an /authorize request and park it. Returns an `internalState` to send
268
+ * to the login page as its `state`; the callback uses it to find this request.
269
+ * `redirectable: false` means the error must be shown as a page (we can't trust
270
+ * the redirect_uri); `true` means it's safe to bounce the error to the client.
271
+ */
272
+ export async function startAuthorize(p) {
273
+ const store = await getStore();
274
+ const client = p.client_id ? await store.getClient(p.client_id) : undefined;
275
+ if (!client) {
276
+ return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
277
+ }
278
+ if (!p.redirect_uri || !client.redirect_uris.includes(p.redirect_uri)) {
279
+ return { ok: false, error: "invalid_request", error_description: "redirect_uri does not match a registered URI.", redirectable: false };
280
+ }
281
+ // From here errors CAN go back to the client's redirect_uri.
282
+ if (p.response_type !== "code") {
283
+ return { ok: false, error: "unsupported_response_type", error_description: "Only response_type=code is supported.", redirectable: true };
284
+ }
285
+ if (!p.code_challenge || (p.code_challenge_method ?? "").toUpperCase() !== "S256") {
286
+ return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
287
+ }
288
+ const internalState = token(24);
289
+ await store.putPending(internalState, {
290
+ client_id: client.client_id,
291
+ redirect_uri: p.redirect_uri,
292
+ code_challenge: p.code_challenge,
293
+ state: p.state ?? undefined,
294
+ scope: p.scope ?? undefined,
295
+ expiresAt: now() + PENDING_TTL,
296
+ });
297
+ return { ok: true, internalState };
298
+ }
299
+ /**
300
+ * The login page (/mcp-connect) bounced back with the user's `ljwt` and our
301
+ * `internalState`. Mint a one-time authorization code bound to that ljwt + the
302
+ * parked PKCE challenge, and return where to redirect the user (the client's
303
+ * redirect_uri with ?code=&state=).
304
+ */
305
+ export async function completeAuthorize(internalState, ljwt) {
306
+ const store = await getStore();
307
+ const p = internalState ? await store.takePending(internalState) : undefined;
308
+ if (!p) {
309
+ return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
310
+ }
311
+ if (!ljwt) {
312
+ return { ok: false, error: "access_denied", error_description: "No Webcake token returned from login." };
313
+ }
314
+ const code = token(32);
315
+ await store.putCode(code, {
316
+ client_id: p.client_id,
317
+ redirect_uri: p.redirect_uri,
318
+ code_challenge: p.code_challenge,
319
+ scope: p.scope,
320
+ ljwt,
321
+ expiresAt: now() + CODE_TTL,
322
+ });
323
+ return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
324
+ }
325
+ async function issueTokens(store, ljwt, client_id, scope) {
326
+ const access = token(32);
327
+ const refresh = token(32);
328
+ await store.putAccess(access, { ljwt, scope, expiresAt: now() + ACCESS_TTL });
329
+ await store.putRefresh(refresh, { ljwt, client_id, scope, expiresAt: now() + REFRESH_TTL });
330
+ return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
331
+ }
332
+ export async function exchangeToken(p) {
333
+ const store = await getStore();
334
+ if (p.grant_type === "authorization_code") {
335
+ const c = p.code ? await store.takeCode(p.code) : undefined; // one-time use
336
+ if (!c) {
337
+ return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
338
+ }
339
+ if (c.client_id !== p.client_id) {
340
+ return { ok: false, status: 400, error: "invalid_grant", error_description: "client_id does not match the authorization code." };
341
+ }
342
+ if (c.redirect_uri !== p.redirect_uri) {
343
+ return { ok: false, status: 400, error: "invalid_grant", error_description: "redirect_uri does not match the authorization request." };
344
+ }
345
+ if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
346
+ return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
347
+ }
348
+ return { ok: true, body: await issueTokens(store, c.ljwt, c.client_id, c.scope) };
349
+ }
350
+ if (p.grant_type === "refresh_token") {
351
+ const r = p.refresh_token ? await store.takeRefresh(p.refresh_token) : undefined; // rotate
352
+ if (!r) {
353
+ return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
354
+ }
355
+ return { ok: true, body: await issueTokens(store, r.ljwt, r.client_id, r.scope) };
356
+ }
357
+ return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
358
+ }
359
+ // ---- Resource-server side: resolve a Bearer access token to its ljwt -------
360
+ /** Returns the user's ljwt for a valid, unexpired access token, else undefined. */
361
+ export async function resolveAccessToken(accessToken) {
362
+ if (!accessToken)
363
+ return undefined;
364
+ const a = await (await getStore()).getAccess(accessToken);
365
+ return a?.ljwt;
366
+ }
367
+ /** Revoke an access or refresh token (best-effort; for /revoke). */
368
+ export async function revokeToken(t) {
369
+ if (!t)
370
+ return;
371
+ await (await getStore()).revoke(t);
372
+ }
373
+ // ---- Metadata documents (RFC 8414 / RFC 9728) -----------------------------
374
+ /** /.well-known/oauth-authorization-server */
375
+ export function authServerMetadata(issuer) {
376
+ return {
377
+ issuer,
378
+ authorization_endpoint: `${issuer}/authorize`,
379
+ token_endpoint: `${issuer}/token`,
380
+ registration_endpoint: `${issuer}/register`,
381
+ revocation_endpoint: `${issuer}/revoke`,
382
+ response_types_supported: ["code"],
383
+ grant_types_supported: ["authorization_code", "refresh_token"],
384
+ code_challenge_methods_supported: ["S256"],
385
+ token_endpoint_auth_methods_supported: ["none"],
386
+ scopes_supported: ["landing:read", "landing:write"],
387
+ };
388
+ }
389
+ /** /.well-known/oauth-protected-resource */
390
+ export function protectedResourceMetadata(resource, issuer) {
391
+ return {
392
+ resource,
393
+ authorization_servers: [issuer],
394
+ scopes_supported: ["landing:read", "landing:write"],
395
+ bearer_methods_supported: ["header"],
396
+ };
397
+ }
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.78",
4
+ "d": "15/06/2026",
5
+ "type": "Added",
6
+ "en": "The OAuth token store (clients, authorization codes, access and refresh tokens) now uses Postgres when DATABASE_URL, WEBCAKE_POSTGRES_URL, or…",
7
+ "vi": "Kho lưu trữ OAuth token (clients, authorization code, access và refresh token) nay sử dụng Postgres khi DATABASE_URL, WEBCAKE_POSTGRES_URL, hoặc…"
8
+ },
9
+ {
10
+ "v": "1.0.77",
11
+ "d": "15/06/2026",
12
+ "type": "Added",
13
+ "en": "The remote serve transport now implements a spec-conformant OAuth 2.1 Authorization Server (Authorization Code + PKCE S256, Dynamic Client…",
14
+ "vi": "Transport serve từ xa nay triển khai một OAuth 2.1 Authorization Server chuẩn spec (Authorization Code + PKCE S256, Dynamic Client Registration,…"
15
+ },
2
16
  {
3
17
  "v": "1.0.76",
4
18
  "d": "15/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "ingest_html and ingest_url now extract the full design system from a tailwind.config script block when present (Google Stitch and other Tailwind-CDN…",
28
42
  "vi": "ingest_html và ingest_url nay trích xuất toàn bộ design system từ block script tailwind.config khi có (trang Google Stitch và các trang Tailwind-CDN…"
29
- },
30
- {
31
- "v": "1.0.72",
32
- "d": "13/06/2026",
33
- "type": "Fixed",
34
- "en": "When cloning a LadiPage or Webcake-published page via ingest_html / ingest_url, html-box elements' passthrough HTML content now has its…",
35
- "vi": "Khi clone trang LadiPage hoặc Webcake-published qua ingest_html / ingest_url, nội dung HTML passthrough của các phần tử html-box nay được đổi tên…"
36
- },
37
- {
38
- "v": "1.0.71",
39
- "d": "13/06/2026",
40
- "type": "Added",
41
- "en": "ingest_html and ingest_url now automatically convert absolute-canvas builder exports (LadiPage-family / Webcake-published HTML) into a ready-to-save…",
42
- "vi": "ingest_html và ingest_url nay tự động chuyển đổi các bản export từ builder absolute-canvas (LadiPage-family / Webcake-published HTML) thành source…"
43
43
  }
44
44
  ]