@cmssy/next 0.1.7 → 0.1.9
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/index.cjs +542 -4
- package/dist/index.d.cts +52 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.js +530 -3
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,9 @@ var headers = require('next/headers');
|
|
|
4
4
|
var navigation = require('next/navigation');
|
|
5
5
|
var react = require('@cmssy/react');
|
|
6
6
|
var jsxRuntime = require('react/jsx-runtime');
|
|
7
|
-
var crypto = require('crypto');
|
|
7
|
+
var crypto$1 = require('crypto');
|
|
8
|
+
var jose = require('jose');
|
|
9
|
+
var server = require('next/server');
|
|
8
10
|
|
|
9
11
|
// src/create-cmssy-page.tsx
|
|
10
12
|
|
|
@@ -156,9 +158,9 @@ function resolveBridgeOrigin(editorOrigin) {
|
|
|
156
158
|
}
|
|
157
159
|
var MIN_SECRET_LENGTH = 16;
|
|
158
160
|
function secretsMatch(a, b) {
|
|
159
|
-
const ha = crypto.createHash("sha256").update(a).digest();
|
|
160
|
-
const hb = crypto.createHash("sha256").update(b).digest();
|
|
161
|
-
return crypto.timingSafeEqual(ha, hb);
|
|
161
|
+
const ha = crypto$1.createHash("sha256").update(a).digest();
|
|
162
|
+
const hb = crypto$1.createHash("sha256").update(b).digest();
|
|
163
|
+
return crypto$1.timingSafeEqual(ha, hb);
|
|
162
164
|
}
|
|
163
165
|
function safeRedirect(redirect2, fallback) {
|
|
164
166
|
if (!redirect2 || !redirect2.startsWith("/")) return fallback;
|
|
@@ -226,15 +228,551 @@ async function getCmssyLocale(config) {
|
|
|
226
228
|
const { defaultLocale } = await react.resolveSiteLocales(config);
|
|
227
229
|
return defaultLocale;
|
|
228
230
|
}
|
|
231
|
+
var CMSSY_SESSION_COOKIE = "cmssy_session";
|
|
232
|
+
var SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
233
|
+
var MIN_SESSION_SECRET_LENGTH = 32;
|
|
234
|
+
var ACCESS_EXPIRY_SKEW_MS = 3e4;
|
|
235
|
+
async function deriveSessionKey(secret) {
|
|
236
|
+
if (typeof secret !== "string" || secret.length < MIN_SESSION_SECRET_LENGTH) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`cmssy auth sessionSecret must be at least ${MIN_SESSION_SECRET_LENGTH} characters`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const digest = await crypto.subtle.digest(
|
|
242
|
+
"SHA-256",
|
|
243
|
+
new TextEncoder().encode(secret)
|
|
244
|
+
);
|
|
245
|
+
return new Uint8Array(digest);
|
|
246
|
+
}
|
|
247
|
+
async function sealSession(payload, secret, audience) {
|
|
248
|
+
const key = await deriveSessionKey(secret);
|
|
249
|
+
const jwt = new jose.EncryptJWT({ ...payload }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`);
|
|
250
|
+
if (audience) jwt.setAudience(audience);
|
|
251
|
+
return jwt.encrypt(key);
|
|
252
|
+
}
|
|
253
|
+
async function openSession(token, secret, audience) {
|
|
254
|
+
const key = await deriveSessionKey(secret);
|
|
255
|
+
try {
|
|
256
|
+
const { payload } = await jose.jwtDecrypt(token, key, {
|
|
257
|
+
keyManagementAlgorithms: ["dir"],
|
|
258
|
+
contentEncryptionAlgorithms: ["A256GCM"],
|
|
259
|
+
...audience ? { audience } : {}
|
|
260
|
+
});
|
|
261
|
+
const { accessToken, refreshToken, accessExpiresAt, user } = payload;
|
|
262
|
+
if (typeof accessToken !== "string" || typeof refreshToken !== "string" || !Number.isFinite(accessExpiresAt) || !user || typeof user !== "object" || typeof user.recordId !== "string" || typeof user.email !== "string") {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
accessToken,
|
|
267
|
+
refreshToken,
|
|
268
|
+
accessExpiresAt,
|
|
269
|
+
user: {
|
|
270
|
+
recordId: user.recordId,
|
|
271
|
+
email: user.email
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function isAccessExpired(payload, now = Date.now()) {
|
|
279
|
+
return payload.accessExpiresAt <= now + ACCESS_EXPIRY_SKEW_MS;
|
|
280
|
+
}
|
|
281
|
+
function sessionCookieOptions() {
|
|
282
|
+
return {
|
|
283
|
+
httpOnly: true,
|
|
284
|
+
secure: process.env.NODE_ENV !== "development",
|
|
285
|
+
sameSite: "lax",
|
|
286
|
+
path: "/",
|
|
287
|
+
maxAge: SESSION_MAX_AGE_SECONDS
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/config.ts
|
|
292
|
+
function assertAuthConfig(config) {
|
|
293
|
+
const auth = config.auth;
|
|
294
|
+
if (!auth || typeof auth.modelSlug !== "string" || !auth.modelSlug) {
|
|
295
|
+
throw new Error("cmssy: config.auth.modelSlug is required for auth routes");
|
|
296
|
+
}
|
|
297
|
+
if (typeof auth.sessionSecret !== "string" || auth.sessionSecret.length < MIN_SESSION_SECRET_LENGTH) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`cmssy: config.auth.sessionSecret must be at least ${MIN_SESSION_SECRET_LENGTH} characters`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return auth;
|
|
303
|
+
}
|
|
304
|
+
var LOGIN_MUTATION = `mutation SiteMemberLogin($input: SiteMemberLoginInput!) {
|
|
305
|
+
siteMemberLogin(input: $input) {
|
|
306
|
+
success message accessToken refreshToken accessTokenExpiresIn
|
|
307
|
+
}
|
|
308
|
+
}`;
|
|
309
|
+
var REGISTER_MUTATION = `mutation SiteMemberRegister($input: SiteMemberRegisterInput!) {
|
|
310
|
+
siteMemberRegister(input: $input) { success message }
|
|
311
|
+
}`;
|
|
312
|
+
var REFRESH_MUTATION = `mutation SiteMemberRefresh($refreshToken: String!) {
|
|
313
|
+
siteMemberRefresh(refreshToken: $refreshToken) {
|
|
314
|
+
success message accessToken refreshToken accessTokenExpiresIn
|
|
315
|
+
}
|
|
316
|
+
}`;
|
|
317
|
+
var LOGOUT_MUTATION = `mutation SiteMemberLogout($refreshToken: String!) {
|
|
318
|
+
siteMemberLogout(refreshToken: $refreshToken) { success message }
|
|
319
|
+
}`;
|
|
320
|
+
var LOGOUT_EVERYWHERE_MUTATION = `mutation SiteMemberLogoutEverywhere {
|
|
321
|
+
siteMemberLogoutEverywhere { success message }
|
|
322
|
+
}`;
|
|
323
|
+
var FORGOT_MUTATION = `mutation SiteMemberForgotPassword($modelSlug: String!, $identity: String!) {
|
|
324
|
+
siteMemberForgotPassword(modelSlug: $modelSlug, identity: $identity) { success message }
|
|
325
|
+
}`;
|
|
326
|
+
var RESET_MUTATION = `mutation SiteMemberResetPassword($token: String!, $newPassword: String!) {
|
|
327
|
+
siteMemberResetPassword(token: $token, newPassword: $newPassword) { success message }
|
|
328
|
+
}`;
|
|
329
|
+
var VERIFY_MUTATION = `mutation SiteMemberVerifyEmail($token: String!) {
|
|
330
|
+
siteMemberVerifyEmail(token: $token) { success message }
|
|
331
|
+
}`;
|
|
332
|
+
var workspaceIdCache = /* @__PURE__ */ new Map();
|
|
333
|
+
function workspaceIdFor(config) {
|
|
334
|
+
const key = `${config.apiUrl}::${config.workspaceSlug}`;
|
|
335
|
+
const existing = workspaceIdCache.get(key);
|
|
336
|
+
if (existing) return existing;
|
|
337
|
+
const fresh = react.resolveWorkspaceId(config).catch((err) => {
|
|
338
|
+
workspaceIdCache.delete(key);
|
|
339
|
+
throw err;
|
|
340
|
+
});
|
|
341
|
+
workspaceIdCache.set(key, fresh);
|
|
342
|
+
return fresh;
|
|
343
|
+
}
|
|
344
|
+
async function authRequest(config, query, variables, label, accessToken) {
|
|
345
|
+
const workspaceId = await workspaceIdFor(config);
|
|
346
|
+
return react.graphqlRequest(
|
|
347
|
+
config,
|
|
348
|
+
query,
|
|
349
|
+
variables,
|
|
350
|
+
{
|
|
351
|
+
headers: {
|
|
352
|
+
"x-workspace-id": workspaceId,
|
|
353
|
+
...accessToken ? { authorization: `Bearer ${accessToken}` } : {}
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
label
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
function decodeAccessClaims(accessToken) {
|
|
360
|
+
const parts = accessToken.split(".");
|
|
361
|
+
if (parts.length !== 3) return null;
|
|
362
|
+
try {
|
|
363
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
364
|
+
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
365
|
+
const json2 = JSON.parse(new TextDecoder().decode(bytes));
|
|
366
|
+
if (typeof json2.recordId !== "string" || typeof json2.email !== "string" || json2.type !== "site_member") {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
return { recordId: json2.recordId, email: json2.email };
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function toSessionPayload(result) {
|
|
375
|
+
if (!result.success || !result.accessToken || !result.refreshToken || !result.accessTokenExpiresIn) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
const user = decodeAccessClaims(result.accessToken);
|
|
379
|
+
if (!user) return null;
|
|
380
|
+
return {
|
|
381
|
+
accessToken: result.accessToken,
|
|
382
|
+
refreshToken: result.refreshToken,
|
|
383
|
+
accessExpiresAt: Date.now() + result.accessTokenExpiresIn * 1e3,
|
|
384
|
+
user
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
async function backendSignIn(config, modelSlug, identity, password) {
|
|
388
|
+
const data = await authRequest(
|
|
389
|
+
config,
|
|
390
|
+
LOGIN_MUTATION,
|
|
391
|
+
{
|
|
392
|
+
input: { modelSlug, identity, password }
|
|
393
|
+
},
|
|
394
|
+
"site member login"
|
|
395
|
+
);
|
|
396
|
+
return data.siteMemberLogin;
|
|
397
|
+
}
|
|
398
|
+
async function backendRegister(config, modelSlug, identity, password, fields) {
|
|
399
|
+
const data = await authRequest(
|
|
400
|
+
config,
|
|
401
|
+
REGISTER_MUTATION,
|
|
402
|
+
{
|
|
403
|
+
input: { modelSlug, identity, password, fields }
|
|
404
|
+
},
|
|
405
|
+
"site member register"
|
|
406
|
+
);
|
|
407
|
+
return data.siteMemberRegister;
|
|
408
|
+
}
|
|
409
|
+
async function backendRefresh(config, refreshToken) {
|
|
410
|
+
const data = await authRequest(
|
|
411
|
+
config,
|
|
412
|
+
REFRESH_MUTATION,
|
|
413
|
+
{ refreshToken },
|
|
414
|
+
"site member refresh"
|
|
415
|
+
);
|
|
416
|
+
return data.siteMemberRefresh;
|
|
417
|
+
}
|
|
418
|
+
async function backendSignOut(config, refreshToken) {
|
|
419
|
+
try {
|
|
420
|
+
await authRequest(
|
|
421
|
+
config,
|
|
422
|
+
LOGOUT_MUTATION,
|
|
423
|
+
{ refreshToken },
|
|
424
|
+
"site member logout"
|
|
425
|
+
);
|
|
426
|
+
} catch {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async function backendSignOutEverywhere(config, accessToken) {
|
|
431
|
+
try {
|
|
432
|
+
await authRequest(
|
|
433
|
+
config,
|
|
434
|
+
LOGOUT_EVERYWHERE_MUTATION,
|
|
435
|
+
{},
|
|
436
|
+
"site member logout everywhere",
|
|
437
|
+
accessToken
|
|
438
|
+
);
|
|
439
|
+
} catch {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function backendForgotPassword(config, modelSlug, identity) {
|
|
444
|
+
const data = await authRequest(
|
|
445
|
+
config,
|
|
446
|
+
FORGOT_MUTATION,
|
|
447
|
+
{ modelSlug, identity },
|
|
448
|
+
"site member forgot password"
|
|
449
|
+
);
|
|
450
|
+
return data.siteMemberForgotPassword;
|
|
451
|
+
}
|
|
452
|
+
async function backendResetPassword(config, token, newPassword) {
|
|
453
|
+
const data = await authRequest(
|
|
454
|
+
config,
|
|
455
|
+
RESET_MUTATION,
|
|
456
|
+
{ token, newPassword },
|
|
457
|
+
"site member reset password"
|
|
458
|
+
);
|
|
459
|
+
return data.siteMemberResetPassword;
|
|
460
|
+
}
|
|
461
|
+
async function backendVerifyEmail(config, token) {
|
|
462
|
+
const data = await authRequest(
|
|
463
|
+
config,
|
|
464
|
+
VERIFY_MUTATION,
|
|
465
|
+
{ token },
|
|
466
|
+
"site member verify email"
|
|
467
|
+
);
|
|
468
|
+
return data.siteMemberVerifyEmail;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/create-auth-route.ts
|
|
472
|
+
var MAX_BODY_CHARS = 16 * 1024;
|
|
473
|
+
function json(body, status = 200) {
|
|
474
|
+
return new Response(JSON.stringify(body), {
|
|
475
|
+
status,
|
|
476
|
+
headers: {
|
|
477
|
+
"content-type": "application/json",
|
|
478
|
+
"cache-control": "no-store"
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async function readBody(request) {
|
|
483
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
484
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
485
|
+
throw new Error("content-type must be application/json");
|
|
486
|
+
}
|
|
487
|
+
const text = await request.text();
|
|
488
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
489
|
+
throw new Error("body too large");
|
|
490
|
+
}
|
|
491
|
+
if (!text) return {};
|
|
492
|
+
const parsed = JSON.parse(text);
|
|
493
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
494
|
+
throw new Error("body must be a JSON object");
|
|
495
|
+
}
|
|
496
|
+
return parsed;
|
|
497
|
+
}
|
|
498
|
+
function str(value) {
|
|
499
|
+
return typeof value === "string" ? value : "";
|
|
500
|
+
}
|
|
501
|
+
function plainObject(value) {
|
|
502
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
503
|
+
return value;
|
|
504
|
+
}
|
|
505
|
+
async function readSession(config, auth) {
|
|
506
|
+
const jar = await headers.cookies();
|
|
507
|
+
const raw = jar.get(CMSSY_SESSION_COOKIE)?.value;
|
|
508
|
+
if (!raw) return null;
|
|
509
|
+
return openSession(raw, auth.sessionSecret, config.workspaceSlug);
|
|
510
|
+
}
|
|
511
|
+
async function writeSession(config, auth, payload) {
|
|
512
|
+
const sealed = await sealSession(
|
|
513
|
+
payload,
|
|
514
|
+
auth.sessionSecret,
|
|
515
|
+
config.workspaceSlug
|
|
516
|
+
);
|
|
517
|
+
const jar = await headers.cookies();
|
|
518
|
+
jar.set(CMSSY_SESSION_COOKIE, sealed, sessionCookieOptions());
|
|
519
|
+
}
|
|
520
|
+
async function clearSession() {
|
|
521
|
+
const jar = await headers.cookies();
|
|
522
|
+
jar.set(CMSSY_SESSION_COOKIE, "", {
|
|
523
|
+
...sessionCookieOptions(),
|
|
524
|
+
maxAge: 0
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
async function refreshSession(config, auth, session) {
|
|
528
|
+
const result = await backendRefresh(config, session.refreshToken);
|
|
529
|
+
const payload = toSessionPayload(result);
|
|
530
|
+
if (!payload) {
|
|
531
|
+
await clearSession();
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
await writeSession(config, auth, payload);
|
|
535
|
+
return payload;
|
|
536
|
+
}
|
|
537
|
+
function createCmssyAuthRoute(config) {
|
|
538
|
+
const auth = assertAuthConfig(config);
|
|
539
|
+
async function handleSignIn(body) {
|
|
540
|
+
const identity = str(body.identity);
|
|
541
|
+
const password = str(body.password);
|
|
542
|
+
if (!identity || !password) {
|
|
543
|
+
return json({ ok: false, message: "Invalid credentials." }, 400);
|
|
544
|
+
}
|
|
545
|
+
const result = await backendSignIn(
|
|
546
|
+
config,
|
|
547
|
+
auth.modelSlug,
|
|
548
|
+
identity,
|
|
549
|
+
password
|
|
550
|
+
);
|
|
551
|
+
const payload = toSessionPayload(result);
|
|
552
|
+
if (!payload) {
|
|
553
|
+
return json({ ok: false, message: result.message }, 401);
|
|
554
|
+
}
|
|
555
|
+
await writeSession(config, auth, payload);
|
|
556
|
+
return json({ ok: true, user: payload.user });
|
|
557
|
+
}
|
|
558
|
+
async function handleRegister(body) {
|
|
559
|
+
const identity = str(body.identity);
|
|
560
|
+
const password = str(body.password);
|
|
561
|
+
if (!identity || !password) {
|
|
562
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
563
|
+
}
|
|
564
|
+
const result = await backendRegister(
|
|
565
|
+
config,
|
|
566
|
+
auth.modelSlug,
|
|
567
|
+
identity,
|
|
568
|
+
password,
|
|
569
|
+
plainObject(body.fields)
|
|
570
|
+
);
|
|
571
|
+
return json({ ok: result.success, message: result.message });
|
|
572
|
+
}
|
|
573
|
+
async function handleSignOut() {
|
|
574
|
+
const session = await readSession(config, auth);
|
|
575
|
+
if (session) {
|
|
576
|
+
await backendSignOut(config, session.refreshToken);
|
|
577
|
+
}
|
|
578
|
+
await clearSession();
|
|
579
|
+
return json({ ok: true });
|
|
580
|
+
}
|
|
581
|
+
async function handleSignOutEverywhere() {
|
|
582
|
+
let session = await readSession(config, auth);
|
|
583
|
+
if (session && isAccessExpired(session)) {
|
|
584
|
+
session = await refreshSession(config, auth, session);
|
|
585
|
+
}
|
|
586
|
+
if (session) {
|
|
587
|
+
await backendSignOutEverywhere(config, session.accessToken);
|
|
588
|
+
}
|
|
589
|
+
await clearSession();
|
|
590
|
+
return json({ ok: true });
|
|
591
|
+
}
|
|
592
|
+
async function handleRefresh() {
|
|
593
|
+
const session = await readSession(config, auth);
|
|
594
|
+
if (!session) {
|
|
595
|
+
return json({ ok: false, user: null }, 401);
|
|
596
|
+
}
|
|
597
|
+
const refreshed = await refreshSession(config, auth, session);
|
|
598
|
+
if (!refreshed) {
|
|
599
|
+
return json({ ok: false, user: null }, 401);
|
|
600
|
+
}
|
|
601
|
+
return json({ ok: true, user: refreshed.user });
|
|
602
|
+
}
|
|
603
|
+
async function handleMe() {
|
|
604
|
+
let session = await readSession(config, auth);
|
|
605
|
+
if (session && isAccessExpired(session)) {
|
|
606
|
+
session = await refreshSession(config, auth, session);
|
|
607
|
+
}
|
|
608
|
+
return json({ user: session?.user ?? null });
|
|
609
|
+
}
|
|
610
|
+
async function handleForgotPassword(body) {
|
|
611
|
+
const identity = str(body.identity);
|
|
612
|
+
if (!identity) {
|
|
613
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
614
|
+
}
|
|
615
|
+
const result = await backendForgotPassword(
|
|
616
|
+
config,
|
|
617
|
+
auth.modelSlug,
|
|
618
|
+
identity
|
|
619
|
+
);
|
|
620
|
+
return json({ ok: result.success, message: result.message });
|
|
621
|
+
}
|
|
622
|
+
async function handleResetPassword(body) {
|
|
623
|
+
const token = str(body.token);
|
|
624
|
+
const newPassword = str(body.newPassword);
|
|
625
|
+
if (!token || !newPassword) {
|
|
626
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
627
|
+
}
|
|
628
|
+
const result = await backendResetPassword(config, token, newPassword);
|
|
629
|
+
return json({ ok: result.success, message: result.message });
|
|
630
|
+
}
|
|
631
|
+
async function handleVerifyEmail(body) {
|
|
632
|
+
const token = str(body.token);
|
|
633
|
+
if (!token) {
|
|
634
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
635
|
+
}
|
|
636
|
+
const result = await backendVerifyEmail(config, token);
|
|
637
|
+
return json({ ok: result.success, message: result.message });
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
async POST(request, context) {
|
|
641
|
+
const { action } = await context.params;
|
|
642
|
+
let body;
|
|
643
|
+
try {
|
|
644
|
+
body = await readBody(request);
|
|
645
|
+
} catch {
|
|
646
|
+
return json({ ok: false, message: "Invalid request body." }, 400);
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
switch (action) {
|
|
650
|
+
case "sign-in":
|
|
651
|
+
return await handleSignIn(body);
|
|
652
|
+
case "register":
|
|
653
|
+
return await handleRegister(body);
|
|
654
|
+
case "sign-out":
|
|
655
|
+
return await handleSignOut();
|
|
656
|
+
case "sign-out-everywhere":
|
|
657
|
+
return await handleSignOutEverywhere();
|
|
658
|
+
case "refresh":
|
|
659
|
+
return await handleRefresh();
|
|
660
|
+
case "forgot-password":
|
|
661
|
+
return await handleForgotPassword(body);
|
|
662
|
+
case "reset-password":
|
|
663
|
+
return await handleResetPassword(body);
|
|
664
|
+
case "verify-email":
|
|
665
|
+
return await handleVerifyEmail(body);
|
|
666
|
+
default:
|
|
667
|
+
return json({ ok: false, message: "Not found." }, 404);
|
|
668
|
+
}
|
|
669
|
+
} catch {
|
|
670
|
+
return json({ ok: false, message: "Something went wrong." }, 500);
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
async GET(_request, context) {
|
|
674
|
+
const { action } = await context.params;
|
|
675
|
+
if (action !== "me") {
|
|
676
|
+
return json({ ok: false, message: "Not found." }, 404);
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
return await handleMe();
|
|
680
|
+
} catch {
|
|
681
|
+
return json({ user: null });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
async function readValidSession(config) {
|
|
687
|
+
const auth = assertAuthConfig(config);
|
|
688
|
+
const jar = await headers.cookies();
|
|
689
|
+
const raw = jar.get(CMSSY_SESSION_COOKIE)?.value;
|
|
690
|
+
if (!raw) return null;
|
|
691
|
+
const session = await openSession(
|
|
692
|
+
raw,
|
|
693
|
+
auth.sessionSecret,
|
|
694
|
+
config.workspaceSlug
|
|
695
|
+
);
|
|
696
|
+
if (!session || isAccessExpired(session)) return null;
|
|
697
|
+
return session;
|
|
698
|
+
}
|
|
699
|
+
async function getCmssyUser(config) {
|
|
700
|
+
const session = await readValidSession(config);
|
|
701
|
+
return session?.user ?? null;
|
|
702
|
+
}
|
|
703
|
+
async function getCmssyAccessToken(config) {
|
|
704
|
+
const session = await readValidSession(config);
|
|
705
|
+
return session?.accessToken ?? null;
|
|
706
|
+
}
|
|
707
|
+
function isPrefetch(request) {
|
|
708
|
+
return request.headers.get("next-router-prefetch") !== null || request.headers.get("purpose") === "prefetch" || (request.headers.get("sec-purpose") ?? "").includes("prefetch");
|
|
709
|
+
}
|
|
710
|
+
function createCmssyAuthMiddleware(config) {
|
|
711
|
+
const auth = assertAuthConfig(config);
|
|
712
|
+
return async function cmssyAuthMiddleware(request) {
|
|
713
|
+
const raw = request.cookies.get(CMSSY_SESSION_COOKIE)?.value;
|
|
714
|
+
if (!raw) return server.NextResponse.next();
|
|
715
|
+
const session = await openSession(
|
|
716
|
+
raw,
|
|
717
|
+
auth.sessionSecret,
|
|
718
|
+
config.workspaceSlug
|
|
719
|
+
);
|
|
720
|
+
if (!session) {
|
|
721
|
+
const response = server.NextResponse.next();
|
|
722
|
+
response.cookies.set(CMSSY_SESSION_COOKIE, "", {
|
|
723
|
+
...sessionCookieOptions(),
|
|
724
|
+
maxAge: 0
|
|
725
|
+
});
|
|
726
|
+
return response;
|
|
727
|
+
}
|
|
728
|
+
if (!isAccessExpired(session)) return server.NextResponse.next();
|
|
729
|
+
if (isPrefetch(request)) return server.NextResponse.next();
|
|
730
|
+
let payload = null;
|
|
731
|
+
try {
|
|
732
|
+
const result = await backendRefresh(config, session.refreshToken);
|
|
733
|
+
payload = toSessionPayload(result);
|
|
734
|
+
} catch {
|
|
735
|
+
return server.NextResponse.next();
|
|
736
|
+
}
|
|
737
|
+
if (!payload) {
|
|
738
|
+
const cleared = server.NextResponse.next();
|
|
739
|
+
cleared.cookies.set(CMSSY_SESSION_COOKIE, "", {
|
|
740
|
+
...sessionCookieOptions(),
|
|
741
|
+
maxAge: 0
|
|
742
|
+
});
|
|
743
|
+
return cleared;
|
|
744
|
+
}
|
|
745
|
+
const sealed = await sealSession(
|
|
746
|
+
payload,
|
|
747
|
+
auth.sessionSecret,
|
|
748
|
+
config.workspaceSlug
|
|
749
|
+
);
|
|
750
|
+
request.cookies.set(CMSSY_SESSION_COOKIE, sealed);
|
|
751
|
+
const refreshed = server.NextResponse.next({ request });
|
|
752
|
+
refreshed.cookies.set(CMSSY_SESSION_COOKIE, sealed, sessionCookieOptions());
|
|
753
|
+
return refreshed;
|
|
754
|
+
};
|
|
755
|
+
}
|
|
229
756
|
|
|
230
757
|
exports.CMSSY_EDIT_HEADER = CMSSY_EDIT_HEADER;
|
|
231
758
|
exports.CMSSY_LOCALE_HEADER = CMSSY_LOCALE_HEADER;
|
|
759
|
+
exports.CMSSY_SESSION_COOKIE = CMSSY_SESSION_COOKIE;
|
|
760
|
+
exports.SESSION_MAX_AGE_SECONDS = SESSION_MAX_AGE_SECONDS;
|
|
232
761
|
exports.applyCmssyCsp = applyCmssyCsp;
|
|
762
|
+
exports.assertAuthConfig = assertAuthConfig;
|
|
233
763
|
exports.cmssyCspHeaders = cmssyCspHeaders;
|
|
764
|
+
exports.createCmssyAuthMiddleware = createCmssyAuthMiddleware;
|
|
765
|
+
exports.createCmssyAuthRoute = createCmssyAuthRoute;
|
|
234
766
|
exports.createCmssyPage = createCmssyPage;
|
|
235
767
|
exports.createDraftRoute = createDraftRoute;
|
|
768
|
+
exports.getCmssyAccessToken = getCmssyAccessToken;
|
|
236
769
|
exports.getCmssyLocale = getCmssyLocale;
|
|
770
|
+
exports.getCmssyUser = getCmssyUser;
|
|
771
|
+
exports.isAccessExpired = isAccessExpired;
|
|
237
772
|
exports.isCmssyEditMode = isCmssyEditMode;
|
|
238
773
|
exports.isCmssyEditRequest = isCmssyEditRequest;
|
|
239
774
|
exports.localeForPathname = localeForPathname;
|
|
775
|
+
exports.openSession = openSession;
|
|
776
|
+
exports.sealSession = sealSession;
|
|
777
|
+
exports.sessionCookieOptions = sessionCookieOptions;
|
|
240
778
|
exports.splitCmssyLocale = splitCmssyLocale;
|
package/dist/index.d.cts
CHANGED
|
@@ -2,17 +2,24 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
|
2
2
|
import { ComponentType } from 'react';
|
|
3
3
|
import { CmssyPageData, CmssyFormDefinition, BlockDefinition, CmssyClientConfig } from '@cmssy/react';
|
|
4
4
|
import { EditBridgeConfig } from '@cmssy/react/client';
|
|
5
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
5
6
|
|
|
7
|
+
interface CmssyAuthConfig {
|
|
8
|
+
modelSlug: string;
|
|
9
|
+
sessionSecret: string;
|
|
10
|
+
}
|
|
6
11
|
interface CmssyNextConfig {
|
|
7
12
|
apiUrl: string;
|
|
8
13
|
workspaceSlug: string;
|
|
9
14
|
draftSecret: string;
|
|
10
15
|
editorOrigin: string | string[];
|
|
16
|
+
auth?: CmssyAuthConfig;
|
|
11
17
|
defaultLocale?: string;
|
|
12
18
|
/** All languages enabled on the workspace; exposed to blocks via context.locale.enabled. */
|
|
13
19
|
enabledLocales?: string[];
|
|
14
20
|
resolveLocale?: () => string | Promise<string>;
|
|
15
21
|
}
|
|
22
|
+
declare function assertAuthConfig(config: CmssyNextConfig): CmssyAuthConfig;
|
|
16
23
|
|
|
17
24
|
interface CmssyEditorProps {
|
|
18
25
|
page: CmssyPageData;
|
|
@@ -75,4 +82,48 @@ declare function splitCmssyLocale(config: CmssyClientConfig, path: string[] | un
|
|
|
75
82
|
}>;
|
|
76
83
|
declare function getCmssyLocale(config: CmssyClientConfig): Promise<string>;
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
declare const CMSSY_SESSION_COOKIE = "cmssy_session";
|
|
86
|
+
declare const SESSION_MAX_AGE_SECONDS: number;
|
|
87
|
+
interface CmssySessionUser {
|
|
88
|
+
recordId: string;
|
|
89
|
+
email: string;
|
|
90
|
+
}
|
|
91
|
+
interface CmssySessionPayload {
|
|
92
|
+
accessToken: string;
|
|
93
|
+
refreshToken: string;
|
|
94
|
+
accessExpiresAt: number;
|
|
95
|
+
user: CmssySessionUser;
|
|
96
|
+
}
|
|
97
|
+
declare function sealSession(payload: CmssySessionPayload, secret: string, audience?: string): Promise<string>;
|
|
98
|
+
declare function openSession(token: string, secret: string, audience?: string): Promise<CmssySessionPayload | null>;
|
|
99
|
+
declare function isAccessExpired(payload: CmssySessionPayload, now?: number): boolean;
|
|
100
|
+
interface SessionCookieOptions {
|
|
101
|
+
httpOnly: true;
|
|
102
|
+
secure: boolean;
|
|
103
|
+
sameSite: "lax";
|
|
104
|
+
path: "/";
|
|
105
|
+
maxAge: number;
|
|
106
|
+
}
|
|
107
|
+
declare function sessionCookieOptions(): SessionCookieOptions;
|
|
108
|
+
|
|
109
|
+
interface CmssyAuthRouteHandlers {
|
|
110
|
+
POST(request: Request, context: {
|
|
111
|
+
params: Promise<{
|
|
112
|
+
action: string;
|
|
113
|
+
}>;
|
|
114
|
+
}): Promise<Response>;
|
|
115
|
+
GET(request: Request, context: {
|
|
116
|
+
params: Promise<{
|
|
117
|
+
action: string;
|
|
118
|
+
}>;
|
|
119
|
+
}): Promise<Response>;
|
|
120
|
+
}
|
|
121
|
+
declare function createCmssyAuthRoute(config: CmssyNextConfig): CmssyAuthRouteHandlers;
|
|
122
|
+
|
|
123
|
+
declare function getCmssyUser(config: CmssyNextConfig): Promise<CmssySessionUser | null>;
|
|
124
|
+
declare function getCmssyAccessToken(config: CmssyNextConfig): Promise<string | null>;
|
|
125
|
+
|
|
126
|
+
type CmssyAuthMiddleware = (request: NextRequest) => Promise<NextResponse>;
|
|
127
|
+
declare function createCmssyAuthMiddleware(config: CmssyNextConfig): CmssyAuthMiddleware;
|
|
128
|
+
|
|
129
|
+
export { CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, CMSSY_SESSION_COOKIE, type CmssyAuthConfig, type CmssyAuthMiddleware, type CmssyAuthRouteHandlers, type CmssyCspOptions, type CmssyDraftRouteConfig, type CmssyEditorProps, type CmssyNextConfig, type CmssySessionPayload, type CmssySessionUser, type CreateCmssyPageOptions, SESSION_MAX_AGE_SECONDS, type SessionCookieOptions, applyCmssyCsp, assertAuthConfig, cmssyCspHeaders, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyPage, createDraftRoute, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, sealSession, sessionCookieOptions, splitCmssyLocale };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,17 +2,24 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
|
2
2
|
import { ComponentType } from 'react';
|
|
3
3
|
import { CmssyPageData, CmssyFormDefinition, BlockDefinition, CmssyClientConfig } from '@cmssy/react';
|
|
4
4
|
import { EditBridgeConfig } from '@cmssy/react/client';
|
|
5
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
5
6
|
|
|
7
|
+
interface CmssyAuthConfig {
|
|
8
|
+
modelSlug: string;
|
|
9
|
+
sessionSecret: string;
|
|
10
|
+
}
|
|
6
11
|
interface CmssyNextConfig {
|
|
7
12
|
apiUrl: string;
|
|
8
13
|
workspaceSlug: string;
|
|
9
14
|
draftSecret: string;
|
|
10
15
|
editorOrigin: string | string[];
|
|
16
|
+
auth?: CmssyAuthConfig;
|
|
11
17
|
defaultLocale?: string;
|
|
12
18
|
/** All languages enabled on the workspace; exposed to blocks via context.locale.enabled. */
|
|
13
19
|
enabledLocales?: string[];
|
|
14
20
|
resolveLocale?: () => string | Promise<string>;
|
|
15
21
|
}
|
|
22
|
+
declare function assertAuthConfig(config: CmssyNextConfig): CmssyAuthConfig;
|
|
16
23
|
|
|
17
24
|
interface CmssyEditorProps {
|
|
18
25
|
page: CmssyPageData;
|
|
@@ -75,4 +82,48 @@ declare function splitCmssyLocale(config: CmssyClientConfig, path: string[] | un
|
|
|
75
82
|
}>;
|
|
76
83
|
declare function getCmssyLocale(config: CmssyClientConfig): Promise<string>;
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
declare const CMSSY_SESSION_COOKIE = "cmssy_session";
|
|
86
|
+
declare const SESSION_MAX_AGE_SECONDS: number;
|
|
87
|
+
interface CmssySessionUser {
|
|
88
|
+
recordId: string;
|
|
89
|
+
email: string;
|
|
90
|
+
}
|
|
91
|
+
interface CmssySessionPayload {
|
|
92
|
+
accessToken: string;
|
|
93
|
+
refreshToken: string;
|
|
94
|
+
accessExpiresAt: number;
|
|
95
|
+
user: CmssySessionUser;
|
|
96
|
+
}
|
|
97
|
+
declare function sealSession(payload: CmssySessionPayload, secret: string, audience?: string): Promise<string>;
|
|
98
|
+
declare function openSession(token: string, secret: string, audience?: string): Promise<CmssySessionPayload | null>;
|
|
99
|
+
declare function isAccessExpired(payload: CmssySessionPayload, now?: number): boolean;
|
|
100
|
+
interface SessionCookieOptions {
|
|
101
|
+
httpOnly: true;
|
|
102
|
+
secure: boolean;
|
|
103
|
+
sameSite: "lax";
|
|
104
|
+
path: "/";
|
|
105
|
+
maxAge: number;
|
|
106
|
+
}
|
|
107
|
+
declare function sessionCookieOptions(): SessionCookieOptions;
|
|
108
|
+
|
|
109
|
+
interface CmssyAuthRouteHandlers {
|
|
110
|
+
POST(request: Request, context: {
|
|
111
|
+
params: Promise<{
|
|
112
|
+
action: string;
|
|
113
|
+
}>;
|
|
114
|
+
}): Promise<Response>;
|
|
115
|
+
GET(request: Request, context: {
|
|
116
|
+
params: Promise<{
|
|
117
|
+
action: string;
|
|
118
|
+
}>;
|
|
119
|
+
}): Promise<Response>;
|
|
120
|
+
}
|
|
121
|
+
declare function createCmssyAuthRoute(config: CmssyNextConfig): CmssyAuthRouteHandlers;
|
|
122
|
+
|
|
123
|
+
declare function getCmssyUser(config: CmssyNextConfig): Promise<CmssySessionUser | null>;
|
|
124
|
+
declare function getCmssyAccessToken(config: CmssyNextConfig): Promise<string | null>;
|
|
125
|
+
|
|
126
|
+
type CmssyAuthMiddleware = (request: NextRequest) => Promise<NextResponse>;
|
|
127
|
+
declare function createCmssyAuthMiddleware(config: CmssyNextConfig): CmssyAuthMiddleware;
|
|
128
|
+
|
|
129
|
+
export { CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, CMSSY_SESSION_COOKIE, type CmssyAuthConfig, type CmssyAuthMiddleware, type CmssyAuthRouteHandlers, type CmssyCspOptions, type CmssyDraftRouteConfig, type CmssyEditorProps, type CmssyNextConfig, type CmssySessionPayload, type CmssySessionUser, type CreateCmssyPageOptions, SESSION_MAX_AGE_SECONDS, type SessionCookieOptions, applyCmssyCsp, assertAuthConfig, cmssyCspHeaders, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyPage, createDraftRoute, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, sealSession, sessionCookieOptions, splitCmssyLocale };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { draftMode, headers } from 'next/headers';
|
|
1
|
+
import { draftMode, headers, cookies } from 'next/headers';
|
|
2
2
|
import { notFound, redirect } from 'next/navigation';
|
|
3
|
-
import { resolveSiteLocales, splitLocaleFromPath, fetchPage, resolveForms, CmssyServerPage } from '@cmssy/react';
|
|
3
|
+
import { resolveSiteLocales, splitLocaleFromPath, fetchPage, resolveForms, CmssyServerPage, graphqlRequest, resolveWorkspaceId } from '@cmssy/react';
|
|
4
4
|
import { jsx } from 'react/jsx-runtime';
|
|
5
5
|
import { createHash, timingSafeEqual } from 'crypto';
|
|
6
|
+
import { EncryptJWT, jwtDecrypt } from 'jose';
|
|
7
|
+
import { NextResponse } from 'next/server';
|
|
6
8
|
|
|
7
9
|
// src/create-cmssy-page.tsx
|
|
8
10
|
|
|
@@ -224,5 +226,530 @@ async function getCmssyLocale(config) {
|
|
|
224
226
|
const { defaultLocale } = await resolveSiteLocales(config);
|
|
225
227
|
return defaultLocale;
|
|
226
228
|
}
|
|
229
|
+
var CMSSY_SESSION_COOKIE = "cmssy_session";
|
|
230
|
+
var SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
231
|
+
var MIN_SESSION_SECRET_LENGTH = 32;
|
|
232
|
+
var ACCESS_EXPIRY_SKEW_MS = 3e4;
|
|
233
|
+
async function deriveSessionKey(secret) {
|
|
234
|
+
if (typeof secret !== "string" || secret.length < MIN_SESSION_SECRET_LENGTH) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`cmssy auth sessionSecret must be at least ${MIN_SESSION_SECRET_LENGTH} characters`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const digest = await crypto.subtle.digest(
|
|
240
|
+
"SHA-256",
|
|
241
|
+
new TextEncoder().encode(secret)
|
|
242
|
+
);
|
|
243
|
+
return new Uint8Array(digest);
|
|
244
|
+
}
|
|
245
|
+
async function sealSession(payload, secret, audience) {
|
|
246
|
+
const key = await deriveSessionKey(secret);
|
|
247
|
+
const jwt = new EncryptJWT({ ...payload }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`);
|
|
248
|
+
if (audience) jwt.setAudience(audience);
|
|
249
|
+
return jwt.encrypt(key);
|
|
250
|
+
}
|
|
251
|
+
async function openSession(token, secret, audience) {
|
|
252
|
+
const key = await deriveSessionKey(secret);
|
|
253
|
+
try {
|
|
254
|
+
const { payload } = await jwtDecrypt(token, key, {
|
|
255
|
+
keyManagementAlgorithms: ["dir"],
|
|
256
|
+
contentEncryptionAlgorithms: ["A256GCM"],
|
|
257
|
+
...audience ? { audience } : {}
|
|
258
|
+
});
|
|
259
|
+
const { accessToken, refreshToken, accessExpiresAt, user } = payload;
|
|
260
|
+
if (typeof accessToken !== "string" || typeof refreshToken !== "string" || !Number.isFinite(accessExpiresAt) || !user || typeof user !== "object" || typeof user.recordId !== "string" || typeof user.email !== "string") {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
accessToken,
|
|
265
|
+
refreshToken,
|
|
266
|
+
accessExpiresAt,
|
|
267
|
+
user: {
|
|
268
|
+
recordId: user.recordId,
|
|
269
|
+
email: user.email
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function isAccessExpired(payload, now = Date.now()) {
|
|
277
|
+
return payload.accessExpiresAt <= now + ACCESS_EXPIRY_SKEW_MS;
|
|
278
|
+
}
|
|
279
|
+
function sessionCookieOptions() {
|
|
280
|
+
return {
|
|
281
|
+
httpOnly: true,
|
|
282
|
+
secure: process.env.NODE_ENV !== "development",
|
|
283
|
+
sameSite: "lax",
|
|
284
|
+
path: "/",
|
|
285
|
+
maxAge: SESSION_MAX_AGE_SECONDS
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/config.ts
|
|
290
|
+
function assertAuthConfig(config) {
|
|
291
|
+
const auth = config.auth;
|
|
292
|
+
if (!auth || typeof auth.modelSlug !== "string" || !auth.modelSlug) {
|
|
293
|
+
throw new Error("cmssy: config.auth.modelSlug is required for auth routes");
|
|
294
|
+
}
|
|
295
|
+
if (typeof auth.sessionSecret !== "string" || auth.sessionSecret.length < MIN_SESSION_SECRET_LENGTH) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`cmssy: config.auth.sessionSecret must be at least ${MIN_SESSION_SECRET_LENGTH} characters`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return auth;
|
|
301
|
+
}
|
|
302
|
+
var LOGIN_MUTATION = `mutation SiteMemberLogin($input: SiteMemberLoginInput!) {
|
|
303
|
+
siteMemberLogin(input: $input) {
|
|
304
|
+
success message accessToken refreshToken accessTokenExpiresIn
|
|
305
|
+
}
|
|
306
|
+
}`;
|
|
307
|
+
var REGISTER_MUTATION = `mutation SiteMemberRegister($input: SiteMemberRegisterInput!) {
|
|
308
|
+
siteMemberRegister(input: $input) { success message }
|
|
309
|
+
}`;
|
|
310
|
+
var REFRESH_MUTATION = `mutation SiteMemberRefresh($refreshToken: String!) {
|
|
311
|
+
siteMemberRefresh(refreshToken: $refreshToken) {
|
|
312
|
+
success message accessToken refreshToken accessTokenExpiresIn
|
|
313
|
+
}
|
|
314
|
+
}`;
|
|
315
|
+
var LOGOUT_MUTATION = `mutation SiteMemberLogout($refreshToken: String!) {
|
|
316
|
+
siteMemberLogout(refreshToken: $refreshToken) { success message }
|
|
317
|
+
}`;
|
|
318
|
+
var LOGOUT_EVERYWHERE_MUTATION = `mutation SiteMemberLogoutEverywhere {
|
|
319
|
+
siteMemberLogoutEverywhere { success message }
|
|
320
|
+
}`;
|
|
321
|
+
var FORGOT_MUTATION = `mutation SiteMemberForgotPassword($modelSlug: String!, $identity: String!) {
|
|
322
|
+
siteMemberForgotPassword(modelSlug: $modelSlug, identity: $identity) { success message }
|
|
323
|
+
}`;
|
|
324
|
+
var RESET_MUTATION = `mutation SiteMemberResetPassword($token: String!, $newPassword: String!) {
|
|
325
|
+
siteMemberResetPassword(token: $token, newPassword: $newPassword) { success message }
|
|
326
|
+
}`;
|
|
327
|
+
var VERIFY_MUTATION = `mutation SiteMemberVerifyEmail($token: String!) {
|
|
328
|
+
siteMemberVerifyEmail(token: $token) { success message }
|
|
329
|
+
}`;
|
|
330
|
+
var workspaceIdCache = /* @__PURE__ */ new Map();
|
|
331
|
+
function workspaceIdFor(config) {
|
|
332
|
+
const key = `${config.apiUrl}::${config.workspaceSlug}`;
|
|
333
|
+
const existing = workspaceIdCache.get(key);
|
|
334
|
+
if (existing) return existing;
|
|
335
|
+
const fresh = resolveWorkspaceId(config).catch((err) => {
|
|
336
|
+
workspaceIdCache.delete(key);
|
|
337
|
+
throw err;
|
|
338
|
+
});
|
|
339
|
+
workspaceIdCache.set(key, fresh);
|
|
340
|
+
return fresh;
|
|
341
|
+
}
|
|
342
|
+
async function authRequest(config, query, variables, label, accessToken) {
|
|
343
|
+
const workspaceId = await workspaceIdFor(config);
|
|
344
|
+
return graphqlRequest(
|
|
345
|
+
config,
|
|
346
|
+
query,
|
|
347
|
+
variables,
|
|
348
|
+
{
|
|
349
|
+
headers: {
|
|
350
|
+
"x-workspace-id": workspaceId,
|
|
351
|
+
...accessToken ? { authorization: `Bearer ${accessToken}` } : {}
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
label
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
function decodeAccessClaims(accessToken) {
|
|
358
|
+
const parts = accessToken.split(".");
|
|
359
|
+
if (parts.length !== 3) return null;
|
|
360
|
+
try {
|
|
361
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
362
|
+
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
363
|
+
const json2 = JSON.parse(new TextDecoder().decode(bytes));
|
|
364
|
+
if (typeof json2.recordId !== "string" || typeof json2.email !== "string" || json2.type !== "site_member") {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return { recordId: json2.recordId, email: json2.email };
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function toSessionPayload(result) {
|
|
373
|
+
if (!result.success || !result.accessToken || !result.refreshToken || !result.accessTokenExpiresIn) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const user = decodeAccessClaims(result.accessToken);
|
|
377
|
+
if (!user) return null;
|
|
378
|
+
return {
|
|
379
|
+
accessToken: result.accessToken,
|
|
380
|
+
refreshToken: result.refreshToken,
|
|
381
|
+
accessExpiresAt: Date.now() + result.accessTokenExpiresIn * 1e3,
|
|
382
|
+
user
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
async function backendSignIn(config, modelSlug, identity, password) {
|
|
386
|
+
const data = await authRequest(
|
|
387
|
+
config,
|
|
388
|
+
LOGIN_MUTATION,
|
|
389
|
+
{
|
|
390
|
+
input: { modelSlug, identity, password }
|
|
391
|
+
},
|
|
392
|
+
"site member login"
|
|
393
|
+
);
|
|
394
|
+
return data.siteMemberLogin;
|
|
395
|
+
}
|
|
396
|
+
async function backendRegister(config, modelSlug, identity, password, fields) {
|
|
397
|
+
const data = await authRequest(
|
|
398
|
+
config,
|
|
399
|
+
REGISTER_MUTATION,
|
|
400
|
+
{
|
|
401
|
+
input: { modelSlug, identity, password, fields }
|
|
402
|
+
},
|
|
403
|
+
"site member register"
|
|
404
|
+
);
|
|
405
|
+
return data.siteMemberRegister;
|
|
406
|
+
}
|
|
407
|
+
async function backendRefresh(config, refreshToken) {
|
|
408
|
+
const data = await authRequest(
|
|
409
|
+
config,
|
|
410
|
+
REFRESH_MUTATION,
|
|
411
|
+
{ refreshToken },
|
|
412
|
+
"site member refresh"
|
|
413
|
+
);
|
|
414
|
+
return data.siteMemberRefresh;
|
|
415
|
+
}
|
|
416
|
+
async function backendSignOut(config, refreshToken) {
|
|
417
|
+
try {
|
|
418
|
+
await authRequest(
|
|
419
|
+
config,
|
|
420
|
+
LOGOUT_MUTATION,
|
|
421
|
+
{ refreshToken },
|
|
422
|
+
"site member logout"
|
|
423
|
+
);
|
|
424
|
+
} catch {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function backendSignOutEverywhere(config, accessToken) {
|
|
429
|
+
try {
|
|
430
|
+
await authRequest(
|
|
431
|
+
config,
|
|
432
|
+
LOGOUT_EVERYWHERE_MUTATION,
|
|
433
|
+
{},
|
|
434
|
+
"site member logout everywhere",
|
|
435
|
+
accessToken
|
|
436
|
+
);
|
|
437
|
+
} catch {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async function backendForgotPassword(config, modelSlug, identity) {
|
|
442
|
+
const data = await authRequest(
|
|
443
|
+
config,
|
|
444
|
+
FORGOT_MUTATION,
|
|
445
|
+
{ modelSlug, identity },
|
|
446
|
+
"site member forgot password"
|
|
447
|
+
);
|
|
448
|
+
return data.siteMemberForgotPassword;
|
|
449
|
+
}
|
|
450
|
+
async function backendResetPassword(config, token, newPassword) {
|
|
451
|
+
const data = await authRequest(
|
|
452
|
+
config,
|
|
453
|
+
RESET_MUTATION,
|
|
454
|
+
{ token, newPassword },
|
|
455
|
+
"site member reset password"
|
|
456
|
+
);
|
|
457
|
+
return data.siteMemberResetPassword;
|
|
458
|
+
}
|
|
459
|
+
async function backendVerifyEmail(config, token) {
|
|
460
|
+
const data = await authRequest(
|
|
461
|
+
config,
|
|
462
|
+
VERIFY_MUTATION,
|
|
463
|
+
{ token },
|
|
464
|
+
"site member verify email"
|
|
465
|
+
);
|
|
466
|
+
return data.siteMemberVerifyEmail;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/create-auth-route.ts
|
|
470
|
+
var MAX_BODY_CHARS = 16 * 1024;
|
|
471
|
+
function json(body, status = 200) {
|
|
472
|
+
return new Response(JSON.stringify(body), {
|
|
473
|
+
status,
|
|
474
|
+
headers: {
|
|
475
|
+
"content-type": "application/json",
|
|
476
|
+
"cache-control": "no-store"
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
async function readBody(request) {
|
|
481
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
482
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
483
|
+
throw new Error("content-type must be application/json");
|
|
484
|
+
}
|
|
485
|
+
const text = await request.text();
|
|
486
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
487
|
+
throw new Error("body too large");
|
|
488
|
+
}
|
|
489
|
+
if (!text) return {};
|
|
490
|
+
const parsed = JSON.parse(text);
|
|
491
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
492
|
+
throw new Error("body must be a JSON object");
|
|
493
|
+
}
|
|
494
|
+
return parsed;
|
|
495
|
+
}
|
|
496
|
+
function str(value) {
|
|
497
|
+
return typeof value === "string" ? value : "";
|
|
498
|
+
}
|
|
499
|
+
function plainObject(value) {
|
|
500
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
503
|
+
async function readSession(config, auth) {
|
|
504
|
+
const jar = await cookies();
|
|
505
|
+
const raw = jar.get(CMSSY_SESSION_COOKIE)?.value;
|
|
506
|
+
if (!raw) return null;
|
|
507
|
+
return openSession(raw, auth.sessionSecret, config.workspaceSlug);
|
|
508
|
+
}
|
|
509
|
+
async function writeSession(config, auth, payload) {
|
|
510
|
+
const sealed = await sealSession(
|
|
511
|
+
payload,
|
|
512
|
+
auth.sessionSecret,
|
|
513
|
+
config.workspaceSlug
|
|
514
|
+
);
|
|
515
|
+
const jar = await cookies();
|
|
516
|
+
jar.set(CMSSY_SESSION_COOKIE, sealed, sessionCookieOptions());
|
|
517
|
+
}
|
|
518
|
+
async function clearSession() {
|
|
519
|
+
const jar = await cookies();
|
|
520
|
+
jar.set(CMSSY_SESSION_COOKIE, "", {
|
|
521
|
+
...sessionCookieOptions(),
|
|
522
|
+
maxAge: 0
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async function refreshSession(config, auth, session) {
|
|
526
|
+
const result = await backendRefresh(config, session.refreshToken);
|
|
527
|
+
const payload = toSessionPayload(result);
|
|
528
|
+
if (!payload) {
|
|
529
|
+
await clearSession();
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
await writeSession(config, auth, payload);
|
|
533
|
+
return payload;
|
|
534
|
+
}
|
|
535
|
+
function createCmssyAuthRoute(config) {
|
|
536
|
+
const auth = assertAuthConfig(config);
|
|
537
|
+
async function handleSignIn(body) {
|
|
538
|
+
const identity = str(body.identity);
|
|
539
|
+
const password = str(body.password);
|
|
540
|
+
if (!identity || !password) {
|
|
541
|
+
return json({ ok: false, message: "Invalid credentials." }, 400);
|
|
542
|
+
}
|
|
543
|
+
const result = await backendSignIn(
|
|
544
|
+
config,
|
|
545
|
+
auth.modelSlug,
|
|
546
|
+
identity,
|
|
547
|
+
password
|
|
548
|
+
);
|
|
549
|
+
const payload = toSessionPayload(result);
|
|
550
|
+
if (!payload) {
|
|
551
|
+
return json({ ok: false, message: result.message }, 401);
|
|
552
|
+
}
|
|
553
|
+
await writeSession(config, auth, payload);
|
|
554
|
+
return json({ ok: true, user: payload.user });
|
|
555
|
+
}
|
|
556
|
+
async function handleRegister(body) {
|
|
557
|
+
const identity = str(body.identity);
|
|
558
|
+
const password = str(body.password);
|
|
559
|
+
if (!identity || !password) {
|
|
560
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
561
|
+
}
|
|
562
|
+
const result = await backendRegister(
|
|
563
|
+
config,
|
|
564
|
+
auth.modelSlug,
|
|
565
|
+
identity,
|
|
566
|
+
password,
|
|
567
|
+
plainObject(body.fields)
|
|
568
|
+
);
|
|
569
|
+
return json({ ok: result.success, message: result.message });
|
|
570
|
+
}
|
|
571
|
+
async function handleSignOut() {
|
|
572
|
+
const session = await readSession(config, auth);
|
|
573
|
+
if (session) {
|
|
574
|
+
await backendSignOut(config, session.refreshToken);
|
|
575
|
+
}
|
|
576
|
+
await clearSession();
|
|
577
|
+
return json({ ok: true });
|
|
578
|
+
}
|
|
579
|
+
async function handleSignOutEverywhere() {
|
|
580
|
+
let session = await readSession(config, auth);
|
|
581
|
+
if (session && isAccessExpired(session)) {
|
|
582
|
+
session = await refreshSession(config, auth, session);
|
|
583
|
+
}
|
|
584
|
+
if (session) {
|
|
585
|
+
await backendSignOutEverywhere(config, session.accessToken);
|
|
586
|
+
}
|
|
587
|
+
await clearSession();
|
|
588
|
+
return json({ ok: true });
|
|
589
|
+
}
|
|
590
|
+
async function handleRefresh() {
|
|
591
|
+
const session = await readSession(config, auth);
|
|
592
|
+
if (!session) {
|
|
593
|
+
return json({ ok: false, user: null }, 401);
|
|
594
|
+
}
|
|
595
|
+
const refreshed = await refreshSession(config, auth, session);
|
|
596
|
+
if (!refreshed) {
|
|
597
|
+
return json({ ok: false, user: null }, 401);
|
|
598
|
+
}
|
|
599
|
+
return json({ ok: true, user: refreshed.user });
|
|
600
|
+
}
|
|
601
|
+
async function handleMe() {
|
|
602
|
+
let session = await readSession(config, auth);
|
|
603
|
+
if (session && isAccessExpired(session)) {
|
|
604
|
+
session = await refreshSession(config, auth, session);
|
|
605
|
+
}
|
|
606
|
+
return json({ user: session?.user ?? null });
|
|
607
|
+
}
|
|
608
|
+
async function handleForgotPassword(body) {
|
|
609
|
+
const identity = str(body.identity);
|
|
610
|
+
if (!identity) {
|
|
611
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
612
|
+
}
|
|
613
|
+
const result = await backendForgotPassword(
|
|
614
|
+
config,
|
|
615
|
+
auth.modelSlug,
|
|
616
|
+
identity
|
|
617
|
+
);
|
|
618
|
+
return json({ ok: result.success, message: result.message });
|
|
619
|
+
}
|
|
620
|
+
async function handleResetPassword(body) {
|
|
621
|
+
const token = str(body.token);
|
|
622
|
+
const newPassword = str(body.newPassword);
|
|
623
|
+
if (!token || !newPassword) {
|
|
624
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
625
|
+
}
|
|
626
|
+
const result = await backendResetPassword(config, token, newPassword);
|
|
627
|
+
return json({ ok: result.success, message: result.message });
|
|
628
|
+
}
|
|
629
|
+
async function handleVerifyEmail(body) {
|
|
630
|
+
const token = str(body.token);
|
|
631
|
+
if (!token) {
|
|
632
|
+
return json({ ok: false, message: "Invalid input." }, 400);
|
|
633
|
+
}
|
|
634
|
+
const result = await backendVerifyEmail(config, token);
|
|
635
|
+
return json({ ok: result.success, message: result.message });
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
async POST(request, context) {
|
|
639
|
+
const { action } = await context.params;
|
|
640
|
+
let body;
|
|
641
|
+
try {
|
|
642
|
+
body = await readBody(request);
|
|
643
|
+
} catch {
|
|
644
|
+
return json({ ok: false, message: "Invalid request body." }, 400);
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
switch (action) {
|
|
648
|
+
case "sign-in":
|
|
649
|
+
return await handleSignIn(body);
|
|
650
|
+
case "register":
|
|
651
|
+
return await handleRegister(body);
|
|
652
|
+
case "sign-out":
|
|
653
|
+
return await handleSignOut();
|
|
654
|
+
case "sign-out-everywhere":
|
|
655
|
+
return await handleSignOutEverywhere();
|
|
656
|
+
case "refresh":
|
|
657
|
+
return await handleRefresh();
|
|
658
|
+
case "forgot-password":
|
|
659
|
+
return await handleForgotPassword(body);
|
|
660
|
+
case "reset-password":
|
|
661
|
+
return await handleResetPassword(body);
|
|
662
|
+
case "verify-email":
|
|
663
|
+
return await handleVerifyEmail(body);
|
|
664
|
+
default:
|
|
665
|
+
return json({ ok: false, message: "Not found." }, 404);
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
return json({ ok: false, message: "Something went wrong." }, 500);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
async GET(_request, context) {
|
|
672
|
+
const { action } = await context.params;
|
|
673
|
+
if (action !== "me") {
|
|
674
|
+
return json({ ok: false, message: "Not found." }, 404);
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
return await handleMe();
|
|
678
|
+
} catch {
|
|
679
|
+
return json({ user: null });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async function readValidSession(config) {
|
|
685
|
+
const auth = assertAuthConfig(config);
|
|
686
|
+
const jar = await cookies();
|
|
687
|
+
const raw = jar.get(CMSSY_SESSION_COOKIE)?.value;
|
|
688
|
+
if (!raw) return null;
|
|
689
|
+
const session = await openSession(
|
|
690
|
+
raw,
|
|
691
|
+
auth.sessionSecret,
|
|
692
|
+
config.workspaceSlug
|
|
693
|
+
);
|
|
694
|
+
if (!session || isAccessExpired(session)) return null;
|
|
695
|
+
return session;
|
|
696
|
+
}
|
|
697
|
+
async function getCmssyUser(config) {
|
|
698
|
+
const session = await readValidSession(config);
|
|
699
|
+
return session?.user ?? null;
|
|
700
|
+
}
|
|
701
|
+
async function getCmssyAccessToken(config) {
|
|
702
|
+
const session = await readValidSession(config);
|
|
703
|
+
return session?.accessToken ?? null;
|
|
704
|
+
}
|
|
705
|
+
function isPrefetch(request) {
|
|
706
|
+
return request.headers.get("next-router-prefetch") !== null || request.headers.get("purpose") === "prefetch" || (request.headers.get("sec-purpose") ?? "").includes("prefetch");
|
|
707
|
+
}
|
|
708
|
+
function createCmssyAuthMiddleware(config) {
|
|
709
|
+
const auth = assertAuthConfig(config);
|
|
710
|
+
return async function cmssyAuthMiddleware(request) {
|
|
711
|
+
const raw = request.cookies.get(CMSSY_SESSION_COOKIE)?.value;
|
|
712
|
+
if (!raw) return NextResponse.next();
|
|
713
|
+
const session = await openSession(
|
|
714
|
+
raw,
|
|
715
|
+
auth.sessionSecret,
|
|
716
|
+
config.workspaceSlug
|
|
717
|
+
);
|
|
718
|
+
if (!session) {
|
|
719
|
+
const response = NextResponse.next();
|
|
720
|
+
response.cookies.set(CMSSY_SESSION_COOKIE, "", {
|
|
721
|
+
...sessionCookieOptions(),
|
|
722
|
+
maxAge: 0
|
|
723
|
+
});
|
|
724
|
+
return response;
|
|
725
|
+
}
|
|
726
|
+
if (!isAccessExpired(session)) return NextResponse.next();
|
|
727
|
+
if (isPrefetch(request)) return NextResponse.next();
|
|
728
|
+
let payload = null;
|
|
729
|
+
try {
|
|
730
|
+
const result = await backendRefresh(config, session.refreshToken);
|
|
731
|
+
payload = toSessionPayload(result);
|
|
732
|
+
} catch {
|
|
733
|
+
return NextResponse.next();
|
|
734
|
+
}
|
|
735
|
+
if (!payload) {
|
|
736
|
+
const cleared = NextResponse.next();
|
|
737
|
+
cleared.cookies.set(CMSSY_SESSION_COOKIE, "", {
|
|
738
|
+
...sessionCookieOptions(),
|
|
739
|
+
maxAge: 0
|
|
740
|
+
});
|
|
741
|
+
return cleared;
|
|
742
|
+
}
|
|
743
|
+
const sealed = await sealSession(
|
|
744
|
+
payload,
|
|
745
|
+
auth.sessionSecret,
|
|
746
|
+
config.workspaceSlug
|
|
747
|
+
);
|
|
748
|
+
request.cookies.set(CMSSY_SESSION_COOKIE, sealed);
|
|
749
|
+
const refreshed = NextResponse.next({ request });
|
|
750
|
+
refreshed.cookies.set(CMSSY_SESSION_COOKIE, sealed, sessionCookieOptions());
|
|
751
|
+
return refreshed;
|
|
752
|
+
};
|
|
753
|
+
}
|
|
227
754
|
|
|
228
|
-
export { CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, applyCmssyCsp, cmssyCspHeaders, createCmssyPage, createDraftRoute, getCmssyLocale, isCmssyEditMode, isCmssyEditRequest, localeForPathname, splitCmssyLocale };
|
|
755
|
+
export { CMSSY_EDIT_HEADER, CMSSY_LOCALE_HEADER, CMSSY_SESSION_COOKIE, SESSION_MAX_AGE_SECONDS, applyCmssyCsp, assertAuthConfig, cmssyCspHeaders, createCmssyAuthMiddleware, createCmssyAuthRoute, createCmssyPage, createDraftRoute, getCmssyAccessToken, getCmssyLocale, getCmssyUser, isAccessExpired, isCmssyEditMode, isCmssyEditRequest, localeForPathname, openSession, sealSession, sessionCookieOptions, splitCmssyLocale };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmssy/next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Next.js App Router bindings for cmssy headless sites (createCmssyPage + draft preview)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cmssy",
|
|
@@ -49,7 +49,10 @@
|
|
|
49
49
|
"tsup": "^8.3.0",
|
|
50
50
|
"typescript": "^5.6.0",
|
|
51
51
|
"vitest": "^2.1.0",
|
|
52
|
-
"@cmssy/react": "0.1.
|
|
52
|
+
"@cmssy/react": "0.1.9"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"jose": "^6.2.3"
|
|
53
56
|
},
|
|
54
57
|
"scripts": {
|
|
55
58
|
"build": "tsup",
|