webcake-storefront-mcp 1.1.0 → 1.1.1
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.
- package/dist/auth/oauth-server.js +235 -39
- package/dist/http.js +14 -7
- package/dist/legal.js +1 -1
- package/dist/persistence/postgres.js +132 -0
- package/dist/persistence/redis.js +66 -0
- package/dist/web-guide.js +922 -323
- package/package.json +6 -1
|
@@ -8,29 +8,47 @@
|
|
|
8
8
|
* the authorization code and carried through to the access token so the HTTP
|
|
9
9
|
* layer can inject BOTH x-webcake-jwt AND x-webcake-session-id headers.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* STORE: Postgres when DATABASE_URL is set (tokens survive a `serve` restart and
|
|
12
|
+
* are shared across instances behind a load balancer), else in-memory maps
|
|
13
|
+
* (single-instance `serve`, stdio/`npx`, offline tests). Both implement the same
|
|
14
|
+
* async `OAuthStore` interface; the rest of this module is backend-agnostic.
|
|
15
|
+
*
|
|
16
|
+
* CACHE: Redis (when REDIS_URL is set) caches access_token → {jwt,wsid} so
|
|
17
|
+
* /mcp requests avoid a Postgres round-trip on every call. Postgres remains the
|
|
18
|
+
* source of truth; a Redis miss just falls through to Postgres. On Redis
|
|
19
|
+
* absence / error the lookup goes directly to the store.
|
|
20
|
+
*
|
|
21
|
+
* All exported state functions are async — callers (src/http.ts) already await them.
|
|
22
|
+
* All exported function names and signatures are IDENTICAL to the previous version
|
|
23
|
+
* so http.ts requires no changes.
|
|
13
24
|
*/
|
|
14
25
|
import { randomBytes, createHash } from "node:crypto";
|
|
15
|
-
|
|
26
|
+
import { getPg, ensureOAuthSchema } from "../persistence/postgres.js";
|
|
27
|
+
import { getRedis } from "../persistence/redis.js";
|
|
28
|
+
// ---- TTLs (override via env where useful) ----------------------------------
|
|
16
29
|
const TEN_MIN = 10 * 60 * 1000;
|
|
17
30
|
const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
|
|
18
31
|
const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
|
|
19
32
|
const CODE_TTL = TEN_MIN;
|
|
20
33
|
const PENDING_TTL = TEN_MIN;
|
|
34
|
+
// Redis key prefix + TTL for the access-token cache (slightly shorter than ACCESS_TTL
|
|
35
|
+
// so stale cache entries never outlive the canonical Postgres row).
|
|
36
|
+
const REDIS_TOKEN_PREFIX = "wc:at:";
|
|
37
|
+
const REDIS_TOKEN_TTL_MS = ACCESS_TTL - 60_000; // 1 min earlier than token expiry
|
|
21
38
|
function now() {
|
|
22
39
|
return Date.now();
|
|
23
40
|
}
|
|
24
41
|
function token(bytes = 32) {
|
|
25
42
|
return randomBytes(bytes).toString("base64url");
|
|
26
43
|
}
|
|
27
|
-
// ---- In-memory store
|
|
44
|
+
// ---- In-memory store (default; no DATABASE_URL) ----------------------------
|
|
28
45
|
class MemoryStore {
|
|
29
46
|
clients = new Map();
|
|
30
47
|
pending = new Map();
|
|
31
48
|
codes = new Map();
|
|
32
49
|
access = new Map();
|
|
33
50
|
refresh = new Map();
|
|
51
|
+
/** Lazy sweep of anything expired — cheap, called on the hot paths. */
|
|
34
52
|
sweep() {
|
|
35
53
|
const t = now();
|
|
36
54
|
for (const [k, v] of this.pending)
|
|
@@ -46,24 +64,24 @@ class MemoryStore {
|
|
|
46
64
|
if (v.expiresAt < t)
|
|
47
65
|
this.refresh.delete(k);
|
|
48
66
|
}
|
|
49
|
-
putClient(c) { this.clients.set(c.client_id, c); }
|
|
50
|
-
getClient(id) { return this.clients.get(id); }
|
|
51
|
-
putPending(state, p) { this.sweep(); this.pending.set(state, p); }
|
|
52
|
-
takePending(state) {
|
|
67
|
+
async putClient(c) { this.clients.set(c.client_id, c); }
|
|
68
|
+
async getClient(id) { return this.clients.get(id); }
|
|
69
|
+
async putPending(state, p) { this.sweep(); this.pending.set(state, p); }
|
|
70
|
+
async takePending(state) {
|
|
53
71
|
this.sweep();
|
|
54
72
|
const p = this.pending.get(state);
|
|
55
73
|
this.pending.delete(state);
|
|
56
74
|
return p && p.expiresAt >= now() ? p : undefined;
|
|
57
75
|
}
|
|
58
|
-
putCode(code, c) { this.codes.set(code, c); }
|
|
59
|
-
takeCode(code) {
|
|
76
|
+
async putCode(code, c) { this.codes.set(code, c); }
|
|
77
|
+
async takeCode(code) {
|
|
60
78
|
this.sweep();
|
|
61
79
|
const c = this.codes.get(code);
|
|
62
80
|
this.codes.delete(code);
|
|
63
81
|
return c && c.expiresAt >= now() ? c : undefined;
|
|
64
82
|
}
|
|
65
|
-
putAccess(t, a) { this.access.set(t, a); }
|
|
66
|
-
getAccess(t) {
|
|
83
|
+
async putAccess(t, a) { this.access.set(t, a); }
|
|
84
|
+
async getAccess(t) {
|
|
67
85
|
const a = this.access.get(t);
|
|
68
86
|
if (a && a.expiresAt < now()) {
|
|
69
87
|
this.access.delete(t);
|
|
@@ -71,23 +89,170 @@ class MemoryStore {
|
|
|
71
89
|
}
|
|
72
90
|
return a;
|
|
73
91
|
}
|
|
74
|
-
putRefresh(t, r) { this.refresh.set(t, r); }
|
|
75
|
-
takeRefresh(t) {
|
|
92
|
+
async putRefresh(t, r) { this.refresh.set(t, r); }
|
|
93
|
+
async takeRefresh(t) {
|
|
76
94
|
this.sweep();
|
|
77
95
|
const r = this.refresh.get(t);
|
|
78
96
|
this.refresh.delete(t);
|
|
79
97
|
return r && r.expiresAt >= now() ? r : undefined;
|
|
80
98
|
}
|
|
81
|
-
revoke(t) { this.access.delete(t); this.refresh.delete(t); }
|
|
99
|
+
async revoke(t) { this.access.delete(t); this.refresh.delete(t); }
|
|
100
|
+
}
|
|
101
|
+
// ---- Postgres store (when DATABASE_URL is set) -----------------------------
|
|
102
|
+
class PgStore {
|
|
103
|
+
pool;
|
|
104
|
+
constructor(pool) {
|
|
105
|
+
this.pool = pool;
|
|
106
|
+
}
|
|
107
|
+
async putClient(c) {
|
|
108
|
+
await this.pool.query(`INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
|
|
109
|
+
VALUES ($1,$2,$3,$4)
|
|
110
|
+
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]);
|
|
111
|
+
}
|
|
112
|
+
async getClient(id) {
|
|
113
|
+
const { rows } = await this.pool.query(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id=$1`, [id]);
|
|
114
|
+
const r = rows[0];
|
|
115
|
+
if (!r)
|
|
116
|
+
return undefined;
|
|
117
|
+
return {
|
|
118
|
+
client_id: r.client_id,
|
|
119
|
+
client_name: r.client_name ?? undefined,
|
|
120
|
+
redirect_uris: Array.isArray(r.redirect_uris)
|
|
121
|
+
? r.redirect_uris
|
|
122
|
+
: JSON.parse(r.redirect_uris),
|
|
123
|
+
created_at: Number(r.created_at),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async putPending(state, p) {
|
|
127
|
+
await this.pool.query(`INSERT INTO oauth_pending (state, client_id, redirect_uri, code_challenge, client_state, scope, expires_at)
|
|
128
|
+
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]);
|
|
129
|
+
}
|
|
130
|
+
async takePending(state) {
|
|
131
|
+
const { rows } = await this.pool.query(`DELETE FROM oauth_pending WHERE state=$1
|
|
132
|
+
RETURNING client_id, redirect_uri, code_challenge, client_state, scope, expires_at`, [state]);
|
|
133
|
+
const r = rows[0];
|
|
134
|
+
if (!r || Number(r.expires_at) < now())
|
|
135
|
+
return undefined;
|
|
136
|
+
return {
|
|
137
|
+
client_id: r.client_id,
|
|
138
|
+
redirect_uri: r.redirect_uri,
|
|
139
|
+
code_challenge: r.code_challenge,
|
|
140
|
+
state: r.client_state ?? undefined,
|
|
141
|
+
scope: r.scope ?? undefined,
|
|
142
|
+
expiresAt: Number(r.expires_at),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async putCode(code, c) {
|
|
146
|
+
await this.pool.query(`INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, scope, jwt, wsid, expires_at)
|
|
147
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [code, c.client_id, c.redirect_uri, c.code_challenge, c.scope ?? null, c.cred.jwt, c.cred.wsid, c.expiresAt]);
|
|
148
|
+
}
|
|
149
|
+
async takeCode(code) {
|
|
150
|
+
const { rows } = await this.pool.query(`DELETE FROM oauth_codes WHERE code=$1
|
|
151
|
+
RETURNING client_id, redirect_uri, code_challenge, scope, jwt, wsid, expires_at`, [code]);
|
|
152
|
+
const r = rows[0];
|
|
153
|
+
if (!r || Number(r.expires_at) < now())
|
|
154
|
+
return undefined;
|
|
155
|
+
return {
|
|
156
|
+
client_id: r.client_id,
|
|
157
|
+
redirect_uri: r.redirect_uri,
|
|
158
|
+
code_challenge: r.code_challenge,
|
|
159
|
+
scope: r.scope ?? undefined,
|
|
160
|
+
cred: { jwt: r.jwt, wsid: r.wsid ?? "" },
|
|
161
|
+
expiresAt: Number(r.expires_at),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async putAccess(t, a) {
|
|
165
|
+
await this.pool.query(`INSERT INTO oauth_access_tokens (token, jwt, wsid, scope, expires_at) VALUES ($1,$2,$3,$4,$5)`, [t, a.cred.jwt, a.cred.wsid, a.scope ?? null, a.expiresAt]);
|
|
166
|
+
}
|
|
167
|
+
async getAccess(t) {
|
|
168
|
+
const { rows } = await this.pool.query(`SELECT jwt, wsid, scope, expires_at FROM oauth_access_tokens WHERE token=$1`, [t]);
|
|
169
|
+
const r = rows[0];
|
|
170
|
+
if (!r)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (Number(r.expires_at) < now()) {
|
|
173
|
+
await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
cred: { jwt: r.jwt, wsid: r.wsid ?? "" },
|
|
178
|
+
scope: r.scope ?? undefined,
|
|
179
|
+
expiresAt: Number(r.expires_at),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async putRefresh(t, r) {
|
|
183
|
+
await this.pool.query(`INSERT INTO oauth_refresh_tokens (token, jwt, wsid, client_id, scope, expires_at) VALUES ($1,$2,$3,$4,$5,$6)`, [t, r.cred.jwt, r.cred.wsid, r.client_id, r.scope ?? null, r.expiresAt]);
|
|
184
|
+
}
|
|
185
|
+
async takeRefresh(t) {
|
|
186
|
+
const { rows } = await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1
|
|
187
|
+
RETURNING jwt, wsid, client_id, scope, expires_at`, [t]);
|
|
188
|
+
const r = rows[0];
|
|
189
|
+
if (!r || Number(r.expires_at) < now())
|
|
190
|
+
return undefined;
|
|
191
|
+
return {
|
|
192
|
+
cred: { jwt: r.jwt, wsid: r.wsid ?? "" },
|
|
193
|
+
client_id: r.client_id,
|
|
194
|
+
scope: r.scope ?? undefined,
|
|
195
|
+
expiresAt: Number(r.expires_at),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async revoke(t) {
|
|
199
|
+
await this.pool.query(`DELETE FROM oauth_access_tokens WHERE token=$1`, [t]);
|
|
200
|
+
await this.pool.query(`DELETE FROM oauth_refresh_tokens WHERE token=$1`, [t]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ---- Backend selection (memoized) ------------------------------------------
|
|
204
|
+
const memoryStore = new MemoryStore();
|
|
205
|
+
let storePromise;
|
|
206
|
+
/** Resolve the active store ONCE: Postgres if configured + schema ready, else memory. */
|
|
207
|
+
function getStore() {
|
|
208
|
+
if (storePromise)
|
|
209
|
+
return storePromise;
|
|
210
|
+
storePromise = (async () => {
|
|
211
|
+
const pool = getPg();
|
|
212
|
+
if (pool && (await ensureOAuthSchema(pool)))
|
|
213
|
+
return new PgStore(pool);
|
|
214
|
+
return memoryStore;
|
|
215
|
+
})().catch((e) => {
|
|
216
|
+
console.error("[oauth] store init failed, using in-memory:", e?.message ?? e);
|
|
217
|
+
return memoryStore;
|
|
218
|
+
});
|
|
219
|
+
return storePromise;
|
|
220
|
+
}
|
|
221
|
+
// ---- Redis cache helpers (access-token only) --------------------------------
|
|
222
|
+
/** Write {jwt,wsid} to Redis with the access-token TTL. Fire-and-forget. */
|
|
223
|
+
function redisCachePut(redis, tok, cred) {
|
|
224
|
+
const val = JSON.stringify(cred);
|
|
225
|
+
redis.set(REDIS_TOKEN_PREFIX + tok, val, "PX", REDIS_TOKEN_TTL_MS).catch((e) => {
|
|
226
|
+
console.error("[redis] cache put error:", e?.message ?? e);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/** Read {jwt,wsid} from Redis. Returns null on miss or error. */
|
|
230
|
+
async function redisCacheGet(redis, tok) {
|
|
231
|
+
try {
|
|
232
|
+
const raw = await redis.get(REDIS_TOKEN_PREFIX + tok);
|
|
233
|
+
if (!raw)
|
|
234
|
+
return null;
|
|
235
|
+
return JSON.parse(raw);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Evict an access-token key from Redis. Fire-and-forget. */
|
|
242
|
+
function redisCacheDel(redis, tok) {
|
|
243
|
+
redis.del(REDIS_TOKEN_PREFIX + tok).catch((e) => {
|
|
244
|
+
console.error("[redis] cache del error:", e?.message ?? e);
|
|
245
|
+
});
|
|
82
246
|
}
|
|
83
|
-
const store = new MemoryStore();
|
|
84
247
|
// ---- PKCE ------------------------------------------------------------------
|
|
248
|
+
/** base64url( SHA256(verifier) ) — the S256 transform. */
|
|
85
249
|
export function s256(verifier) {
|
|
86
250
|
return createHash("sha256").update(verifier).digest("base64url");
|
|
87
251
|
}
|
|
88
252
|
export function verifyPkce(verifier, challenge) {
|
|
89
253
|
if (!verifier || !challenge)
|
|
90
254
|
return false;
|
|
255
|
+
// Constant-time-ish compare on equal-length base64url strings.
|
|
91
256
|
const a = s256(verifier);
|
|
92
257
|
if (a.length !== challenge.length)
|
|
93
258
|
return false;
|
|
@@ -96,7 +261,7 @@ export function verifyPkce(verifier, challenge) {
|
|
|
96
261
|
diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
|
|
97
262
|
return diff === 0;
|
|
98
263
|
}
|
|
99
|
-
export function registerClient(body) {
|
|
264
|
+
export async function registerClient(body) {
|
|
100
265
|
const uris = Array.isArray(body?.redirect_uris)
|
|
101
266
|
? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
|
|
102
267
|
: [];
|
|
@@ -109,16 +274,17 @@ export function registerClient(body) {
|
|
|
109
274
|
redirect_uris: uris,
|
|
110
275
|
created_at: now(),
|
|
111
276
|
};
|
|
112
|
-
|
|
277
|
+
(await getStore()).putClient(client).catch((e) => console.error("[oauth] putClient:", e?.message ?? e));
|
|
113
278
|
return { ok: true, client };
|
|
114
279
|
}
|
|
115
|
-
export function getClient(clientId) {
|
|
280
|
+
export async function getClient(clientId) {
|
|
116
281
|
if (!clientId)
|
|
117
282
|
return undefined;
|
|
118
|
-
return
|
|
283
|
+
return (await getStore()).getClient(clientId);
|
|
119
284
|
}
|
|
120
|
-
export function startAuthorize(p) {
|
|
121
|
-
const
|
|
285
|
+
export async function startAuthorize(p) {
|
|
286
|
+
const store = await getStore();
|
|
287
|
+
const client = p.client_id ? await store.getClient(p.client_id) : undefined;
|
|
122
288
|
if (!client) {
|
|
123
289
|
return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
|
|
124
290
|
}
|
|
@@ -132,7 +298,7 @@ export function startAuthorize(p) {
|
|
|
132
298
|
return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
|
|
133
299
|
}
|
|
134
300
|
const internalState = token(24);
|
|
135
|
-
store.putPending(internalState, {
|
|
301
|
+
await store.putPending(internalState, {
|
|
136
302
|
client_id: client.client_id,
|
|
137
303
|
redirect_uri: p.redirect_uri,
|
|
138
304
|
code_challenge: p.code_challenge,
|
|
@@ -146,8 +312,9 @@ export function startAuthorize(p) {
|
|
|
146
312
|
* The consent page (/mcp-storefront) bounced back with the user's jwt + wsid and our
|
|
147
313
|
* internalState. Mint a one-time authorization code bound to that credential pair.
|
|
148
314
|
*/
|
|
149
|
-
export function completeAuthorize(internalState, jwt, wsid) {
|
|
150
|
-
const
|
|
315
|
+
export async function completeAuthorize(internalState, jwt, wsid) {
|
|
316
|
+
const store = await getStore();
|
|
317
|
+
const p = internalState ? await store.takePending(internalState) : undefined;
|
|
151
318
|
if (!p) {
|
|
152
319
|
return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
|
|
153
320
|
}
|
|
@@ -155,7 +322,7 @@ export function completeAuthorize(internalState, jwt, wsid) {
|
|
|
155
322
|
return { ok: false, error: "access_denied", error_description: "No WebCake token returned from login." };
|
|
156
323
|
}
|
|
157
324
|
const code = token(32);
|
|
158
|
-
store.putCode(code, {
|
|
325
|
+
await store.putCode(code, {
|
|
159
326
|
client_id: p.client_id,
|
|
160
327
|
redirect_uri: p.redirect_uri,
|
|
161
328
|
code_challenge: p.code_challenge,
|
|
@@ -165,16 +332,21 @@ export function completeAuthorize(internalState, jwt, wsid) {
|
|
|
165
332
|
});
|
|
166
333
|
return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
|
|
167
334
|
}
|
|
168
|
-
function issueTokens(cred, client_id, scope) {
|
|
335
|
+
async function issueTokens(store, cred, client_id, scope) {
|
|
169
336
|
const access = token(32);
|
|
170
337
|
const refresh = token(32);
|
|
171
|
-
store.putAccess(access, { cred, scope, expiresAt: now() + ACCESS_TTL });
|
|
172
|
-
store.putRefresh(refresh, { cred, client_id, scope, expiresAt: now() + REFRESH_TTL });
|
|
338
|
+
await store.putAccess(access, { cred, scope, expiresAt: now() + ACCESS_TTL });
|
|
339
|
+
await store.putRefresh(refresh, { cred, client_id, scope, expiresAt: now() + REFRESH_TTL });
|
|
340
|
+
// Warm the Redis cache immediately after issuing so the first /mcp request is a cache hit.
|
|
341
|
+
const redis = getRedis();
|
|
342
|
+
if (redis)
|
|
343
|
+
redisCachePut(redis, access, cred);
|
|
173
344
|
return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
|
|
174
345
|
}
|
|
175
|
-
export function exchangeToken(p) {
|
|
346
|
+
export async function exchangeToken(p) {
|
|
347
|
+
const store = await getStore();
|
|
176
348
|
if (p.grant_type === "authorization_code") {
|
|
177
|
-
const c = p.code ? store.takeCode(p.code) : undefined;
|
|
349
|
+
const c = p.code ? await store.takeCode(p.code) : undefined;
|
|
178
350
|
if (!c) {
|
|
179
351
|
return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
|
|
180
352
|
}
|
|
@@ -187,29 +359,53 @@ export function exchangeToken(p) {
|
|
|
187
359
|
if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
|
|
188
360
|
return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
|
|
189
361
|
}
|
|
190
|
-
return { ok: true, body: issueTokens(c.cred, c.client_id, c.scope) };
|
|
362
|
+
return { ok: true, body: await issueTokens(store, c.cred, c.client_id, c.scope) };
|
|
191
363
|
}
|
|
192
364
|
if (p.grant_type === "refresh_token") {
|
|
193
|
-
const r = p.refresh_token ? store.takeRefresh(p.refresh_token) : undefined;
|
|
365
|
+
const r = p.refresh_token ? await store.takeRefresh(p.refresh_token) : undefined;
|
|
194
366
|
if (!r) {
|
|
195
367
|
return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
|
|
196
368
|
}
|
|
197
|
-
return { ok: true, body: issueTokens(r.cred, r.client_id, r.scope) };
|
|
369
|
+
return { ok: true, body: await issueTokens(store, r.cred, r.client_id, r.scope) };
|
|
198
370
|
}
|
|
199
371
|
return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
|
|
200
372
|
}
|
|
201
373
|
// ---- Resource-server side: resolve a Bearer access token to the cred pair --
|
|
202
|
-
/**
|
|
203
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Returns the { jwt, wsid } for a valid, unexpired access token, else undefined.
|
|
376
|
+
*
|
|
377
|
+
* Hot path — hit on every /mcp request. Resolution order:
|
|
378
|
+
* 1. Redis cache (sub-millisecond, no Postgres round-trip)
|
|
379
|
+
* 2. Postgres / in-memory store (source of truth, backfills Redis on hit)
|
|
380
|
+
*/
|
|
381
|
+
export async function resolveAccessToken(accessToken) {
|
|
204
382
|
if (!accessToken)
|
|
205
383
|
return undefined;
|
|
206
|
-
|
|
384
|
+
// 1. Try Redis cache first.
|
|
385
|
+
const redis = getRedis();
|
|
386
|
+
if (redis) {
|
|
387
|
+
const cached = await redisCacheGet(redis, accessToken);
|
|
388
|
+
if (cached)
|
|
389
|
+
return cached;
|
|
390
|
+
}
|
|
391
|
+
// 2. Fall through to the store (Postgres or memory).
|
|
392
|
+
const a = await (await getStore()).getAccess(accessToken);
|
|
393
|
+
if (!a)
|
|
394
|
+
return undefined;
|
|
395
|
+
// Backfill the Redis cache for next time.
|
|
396
|
+
if (redis)
|
|
397
|
+
redisCachePut(redis, accessToken, a.cred);
|
|
398
|
+
return a.cred;
|
|
207
399
|
}
|
|
208
400
|
/** Revoke an access or refresh token (best-effort; for /revoke). */
|
|
209
|
-
export function revokeToken(t) {
|
|
401
|
+
export async function revokeToken(t) {
|
|
210
402
|
if (!t)
|
|
211
403
|
return;
|
|
212
|
-
store.
|
|
404
|
+
// Evict from Redis cache before hitting the store so the slot is gone immediately.
|
|
405
|
+
const redis = getRedis();
|
|
406
|
+
if (redis)
|
|
407
|
+
redisCacheDel(redis, t);
|
|
408
|
+
await (await getStore()).revoke(t);
|
|
213
409
|
}
|
|
214
410
|
// ---- Metadata documents (RFC 8414 / RFC 9728) ------------------------------
|
|
215
411
|
export function authServerMetadata(issuer) {
|
package/dist/http.js
CHANGED
|
@@ -11,7 +11,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
11
11
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { createServer } from "./server.js";
|
|
13
13
|
import { makeApi, resolveEnv, ENVIRONMENTS, DEFAULT_ENV } from "./config.js";
|
|
14
|
-
import { landingHtml, faviconSvg } from "./web-guide.js";
|
|
14
|
+
import { landingHtml, faviconSvg, ogImageSvg, normalizeLang } from "./web-guide.js";
|
|
15
15
|
import { privacyHtml, termsHtml } from "./legal.js";
|
|
16
16
|
import { registerClient, startAuthorize, completeAuthorize, exchangeToken, resolveAccessToken, revokeToken, authServerMetadata, protectedResourceMetadata, } from "./auth/oauth-server.js";
|
|
17
17
|
const MCP_PATH = "/mcp";
|
|
@@ -177,7 +177,7 @@ async function handleOAuth(req, res, path) {
|
|
|
177
177
|
}
|
|
178
178
|
const raw = await readRawBody(req);
|
|
179
179
|
const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
|
|
180
|
-
const result = registerClient(body);
|
|
180
|
+
const result = await registerClient(body);
|
|
181
181
|
if (!result.ok) {
|
|
182
182
|
oauthError(res, 400, result.error, result.error_description);
|
|
183
183
|
return true;
|
|
@@ -196,7 +196,7 @@ async function handleOAuth(req, res, path) {
|
|
|
196
196
|
// ---- Authorize: validate + redirect to storefront consent page ----
|
|
197
197
|
if (req.method === "GET" && path === OAUTH_AUTHORIZE) {
|
|
198
198
|
const sp = new URL(req.url ?? "/", "http://x").searchParams;
|
|
199
|
-
const result = startAuthorize({
|
|
199
|
+
const result = await startAuthorize({
|
|
200
200
|
client_id: sp.get("client_id"),
|
|
201
201
|
redirect_uri: sp.get("redirect_uri"),
|
|
202
202
|
response_type: sp.get("response_type"),
|
|
@@ -235,7 +235,7 @@ async function handleOAuth(req, res, path) {
|
|
|
235
235
|
// Accept both 'token' and 'jwt' from the SPA (login.ts uses both aliases).
|
|
236
236
|
const jwt = sp.get("token") || sp.get("jwt");
|
|
237
237
|
const wsid = sp.get("wsid") || sp.get("session_id") || "";
|
|
238
|
-
const done = completeAuthorize(sp.get("state"), jwt, wsid);
|
|
238
|
+
const done = await completeAuthorize(sp.get("state"), jwt, wsid);
|
|
239
239
|
if (!done.ok) {
|
|
240
240
|
htmlError(res, 400, done.error_description);
|
|
241
241
|
return true;
|
|
@@ -261,7 +261,7 @@ async function handleOAuth(req, res, path) {
|
|
|
261
261
|
}
|
|
262
262
|
const raw = await readRawBody(req);
|
|
263
263
|
const body = parseBodyParams(raw, String(req.headers["content-type"] ?? ""));
|
|
264
|
-
const result = exchangeToken(body);
|
|
264
|
+
const result = await exchangeToken(body);
|
|
265
265
|
if (!result.ok) {
|
|
266
266
|
oauthError(res, result.status, result.error, result.error_description);
|
|
267
267
|
return true;
|
|
@@ -295,6 +295,12 @@ export async function startHttpServer(port) {
|
|
|
295
295
|
res.end(faviconSvg());
|
|
296
296
|
return;
|
|
297
297
|
}
|
|
298
|
+
// ---- OG social card ----
|
|
299
|
+
if (req.method === "GET" && path === "/og.svg") {
|
|
300
|
+
res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" });
|
|
301
|
+
res.end(ogImageSvg());
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
298
304
|
// ---- Legal pages ----
|
|
299
305
|
if (req.method === "GET" && (path === "/privacy" || path === "/privacy-policy")) {
|
|
300
306
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
@@ -312,8 +318,9 @@ export async function startHttpServer(port) {
|
|
|
312
318
|
const accept = String(req.headers["accept"] ?? "");
|
|
313
319
|
const ua = String(req.headers["user-agent"] ?? "");
|
|
314
320
|
if (accept.includes("text/html") || BOT_UA.test(ua)) {
|
|
321
|
+
const lang = normalizeLang(new URL(req.url ?? "/", "http://x").searchParams.get("lang"));
|
|
315
322
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
316
|
-
res.end(landingHtml(publicBase(req)));
|
|
323
|
+
res.end(landingHtml(publicBase(req), lang));
|
|
317
324
|
return;
|
|
318
325
|
}
|
|
319
326
|
}
|
|
@@ -334,7 +341,7 @@ export async function startHttpServer(port) {
|
|
|
334
341
|
// Resolve an OAuth Bearer access token to { jwt, wsid } and inject both headers
|
|
335
342
|
// so apiFromRequest picks them up — existing header/query paths remain untouched.
|
|
336
343
|
const bearer = bearerFrom(req);
|
|
337
|
-
const oauthCred = resolveAccessToken(bearer);
|
|
344
|
+
const oauthCred = await resolveAccessToken(bearer);
|
|
338
345
|
if (oauthCred) {
|
|
339
346
|
if (req.headers["x-webcake-jwt"] == null) {
|
|
340
347
|
req.headers["x-webcake-jwt"] = oauthCred.jwt;
|
package/dist/legal.js
CHANGED
|
@@ -18,7 +18,7 @@ function page(title, bodyHtml) {
|
|
|
18
18
|
h1{font-size:1.9rem;margin:0 0 4px}
|
|
19
19
|
h2{font-size:1.2rem;margin:32px 0 8px}
|
|
20
20
|
.meta{color:#64748b;font-size:.9rem;margin-bottom:28px}
|
|
21
|
-
a{color:#
|
|
21
|
+
a{color:#6d5efc}
|
|
22
22
|
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:rgba(127,127,127,.15);padding:1px 5px;border-radius:4px}
|
|
23
23
|
ul{padding-left:22px}
|
|
24
24
|
footer{margin-top:48px;padding-top:20px;border-top:1px solid rgba(127,127,127,.25);color:#64748b;font-size:.85rem}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy, shared Postgres pool used to PERSIST the OAuth 2.1 Authorization Server
|
|
3
|
+
* state (clients, pending auths, codes, access + refresh tokens) so tokens
|
|
4
|
+
* survive a `serve` restart and are shared across instances behind a load
|
|
5
|
+
* balancer — unlike the caches (Redis/disposable), OAuth state is durable.
|
|
6
|
+
*
|
|
7
|
+
* Returns null when no DATABASE_URL is configured OR `pg` isn't installed — the
|
|
8
|
+
* OAuth store then falls back to in-memory maps, so single-instance `serve`,
|
|
9
|
+
* stdio/`npx`, and the offline smoke gate keep working with ZERO infra.
|
|
10
|
+
*
|
|
11
|
+
* `pg` is an OPTIONAL, CJS dependency (see package.json), required via
|
|
12
|
+
* createRequire under ESM/Node16. The pool connects lazily per query.
|
|
13
|
+
*
|
|
14
|
+
* Configure with DATABASE_URL (or WEBCAKE_POSTGRES_URL), e.g.
|
|
15
|
+
* postgres://user:pw@host:5432/webcake_storefront
|
|
16
|
+
*
|
|
17
|
+
* KEY DIFFERENCE vs. landing-mcp: the credential is a PAIR (jwt + wsid), so
|
|
18
|
+
* oauth_codes / oauth_access_tokens / oauth_refresh_tokens carry two columns
|
|
19
|
+
* (`jwt text NOT NULL` and `wsid text`) instead of a single `ljwt` column.
|
|
20
|
+
*/
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
let cached; // undefined = not yet resolved
|
|
24
|
+
function redactUrl(u) {
|
|
25
|
+
try {
|
|
26
|
+
const x = new URL(u);
|
|
27
|
+
if (x.password)
|
|
28
|
+
x.password = "***";
|
|
29
|
+
return x.toString();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "postgres";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Returns the shared Postgres pool, or null if Postgres isn't configured/available.
|
|
37
|
+
* Memoized: resolves the pool (or its absence) exactly once per process.
|
|
38
|
+
*/
|
|
39
|
+
export function getPg() {
|
|
40
|
+
if (cached !== undefined)
|
|
41
|
+
return cached;
|
|
42
|
+
const url = process.env.DATABASE_URL || process.env.WEBCAKE_POSTGRES_URL;
|
|
43
|
+
if (!url)
|
|
44
|
+
return (cached = null);
|
|
45
|
+
try {
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
|
+
const { Pool } = require("pg");
|
|
48
|
+
const pool = new Pool({
|
|
49
|
+
connectionString: url,
|
|
50
|
+
max: Number(process.env.WEBCAKE_PG_POOL_MAX) || 5,
|
|
51
|
+
// Managed Postgres (Supabase, Neon, …) often requires TLS; allow opting in
|
|
52
|
+
// without verifying the chain via WEBCAKE_PG_SSL=1.
|
|
53
|
+
ssl: /^(1|true|yes|on)$/i.test(process.env.WEBCAKE_PG_SSL ?? "")
|
|
54
|
+
? { rejectUnauthorized: false }
|
|
55
|
+
: undefined,
|
|
56
|
+
});
|
|
57
|
+
pool.on("error", (e) => console.error("[pg] pool error:", e?.message ?? e));
|
|
58
|
+
console.error(`[pg] OAuth store backend: ${redactUrl(url)}`);
|
|
59
|
+
cached = pool;
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.error("[pg] unavailable, using in-memory OAuth store:", e?.message ?? e);
|
|
63
|
+
cached = null;
|
|
64
|
+
}
|
|
65
|
+
return cached;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Create the OAuth tables if absent. Idempotent and memoized to a single
|
|
69
|
+
* in-flight promise per process, so concurrent callers share one round-trip. On
|
|
70
|
+
* any failure it logs and resolves false; the caller degrades to in-memory.
|
|
71
|
+
*
|
|
72
|
+
* Storefront schema: codes/tokens carry TWO credential columns:
|
|
73
|
+
* jwt text NOT NULL — the user's WebCake JWT
|
|
74
|
+
* wsid text — the WebCake session/workspace ID (may be empty)
|
|
75
|
+
*/
|
|
76
|
+
let schemaReady;
|
|
77
|
+
export function ensureOAuthSchema(pool) {
|
|
78
|
+
if (schemaReady)
|
|
79
|
+
return schemaReady;
|
|
80
|
+
schemaReady = (async () => {
|
|
81
|
+
try {
|
|
82
|
+
await pool.query(`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
84
|
+
client_id text PRIMARY KEY,
|
|
85
|
+
client_name text,
|
|
86
|
+
redirect_uris jsonb NOT NULL,
|
|
87
|
+
created_at bigint NOT NULL
|
|
88
|
+
);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS oauth_pending (
|
|
90
|
+
state text PRIMARY KEY,
|
|
91
|
+
client_id text NOT NULL,
|
|
92
|
+
redirect_uri text NOT NULL,
|
|
93
|
+
code_challenge text NOT NULL,
|
|
94
|
+
client_state text,
|
|
95
|
+
scope text,
|
|
96
|
+
expires_at bigint NOT NULL
|
|
97
|
+
);
|
|
98
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
99
|
+
code text PRIMARY KEY,
|
|
100
|
+
client_id text NOT NULL,
|
|
101
|
+
redirect_uri text NOT NULL,
|
|
102
|
+
code_challenge text NOT NULL,
|
|
103
|
+
scope text,
|
|
104
|
+
jwt text NOT NULL,
|
|
105
|
+
wsid text,
|
|
106
|
+
expires_at bigint NOT NULL
|
|
107
|
+
);
|
|
108
|
+
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
|
109
|
+
token text PRIMARY KEY,
|
|
110
|
+
jwt text NOT NULL,
|
|
111
|
+
wsid text,
|
|
112
|
+
scope text,
|
|
113
|
+
expires_at bigint NOT NULL
|
|
114
|
+
);
|
|
115
|
+
CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
|
116
|
+
token text PRIMARY KEY,
|
|
117
|
+
jwt text NOT NULL,
|
|
118
|
+
wsid text,
|
|
119
|
+
client_id text NOT NULL,
|
|
120
|
+
scope text,
|
|
121
|
+
expires_at bigint NOT NULL
|
|
122
|
+
);
|
|
123
|
+
`);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
console.error("[pg] OAuth schema init failed, using in-memory:", e?.message ?? e);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
return schemaReady;
|
|
132
|
+
}
|