webcake-storefront-mcp 1.0.3 → 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 +432 -0
- package/dist/http.js +296 -10
- package/dist/legal.js +127 -0
- package/dist/persistence/postgres.js +132 -0
- package/dist/persistence/redis.js +66 -0
- package/dist/web-guide.js +969 -0
- package/package.json +6 -1
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A THIN OAuth 2.1 Authorization Server, embedded in the MCP server itself.
|
|
3
|
+
*
|
|
4
|
+
* Ported from webcake-landing-mcp with one key adaptation: the credential is a
|
|
5
|
+
* PAIR (jwt + wsid) instead of a single `ljwt`. The consent page is the
|
|
6
|
+
* storefront's /mcp-storefront (not /mcp-connect), and the /oauth/callback
|
|
7
|
+
* receives both `token` (jwt) and `wsid` from the SPA. Both are stored against
|
|
8
|
+
* the authorization code and carried through to the access token so the HTTP
|
|
9
|
+
* layer can inject BOTH x-webcake-jwt AND x-webcake-session-id headers.
|
|
10
|
+
*
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
26
|
+
import { getPg, ensureOAuthSchema } from "../persistence/postgres.js";
|
|
27
|
+
import { getRedis } from "../persistence/redis.js";
|
|
28
|
+
// ---- TTLs (override via env where useful) ----------------------------------
|
|
29
|
+
const TEN_MIN = 10 * 60 * 1000;
|
|
30
|
+
const ACCESS_TTL = Number(process.env.WEBCAKE_OAUTH_ACCESS_TTL_MS) || 60 * 60 * 1000; // 1h
|
|
31
|
+
const REFRESH_TTL = Number(process.env.WEBCAKE_OAUTH_REFRESH_TTL_MS) || 30 * 24 * 60 * 60 * 1000; // 30d
|
|
32
|
+
const CODE_TTL = TEN_MIN;
|
|
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
|
|
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) { 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) {
|
|
71
|
+
this.sweep();
|
|
72
|
+
const p = this.pending.get(state);
|
|
73
|
+
this.pending.delete(state);
|
|
74
|
+
return p && p.expiresAt >= now() ? p : undefined;
|
|
75
|
+
}
|
|
76
|
+
async putCode(code, c) { this.codes.set(code, c); }
|
|
77
|
+
async takeCode(code) {
|
|
78
|
+
this.sweep();
|
|
79
|
+
const c = this.codes.get(code);
|
|
80
|
+
this.codes.delete(code);
|
|
81
|
+
return c && c.expiresAt >= now() ? c : undefined;
|
|
82
|
+
}
|
|
83
|
+
async putAccess(t, a) { this.access.set(t, a); }
|
|
84
|
+
async getAccess(t) {
|
|
85
|
+
const a = this.access.get(t);
|
|
86
|
+
if (a && a.expiresAt < now()) {
|
|
87
|
+
this.access.delete(t);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return a;
|
|
91
|
+
}
|
|
92
|
+
async putRefresh(t, r) { this.refresh.set(t, r); }
|
|
93
|
+
async takeRefresh(t) {
|
|
94
|
+
this.sweep();
|
|
95
|
+
const r = this.refresh.get(t);
|
|
96
|
+
this.refresh.delete(t);
|
|
97
|
+
return r && r.expiresAt >= now() ? r : undefined;
|
|
98
|
+
}
|
|
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
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// ---- PKCE ------------------------------------------------------------------
|
|
248
|
+
/** base64url( SHA256(verifier) ) — the S256 transform. */
|
|
249
|
+
export function s256(verifier) {
|
|
250
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
251
|
+
}
|
|
252
|
+
export function verifyPkce(verifier, challenge) {
|
|
253
|
+
if (!verifier || !challenge)
|
|
254
|
+
return false;
|
|
255
|
+
// Constant-time-ish compare on equal-length base64url strings.
|
|
256
|
+
const a = s256(verifier);
|
|
257
|
+
if (a.length !== challenge.length)
|
|
258
|
+
return false;
|
|
259
|
+
let diff = 0;
|
|
260
|
+
for (let i = 0; i < a.length; i++)
|
|
261
|
+
diff |= a.charCodeAt(i) ^ challenge.charCodeAt(i);
|
|
262
|
+
return diff === 0;
|
|
263
|
+
}
|
|
264
|
+
export async function registerClient(body) {
|
|
265
|
+
const uris = Array.isArray(body?.redirect_uris)
|
|
266
|
+
? body.redirect_uris.filter((u) => typeof u === "string" && /^https?:\/\//i.test(u))
|
|
267
|
+
: [];
|
|
268
|
+
if (uris.length === 0) {
|
|
269
|
+
return { ok: false, error: "invalid_redirect_uri", error_description: "redirect_uris must contain at least one absolute http(s) URI." };
|
|
270
|
+
}
|
|
271
|
+
const client = {
|
|
272
|
+
client_id: token(16),
|
|
273
|
+
client_name: typeof body?.client_name === "string" ? body.client_name : undefined,
|
|
274
|
+
redirect_uris: uris,
|
|
275
|
+
created_at: now(),
|
|
276
|
+
};
|
|
277
|
+
(await getStore()).putClient(client).catch((e) => console.error("[oauth] putClient:", e?.message ?? e));
|
|
278
|
+
return { ok: true, client };
|
|
279
|
+
}
|
|
280
|
+
export async function getClient(clientId) {
|
|
281
|
+
if (!clientId)
|
|
282
|
+
return undefined;
|
|
283
|
+
return (await getStore()).getClient(clientId);
|
|
284
|
+
}
|
|
285
|
+
export async function startAuthorize(p) {
|
|
286
|
+
const store = await getStore();
|
|
287
|
+
const client = p.client_id ? await store.getClient(p.client_id) : undefined;
|
|
288
|
+
if (!client) {
|
|
289
|
+
return { ok: false, error: "invalid_client", error_description: "Unknown client_id. Register first via /register.", redirectable: false };
|
|
290
|
+
}
|
|
291
|
+
if (!p.redirect_uri || !client.redirect_uris.includes(p.redirect_uri)) {
|
|
292
|
+
return { ok: false, error: "invalid_request", error_description: "redirect_uri does not match a registered URI.", redirectable: false };
|
|
293
|
+
}
|
|
294
|
+
if (p.response_type !== "code") {
|
|
295
|
+
return { ok: false, error: "unsupported_response_type", error_description: "Only response_type=code is supported.", redirectable: true };
|
|
296
|
+
}
|
|
297
|
+
if (!p.code_challenge || (p.code_challenge_method ?? "").toUpperCase() !== "S256") {
|
|
298
|
+
return { ok: false, error: "invalid_request", error_description: "PKCE with code_challenge_method=S256 is required.", redirectable: true };
|
|
299
|
+
}
|
|
300
|
+
const internalState = token(24);
|
|
301
|
+
await store.putPending(internalState, {
|
|
302
|
+
client_id: client.client_id,
|
|
303
|
+
redirect_uri: p.redirect_uri,
|
|
304
|
+
code_challenge: p.code_challenge,
|
|
305
|
+
state: p.state ?? undefined,
|
|
306
|
+
scope: p.scope ?? undefined,
|
|
307
|
+
expiresAt: now() + PENDING_TTL,
|
|
308
|
+
});
|
|
309
|
+
return { ok: true, internalState };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The consent page (/mcp-storefront) bounced back with the user's jwt + wsid and our
|
|
313
|
+
* internalState. Mint a one-time authorization code bound to that credential pair.
|
|
314
|
+
*/
|
|
315
|
+
export async function completeAuthorize(internalState, jwt, wsid) {
|
|
316
|
+
const store = await getStore();
|
|
317
|
+
const p = internalState ? await store.takePending(internalState) : undefined;
|
|
318
|
+
if (!p) {
|
|
319
|
+
return { ok: false, error: "invalid_request", error_description: "Authorization session expired or unknown — restart the connection." };
|
|
320
|
+
}
|
|
321
|
+
if (!jwt) {
|
|
322
|
+
return { ok: false, error: "access_denied", error_description: "No WebCake token returned from login." };
|
|
323
|
+
}
|
|
324
|
+
const code = token(32);
|
|
325
|
+
await store.putCode(code, {
|
|
326
|
+
client_id: p.client_id,
|
|
327
|
+
redirect_uri: p.redirect_uri,
|
|
328
|
+
code_challenge: p.code_challenge,
|
|
329
|
+
scope: p.scope,
|
|
330
|
+
cred: { jwt, wsid: wsid ?? "" },
|
|
331
|
+
expiresAt: now() + CODE_TTL,
|
|
332
|
+
});
|
|
333
|
+
return { ok: true, redirectUri: p.redirect_uri, code, state: p.state };
|
|
334
|
+
}
|
|
335
|
+
async function issueTokens(store, cred, client_id, scope) {
|
|
336
|
+
const access = token(32);
|
|
337
|
+
const refresh = token(32);
|
|
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);
|
|
344
|
+
return { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL / 1000), refresh_token: refresh, scope };
|
|
345
|
+
}
|
|
346
|
+
export async function exchangeToken(p) {
|
|
347
|
+
const store = await getStore();
|
|
348
|
+
if (p.grant_type === "authorization_code") {
|
|
349
|
+
const c = p.code ? await store.takeCode(p.code) : undefined;
|
|
350
|
+
if (!c) {
|
|
351
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired authorization code." };
|
|
352
|
+
}
|
|
353
|
+
if (c.client_id !== p.client_id) {
|
|
354
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "client_id does not match the authorization code." };
|
|
355
|
+
}
|
|
356
|
+
if (c.redirect_uri !== p.redirect_uri) {
|
|
357
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "redirect_uri does not match the authorization request." };
|
|
358
|
+
}
|
|
359
|
+
if (!p.code_verifier || !verifyPkce(p.code_verifier, c.code_challenge)) {
|
|
360
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "PKCE verification failed." };
|
|
361
|
+
}
|
|
362
|
+
return { ok: true, body: await issueTokens(store, c.cred, c.client_id, c.scope) };
|
|
363
|
+
}
|
|
364
|
+
if (p.grant_type === "refresh_token") {
|
|
365
|
+
const r = p.refresh_token ? await store.takeRefresh(p.refresh_token) : undefined;
|
|
366
|
+
if (!r) {
|
|
367
|
+
return { ok: false, status: 400, error: "invalid_grant", error_description: "Unknown or expired refresh token." };
|
|
368
|
+
}
|
|
369
|
+
return { ok: true, body: await issueTokens(store, r.cred, r.client_id, r.scope) };
|
|
370
|
+
}
|
|
371
|
+
return { ok: false, status: 400, error: "unsupported_grant_type", error_description: "grant_type must be authorization_code or refresh_token." };
|
|
372
|
+
}
|
|
373
|
+
// ---- Resource-server side: resolve a Bearer access token to the cred pair --
|
|
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) {
|
|
382
|
+
if (!accessToken)
|
|
383
|
+
return undefined;
|
|
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;
|
|
399
|
+
}
|
|
400
|
+
/** Revoke an access or refresh token (best-effort; for /revoke). */
|
|
401
|
+
export async function revokeToken(t) {
|
|
402
|
+
if (!t)
|
|
403
|
+
return;
|
|
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);
|
|
409
|
+
}
|
|
410
|
+
// ---- Metadata documents (RFC 8414 / RFC 9728) ------------------------------
|
|
411
|
+
export function authServerMetadata(issuer) {
|
|
412
|
+
return {
|
|
413
|
+
issuer,
|
|
414
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
415
|
+
token_endpoint: `${issuer}/token`,
|
|
416
|
+
registration_endpoint: `${issuer}/register`,
|
|
417
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
418
|
+
response_types_supported: ["code"],
|
|
419
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
420
|
+
code_challenge_methods_supported: ["S256"],
|
|
421
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
422
|
+
scopes_supported: ["storefront:read", "storefront:write"],
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export function protectedResourceMetadata(resource, issuer) {
|
|
426
|
+
return {
|
|
427
|
+
resource,
|
|
428
|
+
authorization_servers: [issuer],
|
|
429
|
+
scopes_supported: ["storefront:read", "storefront:write"],
|
|
430
|
+
bearer_methods_supported: ["header"],
|
|
431
|
+
};
|
|
432
|
+
}
|