@10x-media/dual-session 0.1.0-beta.0
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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/auth/authorization.js +49 -0
- package/dist/auth/authorization.js.map +1 -0
- package/dist/auth/cookies.d.ts +27 -0
- package/dist/auth/cookies.js +53 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/csrf.js +18 -0
- package/dist/auth/csrf.js.map +1 -0
- package/dist/auth/endpoints.js +284 -0
- package/dist/auth/endpoints.js.map +1 -0
- package/dist/auth/misclassification.js +31 -0
- package/dist/auth/misclassification.js.map +1 -0
- package/dist/auth/runtime.d.ts +59 -0
- package/dist/auth/runtime.js +91 -0
- package/dist/auth/runtime.js.map +1 -0
- package/dist/auth/strategy.js +100 -0
- package/dist/auth/strategy.js.map +1 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +2 -0
- package/dist/exports/i18n.d.ts +3 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/proxy.d.ts +4 -0
- package/dist/exports/proxy.js +3 -0
- package/dist/exports/types.d.ts +2 -0
- package/dist/exports/types.js +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin/constants.d.ts +6 -0
- package/dist/plugin/constants.js +7 -0
- package/dist/plugin/constants.js.map +1 -0
- package/dist/plugin/registerTranslations.js +19 -0
- package/dist/plugin/registerTranslations.js.map +1 -0
- package/dist/plugin/resolveCollections.js +27 -0
- package/dist/plugin/resolveCollections.js.map +1 -0
- package/dist/plugin/selectCollections.js +54 -0
- package/dist/plugin/selectCollections.js.map +1 -0
- package/dist/scope/proxy.d.ts +35 -0
- package/dist/scope/proxy.js +42 -0
- package/dist/scope/proxy.js.map +1 -0
- package/dist/scope/resolveAuthScope.d.ts +43 -0
- package/dist/scope/resolveAuthScope.js +43 -0
- package/dist/scope/resolveAuthScope.js.map +1 -0
- package/dist/translations/en.js +12 -0
- package/dist/translations/en.js.map +1 -0
- package/dist/translations/index.d.ts +14 -0
- package/dist/translations/index.js +27 -0
- package/dist/translations/index.js.map +1 -0
- package/dist/translations/keys.d.ts +13 -0
- package/dist/translations/keys.js +11 -0
- package/dist/translations/keys.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/package.json +124 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/auth/misclassification.ts
|
|
2
|
+
/**
|
|
3
|
+
* Warns, in development only, when a user who was just moved onto an isolated cookie can
|
|
4
|
+
* also reach the admin panel.
|
|
5
|
+
*
|
|
6
|
+
* The `isolate` predicate is written by the project, and getting it wrong is silent: an
|
|
7
|
+
* admin classified as a frontend user lands in the wrong cookie, the panel never sees the
|
|
8
|
+
* session, and nothing errors. The two answers are only ever compared here, at login, where
|
|
9
|
+
* a user is being written to a cookie in the first place. Checking on every request would
|
|
10
|
+
* cost an access call per request to say the same thing.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `canAccessAdmin`'s first branch: the gate is `access.admin` on the collection the
|
|
13
|
+
* user belongs to. Collections that do not define one cannot report anything, so they are
|
|
14
|
+
* skipped rather than guessed about.
|
|
15
|
+
*/
|
|
16
|
+
const warnIfAdminMisclassified = async ({ collection, cookieName, entry, req, user }) => {
|
|
17
|
+
if (cookieName !== entry.cookieName || !user || process.env.NODE_ENV === "production") return;
|
|
18
|
+
const adminAccess = collection.config.access?.admin;
|
|
19
|
+
if (!adminAccess) return;
|
|
20
|
+
const previous = req.user;
|
|
21
|
+
try {
|
|
22
|
+
req.user = user;
|
|
23
|
+
if (await adminAccess({ req })) req.payload.logger.warn(`@10x-media/dual-session: user "${user.id}" passes ${collection.config.slug}.access.admin, but \`isolate\` sent their session to the "${entry.cookieName}" cookie, which the admin panel does not read. They will not be able to sign in to it. Check the predicate.`);
|
|
24
|
+
} catch {} finally {
|
|
25
|
+
req.user = previous;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
export { warnIfAdminMisclassified };
|
|
30
|
+
|
|
31
|
+
//# sourceMappingURL=misclassification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"misclassification.js","names":[],"sources":["../../src/auth/misclassification.ts"],"sourcesContent":["import type { Collection, PayloadRequest, TypedUser } from 'payload'\n\nimport type { ResolvedIsolatedCollection } from '../types'\n\n/**\n * Warns, in development only, when a user who was just moved onto an isolated cookie can\n * also reach the admin panel.\n *\n * The `isolate` predicate is written by the project, and getting it wrong is silent: an\n * admin classified as a frontend user lands in the wrong cookie, the panel never sees the\n * session, and nothing errors. The two answers are only ever compared here, at login, where\n * a user is being written to a cookie in the first place. Checking on every request would\n * cost an access call per request to say the same thing.\n *\n * Mirrors `canAccessAdmin`'s first branch: the gate is `access.admin` on the collection the\n * user belongs to. Collections that do not define one cannot report anything, so they are\n * skipped rather than guessed about.\n */\nexport const warnIfAdminMisclassified = async ({\n\tcollection,\n\tcookieName,\n\tentry,\n\treq,\n\tuser,\n}: {\n\tcollection: Collection\n\t/** The cookie the login was actually written to. */\n\tcookieName: string\n\tentry: ResolvedIsolatedCollection\n\treq: PayloadRequest\n\tuser: null | TypedUser | undefined\n}): Promise<void> => {\n\t// biome-ignore lint/plugin/noProcessEnv: a development-only diagnostic, matching how Payload gates its own\n\tif (cookieName !== entry.cookieName || !user || process.env.NODE_ENV === 'production') {\n\t\treturn\n\t}\n\n\tconst adminAccess = collection.config.access?.admin\n\n\tif (!adminAccess) {\n\t\treturn\n\t}\n\n\t// `access.admin` reads its subject from `req.user`, and the login request itself carries\n\t// whoever asked for it (usually nobody). The request object is this call's alone, so\n\t// standing the new user in it for the duration is the least invasive way to ask.\n\tconst previous = req.user\n\n\ttry {\n\t\treq.user = user\n\t\tif (await adminAccess({ req })) {\n\t\t\treq.payload.logger.warn(\n\t\t\t\t`@10x-media/dual-session: user \"${user.id}\" passes ${collection.config.slug}.access.admin, but \\`isolate\\` sent their session to the \"${entry.cookieName}\" cookie, which the admin panel does not read. They will not be able to sign in to it. Check the predicate.`\n\t\t\t)\n\t\t}\n\t} catch {\n\t\t// An access function that throws is refusing, which is the classification we expected.\n\t} finally {\n\t\treq.user = previous\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkBA,MAAa,2BAA2B,OAAO,EAC9C,YACA,YACA,OACA,KACA,WAQoB;CAEpB,IAAI,eAAe,MAAM,cAAc,CAAC,QAAQ,QAAQ,IAAI,aAAa,cACxE;CAGD,MAAM,cAAc,WAAW,OAAO,QAAQ;CAE9C,IAAI,CAAC,aACJ;CAMD,MAAM,WAAW,IAAI;CAErB,IAAI;EACH,IAAI,OAAO;EACX,IAAI,MAAM,YAAY,EAAE,IAAI,CAAC,GAC5B,IAAI,QAAQ,OAAO,KAClB,kCAAkC,KAAK,GAAG,WAAW,WAAW,OAAO,KAAK,4DAA4D,MAAM,WAAW,4GAC1J;CAEF,QAAQ,CAER,UAAU;EACT,IAAI,OAAO;CACZ;AACD"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CollectionSlug, Payload, TypedUser } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/auth/runtime.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The cookie name this collection's sessions live in, or `undefined` when the collection
|
|
6
|
+
* is not isolated (and therefore still uses the shared `${cookiePrefix}-token`).
|
|
7
|
+
*
|
|
8
|
+
* Resolved from the plugin's registered options rather than recomputed, so a `cookieName`
|
|
9
|
+
* override is honoured and callers never hardcode the name.
|
|
10
|
+
*
|
|
11
|
+
* @throws when the collection has an `isolate` predicate and no `user` is passed, because
|
|
12
|
+
* then the name is a function of the user rather than of the collection.
|
|
13
|
+
*/
|
|
14
|
+
declare const resolveIsolatedCookieName: ({
|
|
15
|
+
collection,
|
|
16
|
+
payload,
|
|
17
|
+
user
|
|
18
|
+
}: {
|
|
19
|
+
collection: CollectionSlug;
|
|
20
|
+
payload: Payload; /** Required when the collection is configured with an `isolate` predicate. */
|
|
21
|
+
user?: null | TypedUser;
|
|
22
|
+
}) => string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Builds the `Set-Cookie` header value that logs a user into an isolated collection.
|
|
25
|
+
*
|
|
26
|
+
* This is the replacement for Payload's `generatePayloadCookie` in code that mints its own
|
|
27
|
+
* token (an OAuth callback, a server action, a custom login route). Those write the shared
|
|
28
|
+
* `${cookiePrefix}-token` directly, which both bypasses the isolation and overwrites
|
|
29
|
+
* whatever admin session the visitor is holding.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* const { token } = await jwtSign({ fieldsToSign, secret, tokenExpiration })
|
|
33
|
+
*
|
|
34
|
+
* headers.append(
|
|
35
|
+
* 'Set-Cookie',
|
|
36
|
+
* generateIsolatedAuthCookie({ collection: 'customers', payload, token }),
|
|
37
|
+
* )
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Pass `user` whenever the collection is configured with an `isolate` predicate: there the
|
|
41
|
+
* cookie is a function of the user, and the call throws rather than pick one blindly.
|
|
42
|
+
*
|
|
43
|
+
* @throws when the collection is not one this plugin isolates, because silently writing the
|
|
44
|
+
* shared cookie instead would reintroduce exactly the bug the plugin exists to fix.
|
|
45
|
+
*/
|
|
46
|
+
declare const generateIsolatedAuthCookie: ({
|
|
47
|
+
collection,
|
|
48
|
+
payload,
|
|
49
|
+
token,
|
|
50
|
+
user
|
|
51
|
+
}: {
|
|
52
|
+
collection: CollectionSlug;
|
|
53
|
+
payload: Payload;
|
|
54
|
+
token: string; /** Required when the collection is configured with an `isolate` predicate. */
|
|
55
|
+
user?: null | TypedUser;
|
|
56
|
+
}) => string;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { generateIsolatedAuthCookie, resolveIsolatedCookieName };
|
|
59
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { generateIsolatedCookie, getSharedCookieName, resolveSlotCookieName } from "./cookies.js";
|
|
2
|
+
import { PLUGIN_SLUG } from "../plugin/constants.js";
|
|
3
|
+
import { resolveCollections } from "../plugin/resolveCollections.js";
|
|
4
|
+
//#region src/auth/runtime.ts
|
|
5
|
+
const findOptions = (payload) => payload.config.plugins?.find((plugin) => plugin.slug === PLUGIN_SLUG)?.options;
|
|
6
|
+
const findEntry = (payload, collection) => {
|
|
7
|
+
const options = findOptions(payload);
|
|
8
|
+
if (!options || options.disabled === true) return;
|
|
9
|
+
return resolveCollections({
|
|
10
|
+
collections: options.collections,
|
|
11
|
+
cookiePrefix: payload.config.cookiePrefix
|
|
12
|
+
}).find((entry) => entry.slug === collection);
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Asks an entry which cookie a session belongs in, refusing to guess when it cannot know.
|
|
16
|
+
*
|
|
17
|
+
* An entry carrying an `isolate` predicate splits its collection's users across two
|
|
18
|
+
* cookies, so answering without the user would be a coin flip that silently signs someone
|
|
19
|
+
* into the wrong session. Better to say so.
|
|
20
|
+
*/
|
|
21
|
+
const slotFor = ({ collection, entry, payload, user }) => {
|
|
22
|
+
if (!entry.isolate) return entry.cookieName;
|
|
23
|
+
if (!user) throw new Error(`@10x-media/dual-session: "${collection}" splits its users across two cookies with an \`isolate\` predicate, so the user has to be passed for the right one to be picked.`);
|
|
24
|
+
return resolveSlotCookieName({
|
|
25
|
+
entry,
|
|
26
|
+
sharedName: getSharedCookieName(payload.config.cookiePrefix),
|
|
27
|
+
user
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The cookie name this collection's sessions live in, or `undefined` when the collection
|
|
32
|
+
* is not isolated (and therefore still uses the shared `${cookiePrefix}-token`).
|
|
33
|
+
*
|
|
34
|
+
* Resolved from the plugin's registered options rather than recomputed, so a `cookieName`
|
|
35
|
+
* override is honoured and callers never hardcode the name.
|
|
36
|
+
*
|
|
37
|
+
* @throws when the collection has an `isolate` predicate and no `user` is passed, because
|
|
38
|
+
* then the name is a function of the user rather than of the collection.
|
|
39
|
+
*/
|
|
40
|
+
const resolveIsolatedCookieName = ({ collection, payload, user }) => {
|
|
41
|
+
const entry = findEntry(payload, collection);
|
|
42
|
+
return entry ? slotFor({
|
|
43
|
+
collection,
|
|
44
|
+
entry,
|
|
45
|
+
payload,
|
|
46
|
+
user
|
|
47
|
+
}) : void 0;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Builds the `Set-Cookie` header value that logs a user into an isolated collection.
|
|
51
|
+
*
|
|
52
|
+
* This is the replacement for Payload's `generatePayloadCookie` in code that mints its own
|
|
53
|
+
* token (an OAuth callback, a server action, a custom login route). Those write the shared
|
|
54
|
+
* `${cookiePrefix}-token` directly, which both bypasses the isolation and overwrites
|
|
55
|
+
* whatever admin session the visitor is holding.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* const { token } = await jwtSign({ fieldsToSign, secret, tokenExpiration })
|
|
59
|
+
*
|
|
60
|
+
* headers.append(
|
|
61
|
+
* 'Set-Cookie',
|
|
62
|
+
* generateIsolatedAuthCookie({ collection: 'customers', payload, token }),
|
|
63
|
+
* )
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* Pass `user` whenever the collection is configured with an `isolate` predicate: there the
|
|
67
|
+
* cookie is a function of the user, and the call throws rather than pick one blindly.
|
|
68
|
+
*
|
|
69
|
+
* @throws when the collection is not one this plugin isolates, because silently writing the
|
|
70
|
+
* shared cookie instead would reintroduce exactly the bug the plugin exists to fix.
|
|
71
|
+
*/
|
|
72
|
+
const generateIsolatedAuthCookie = ({ collection, payload, token, user }) => {
|
|
73
|
+
const entry = findEntry(payload, collection);
|
|
74
|
+
if (!entry) throw new Error(`@10x-media/dual-session: "${collection}" is not an isolated collection, so it has no cookie of its own. Use Payload's \`generatePayloadCookie\` for it, or add it to the plugin's \`collections\`.`);
|
|
75
|
+
const registered = payload.collections[collection];
|
|
76
|
+
if (!registered) throw new Error(`@10x-media/dual-session: collection "${collection}" is not registered.`);
|
|
77
|
+
return generateIsolatedCookie({
|
|
78
|
+
authConfig: registered.config.auth,
|
|
79
|
+
name: slotFor({
|
|
80
|
+
collection,
|
|
81
|
+
entry,
|
|
82
|
+
payload,
|
|
83
|
+
user
|
|
84
|
+
}),
|
|
85
|
+
token
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
//#endregion
|
|
89
|
+
export { generateIsolatedAuthCookie, resolveIsolatedCookieName };
|
|
90
|
+
|
|
91
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.js","names":[],"sources":["../../src/auth/runtime.ts"],"sourcesContent":["import type { CollectionSlug, Payload, TypedUser } from 'payload'\n\nimport { PLUGIN_SLUG } from '../plugin/constants'\nimport { resolveCollections } from '../plugin/resolveCollections'\nimport type { DualSessionPluginOptions, ResolvedIsolatedCollection } from '../types'\nimport { generateIsolatedCookie, getSharedCookieName, resolveSlotCookieName } from './cookies'\n\nconst findOptions = (payload: Payload): DualSessionPluginOptions | undefined =>\n\tpayload.config.plugins?.find((plugin) => plugin.slug === PLUGIN_SLUG)?.options as\n\t\t| DualSessionPluginOptions\n\t\t| undefined\n\nconst findEntry = (\n\tpayload: Payload,\n\tcollection: CollectionSlug\n): ResolvedIsolatedCollection | undefined => {\n\tconst options = findOptions(payload)\n\tif (!options || options.disabled === true) {\n\t\treturn undefined\n\t}\n\n\treturn resolveCollections({\n\t\tcollections: options.collections,\n\t\tcookiePrefix: payload.config.cookiePrefix,\n\t}).find((entry) => entry.slug === collection)\n}\n\n/**\n * Asks an entry which cookie a session belongs in, refusing to guess when it cannot know.\n *\n * An entry carrying an `isolate` predicate splits its collection's users across two\n * cookies, so answering without the user would be a coin flip that silently signs someone\n * into the wrong session. Better to say so.\n */\nconst slotFor = ({\n\tcollection,\n\tentry,\n\tpayload,\n\tuser,\n}: {\n\tcollection: CollectionSlug\n\tentry: ResolvedIsolatedCollection\n\tpayload: Payload\n\tuser: null | TypedUser | undefined\n}): string => {\n\tif (!entry.isolate) {\n\t\treturn entry.cookieName\n\t}\n\n\tif (!user) {\n\t\tthrow new Error(\n\t\t\t`@10x-media/dual-session: \"${collection}\" splits its users across two cookies with an \\`isolate\\` predicate, so the user has to be passed for the right one to be picked.`\n\t\t)\n\t}\n\n\treturn resolveSlotCookieName({\n\t\tentry,\n\t\tsharedName: getSharedCookieName(payload.config.cookiePrefix),\n\t\tuser,\n\t}) as string\n}\n\n/**\n * The cookie name this collection's sessions live in, or `undefined` when the collection\n * is not isolated (and therefore still uses the shared `${cookiePrefix}-token`).\n *\n * Resolved from the plugin's registered options rather than recomputed, so a `cookieName`\n * override is honoured and callers never hardcode the name.\n *\n * @throws when the collection has an `isolate` predicate and no `user` is passed, because\n * then the name is a function of the user rather than of the collection.\n */\nexport const resolveIsolatedCookieName = ({\n\tcollection,\n\tpayload,\n\tuser,\n}: {\n\tcollection: CollectionSlug\n\tpayload: Payload\n\t/** Required when the collection is configured with an `isolate` predicate. */\n\tuser?: null | TypedUser\n}): string | undefined => {\n\tconst entry = findEntry(payload, collection)\n\n\treturn entry ? slotFor({ collection, entry, payload, user }) : undefined\n}\n\n/**\n * Builds the `Set-Cookie` header value that logs a user into an isolated collection.\n *\n * This is the replacement for Payload's `generatePayloadCookie` in code that mints its own\n * token (an OAuth callback, a server action, a custom login route). Those write the shared\n * `${cookiePrefix}-token` directly, which both bypasses the isolation and overwrites\n * whatever admin session the visitor is holding.\n *\n * ```ts\n * const { token } = await jwtSign({ fieldsToSign, secret, tokenExpiration })\n *\n * headers.append(\n * 'Set-Cookie',\n * generateIsolatedAuthCookie({ collection: 'customers', payload, token }),\n * )\n * ```\n *\n * Pass `user` whenever the collection is configured with an `isolate` predicate: there the\n * cookie is a function of the user, and the call throws rather than pick one blindly.\n *\n * @throws when the collection is not one this plugin isolates, because silently writing the\n * shared cookie instead would reintroduce exactly the bug the plugin exists to fix.\n */\nexport const generateIsolatedAuthCookie = ({\n\tcollection,\n\tpayload,\n\ttoken,\n\tuser,\n}: {\n\tcollection: CollectionSlug\n\tpayload: Payload\n\ttoken: string\n\t/** Required when the collection is configured with an `isolate` predicate. */\n\tuser?: null | TypedUser\n}): string => {\n\tconst entry = findEntry(payload, collection)\n\n\tif (!entry) {\n\t\tthrow new Error(\n\t\t\t`@10x-media/dual-session: \"${collection}\" is not an isolated collection, so it has no cookie of its own. Use Payload's \\`generatePayloadCookie\\` for it, or add it to the plugin's \\`collections\\`.`\n\t\t)\n\t}\n\n\tconst registered = payload.collections[collection]\n\n\tif (!registered) {\n\t\tthrow new Error(`@10x-media/dual-session: collection \"${collection}\" is not registered.`)\n\t}\n\n\treturn generateIsolatedCookie({\n\t\tauthConfig: registered.config.auth,\n\t\tname: slotFor({ collection, entry, payload, user }),\n\t\ttoken,\n\t})\n}\n"],"mappings":";;;;AAOA,MAAM,eAAe,YACpB,QAAQ,OAAO,SAAS,MAAM,WAAW,OAAO,SAAS,WAAW,GAAG;AAIxE,MAAM,aACL,SACA,eAC4C;CAC5C,MAAM,UAAU,YAAY,OAAO;CACnC,IAAI,CAAC,WAAW,QAAQ,aAAa,MACpC;CAGD,OAAO,mBAAmB;EACzB,aAAa,QAAQ;EACrB,cAAc,QAAQ,OAAO;CAC9B,CAAC,EAAE,MAAM,UAAU,MAAM,SAAS,UAAU;AAC7C;;;;;;;;AASA,MAAM,WAAW,EAChB,YACA,OACA,SACA,WAMa;CACb,IAAI,CAAC,MAAM,SACV,OAAO,MAAM;CAGd,IAAI,CAAC,MACJ,MAAM,IAAI,MACT,6BAA6B,WAAW,kIACzC;CAGD,OAAO,sBAAsB;EAC5B;EACA,YAAY,oBAAoB,QAAQ,OAAO,YAAY;EAC3D;CACD,CAAC;AACF;;;;;;;;;;;AAYA,MAAa,6BAA6B,EACzC,YACA,SACA,WAMyB;CACzB,MAAM,QAAQ,UAAU,SAAS,UAAU;CAE3C,OAAO,QAAQ,QAAQ;EAAE;EAAY;EAAO;EAAS;CAAK,CAAC,IAAI,KAAA;AAChE;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,MAAa,8BAA8B,EAC1C,YACA,SACA,OACA,WAOa;CACb,MAAM,QAAQ,UAAU,SAAS,UAAU;CAE3C,IAAI,CAAC,OACJ,MAAM,IAAI,MACT,6BAA6B,WAAW,4JACzC;CAGD,MAAM,aAAa,QAAQ,YAAY;CAEvC,IAAI,CAAC,YACJ,MAAM,IAAI,MAAM,wCAAwC,WAAW,qBAAqB;CAGzF,OAAO,uBAAuB;EAC7B,YAAY,WAAW,OAAO;EAC9B,MAAM,QAAQ;GAAE;GAAY;GAAO;GAAS;EAAK,CAAC;EAClD;CACD,CAAC;AACF"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { hasPrecedingAuthorization } from "./authorization.js";
|
|
2
|
+
import { getSharedCookieName } from "./cookies.js";
|
|
3
|
+
import { isCookieAuthAllowed } from "./csrf.js";
|
|
4
|
+
import { parseCookies } from "payload/shared";
|
|
5
|
+
import { jwtVerify } from "jose";
|
|
6
|
+
//#region src/auth/strategy.ts
|
|
7
|
+
const NO_USER = { user: null };
|
|
8
|
+
const verifyToken = async ({ payload, token }) => {
|
|
9
|
+
const { payload: claims } = await jwtVerify(token, new TextEncoder().encode(payload.secret));
|
|
10
|
+
return claims;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* True when `cookieName` holds a signature-valid token minted for `collectionSlug`.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately stops at the signature and the `collection` claim, with no database read.
|
|
16
|
+
* This only ever decides *which* session takes precedence; the winning strategy still
|
|
17
|
+
* does the full lookup, so a token for a deleted user resolves to no user rather than
|
|
18
|
+
* to the wrong one.
|
|
19
|
+
*/
|
|
20
|
+
const hasValidSessionCookie = async ({ collectionSlug, cookieName, headers, payload }) => {
|
|
21
|
+
const token = parseCookies(headers).get(cookieName);
|
|
22
|
+
if (!token) return false;
|
|
23
|
+
try {
|
|
24
|
+
return (await verifyToken({
|
|
25
|
+
payload,
|
|
26
|
+
token
|
|
27
|
+
})).collection === collectionSlug;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Authenticates a request against a collection-scoped cookie instead of the shared
|
|
34
|
+
* `${cookiePrefix}-token`. Mirrors Payload's built-in JWT strategy (verification,
|
|
35
|
+
* email verification gate, session `sid` check) so an isolated collection behaves
|
|
36
|
+
* exactly like a normal auth collection. It just reads a different cookie.
|
|
37
|
+
*/
|
|
38
|
+
const createIsolatedAuthStrategy = ({ adminSessionPriority, cookieName, higherPriority, scopeHeader, scopes, slug }) => {
|
|
39
|
+
const name = `${slug}-dual-session`;
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
authenticate: async ({ headers, isGraphQL = false, payload, strategyName }) => {
|
|
43
|
+
const token = parseCookies(headers).get(cookieName);
|
|
44
|
+
if (!token) return NO_USER;
|
|
45
|
+
if (hasPrecedingAuthorization({
|
|
46
|
+
headers,
|
|
47
|
+
payload,
|
|
48
|
+
slug
|
|
49
|
+
})) return NO_USER;
|
|
50
|
+
if (!isCookieAuthAllowed({
|
|
51
|
+
headers,
|
|
52
|
+
payload
|
|
53
|
+
})) return NO_USER;
|
|
54
|
+
for (const higher of higherPriority) if (await hasValidSessionCookie({
|
|
55
|
+
collectionSlug: higher.slug,
|
|
56
|
+
cookieName: higher.cookieName,
|
|
57
|
+
headers,
|
|
58
|
+
payload
|
|
59
|
+
})) return NO_USER;
|
|
60
|
+
const scope = headers.get(scopeHeader);
|
|
61
|
+
if (scope) {
|
|
62
|
+
if (!scopes.includes(scope)) return NO_USER;
|
|
63
|
+
} else if (adminSessionPriority && await hasValidSessionCookie({
|
|
64
|
+
collectionSlug: payload.config.admin.user,
|
|
65
|
+
cookieName: getSharedCookieName(payload.config.cookiePrefix),
|
|
66
|
+
headers,
|
|
67
|
+
payload
|
|
68
|
+
})) return NO_USER;
|
|
69
|
+
try {
|
|
70
|
+
const claims = await verifyToken({
|
|
71
|
+
payload,
|
|
72
|
+
token
|
|
73
|
+
});
|
|
74
|
+
if (claims.collection !== slug || !claims.id) return NO_USER;
|
|
75
|
+
const collection = payload.collections[slug];
|
|
76
|
+
if (!collection) return NO_USER;
|
|
77
|
+
const user = await payload.findByID({
|
|
78
|
+
id: claims.id,
|
|
79
|
+
collection: slug,
|
|
80
|
+
depth: isGraphQL ? 0 : collection.config.auth.depth
|
|
81
|
+
});
|
|
82
|
+
if (!user) return NO_USER;
|
|
83
|
+
if (collection.config.auth.verify && !user._verified) return NO_USER;
|
|
84
|
+
if (collection.config.auth.useSessions) {
|
|
85
|
+
if (!(user.sessions ?? []).find(({ id }) => id === claims.sid) || !claims.sid) return NO_USER;
|
|
86
|
+
user._sid = claims.sid;
|
|
87
|
+
}
|
|
88
|
+
user.collection = slug;
|
|
89
|
+
user._strategy = strategyName ?? name;
|
|
90
|
+
return { user };
|
|
91
|
+
} catch {
|
|
92
|
+
return NO_USER;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
export { createIsolatedAuthStrategy };
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=strategy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strategy.js","names":[],"sources":["../../src/auth/strategy.ts"],"sourcesContent":["import { jwtVerify } from 'jose'\nimport type { AuthStrategyResult, CollectionSlug, Payload } from 'payload'\nimport { parseCookies } from 'payload/shared'\nimport type { AuthScope, AuthStrategy } from '../types'\nimport { hasPrecedingAuthorization } from './authorization'\nimport { getSharedCookieName } from './cookies'\nimport { isCookieAuthAllowed } from './csrf'\n\nconst NO_USER: AuthStrategyResult = { user: null }\n\ntype AuthenticatedUser = NonNullable<AuthStrategyResult['user']>\n\n/**\n * Fields Payload sets on the authenticated user that are not part of the generated\n * user type but are read by core (`_sid` by `refreshOperation`, `_verified` by the\n * JWT strategy).\n */\ntype UserWithSession = AuthenticatedUser & {\n\t_sid?: string\n\t_verified?: boolean\n\tsessions?: { id: string }[]\n}\n\ntype TokenClaims = {\n\tcollection?: string\n\tid?: number | string\n\tsid?: string\n}\n\nconst verifyToken = async ({ payload, token }: { payload: Payload; token: string }) => {\n\tconst secretKey = new TextEncoder().encode(payload.secret)\n\tconst { payload: claims } = await jwtVerify<TokenClaims>(token, secretKey)\n\treturn claims\n}\n\n/**\n * True when `cookieName` holds a signature-valid token minted for `collectionSlug`.\n *\n * Deliberately stops at the signature and the `collection` claim, with no database read.\n * This only ever decides *which* session takes precedence; the winning strategy still\n * does the full lookup, so a token for a deleted user resolves to no user rather than\n * to the wrong one.\n */\nconst hasValidSessionCookie = async ({\n\tcollectionSlug,\n\tcookieName,\n\theaders,\n\tpayload,\n}: {\n\tcollectionSlug: string\n\tcookieName: string\n\theaders: Headers\n\tpayload: Payload\n}) => {\n\tconst token = parseCookies(headers).get(cookieName)\n\tif (!token) {\n\t\treturn false\n\t}\n\n\ttry {\n\t\tconst claims = await verifyToken({ payload, token })\n\t\treturn claims.collection === collectionSlug\n\t} catch {\n\t\treturn false\n\t}\n}\n\n/**\n * Authenticates a request against a collection-scoped cookie instead of the shared\n * `${cookiePrefix}-token`. Mirrors Payload's built-in JWT strategy (verification,\n * email verification gate, session `sid` check) so an isolated collection behaves\n * exactly like a normal auth collection. It just reads a different cookie.\n */\nexport const createIsolatedAuthStrategy = ({\n\tadminSessionPriority,\n\tcookieName,\n\thigherPriority,\n\tscopeHeader,\n\tscopes,\n\tslug,\n}: {\n\tadminSessionPriority: boolean\n\tcookieName: string\n\t/**\n\t * Isolated collections ranked above this one. Payload builds its strategy chain from\n\t * the order collections appear in the config, which is not a meaningful priority,\n\t * so when a visitor holds sessions for several isolated collections at once, this\n\t * decides which one wins, independently of config order.\n\t */\n\thigherPriority: { cookieName: string; slug: CollectionSlug }[]\n\tscopeHeader: string\n\tscopes: AuthScope[]\n\tslug: CollectionSlug\n}): AuthStrategy => {\n\tconst name = `${slug}-dual-session`\n\n\treturn {\n\t\tname,\n\t\tauthenticate: async ({ headers, isGraphQL = false, payload, strategyName }) => {\n\t\t\tconst token = parseCookies(headers).get(cookieName)\n\n\t\t\t// Nothing to do, and importantly this also stops this strategy from ever\n\t\t\t// interfering with requests that only carry the admin cookie.\n\t\t\tif (!token) {\n\t\t\t\treturn NO_USER\n\t\t\t}\n\n\t\t\tif (hasPrecedingAuthorization({ headers, payload, slug })) {\n\t\t\t\treturn NO_USER\n\t\t\t}\n\n\t\t\tif (!isCookieAuthAllowed({ headers, payload })) {\n\t\t\t\treturn NO_USER\n\t\t\t}\n\n\t\t\tfor (const higher of higherPriority) {\n\t\t\t\tconst outranked = await hasValidSessionCookie({\n\t\t\t\t\tcollectionSlug: higher.slug,\n\t\t\t\t\tcookieName: higher.cookieName,\n\t\t\t\t\theaders,\n\t\t\t\t\tpayload,\n\t\t\t\t})\n\n\t\t\t\tif (outranked) {\n\t\t\t\t\treturn NO_USER\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst scope = headers.get(scopeHeader)\n\n\t\t\tif (scope) {\n\t\t\t\t// The proxy told us what this request is, so trust it.\n\t\t\t\tif (!scopes.includes(scope as AuthScope)) {\n\t\t\t\t\treturn NO_USER\n\t\t\t\t}\n\t\t\t} else if (\n\t\t\t\tadminSessionPriority &&\n\t\t\t\t(await hasValidSessionCookie({\n\t\t\t\t\tcollectionSlug: payload.config.admin.user,\n\t\t\t\t\tcookieName: getSharedCookieName(payload.config.cookiePrefix),\n\t\t\t\t\theaders,\n\t\t\t\t\tpayload,\n\t\t\t\t}))\n\t\t\t) {\n\t\t\t\t// No proxy installed. Never let a frontend session shadow a live admin\n\t\t\t\t// session, or the admin panel becomes unreachable.\n\t\t\t\treturn NO_USER\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst claims = await verifyToken({ payload, token })\n\n\t\t\t\t// The token must belong to this collection. Guards against a token minted\n\t\t\t\t// for another collection being replayed into this cookie.\n\t\t\t\tif (claims.collection !== slug || !claims.id) {\n\t\t\t\t\treturn NO_USER\n\t\t\t\t}\n\n\t\t\t\tconst collection = payload.collections[slug]\n\t\t\t\tif (!collection) {\n\t\t\t\t\treturn NO_USER\n\t\t\t\t}\n\n\t\t\t\tconst user = (await payload.findByID({\n\t\t\t\t\tid: claims.id,\n\t\t\t\t\tcollection: slug,\n\t\t\t\t\tdepth: isGraphQL ? 0 : collection.config.auth.depth,\n\t\t\t\t})) as UserWithSession | null\n\n\t\t\t\tif (!user) {\n\t\t\t\t\treturn NO_USER\n\t\t\t\t}\n\t\t\t\tif (collection.config.auth.verify && !user._verified) {\n\t\t\t\t\treturn NO_USER\n\t\t\t\t}\n\n\t\t\t\tif (collection.config.auth.useSessions) {\n\t\t\t\t\tconst session = (user.sessions ?? []).find(({ id }) => id === claims.sid)\n\t\t\t\t\tif (!session || !claims.sid) {\n\t\t\t\t\t\treturn NO_USER\n\t\t\t\t\t}\n\t\t\t\t\tuser._sid = claims.sid\n\t\t\t\t}\n\n\t\t\t\tuser.collection = slug as UserWithSession['collection']\n\t\t\t\tuser._strategy = strategyName ?? name\n\n\t\t\t\treturn { user }\n\t\t\t} catch {\n\t\t\t\treturn NO_USER\n\t\t\t}\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;AAQA,MAAM,UAA8B,EAAE,MAAM,KAAK;AAqBjD,MAAM,cAAc,OAAO,EAAE,SAAS,YAAiD;CAEtF,MAAM,EAAE,SAAS,WAAW,MAAM,UAAuB,OADvC,IAAI,YAAY,EAAE,OAAO,QAAQ,MACqB,CAAC;CACzE,OAAO;AACR;;;;;;;;;AAUA,MAAM,wBAAwB,OAAO,EACpC,gBACA,YACA,SACA,cAMK;CACL,MAAM,QAAQ,aAAa,OAAO,EAAE,IAAI,UAAU;CAClD,IAAI,CAAC,OACJ,OAAO;CAGR,IAAI;EAEH,QAAO,MADc,YAAY;GAAE;GAAS;EAAM,CAAC,GACrC,eAAe;CAC9B,QAAQ;EACP,OAAO;CACR;AACD;;;;;;;AAQA,MAAa,8BAA8B,EAC1C,sBACA,YACA,gBACA,aACA,QACA,WAcmB;CACnB,MAAM,OAAO,GAAG,KAAK;CAErB,OAAO;EACN;EACA,cAAc,OAAO,EAAE,SAAS,YAAY,OAAO,SAAS,mBAAmB;GAC9E,MAAM,QAAQ,aAAa,OAAO,EAAE,IAAI,UAAU;GAIlD,IAAI,CAAC,OACJ,OAAO;GAGR,IAAI,0BAA0B;IAAE;IAAS;IAAS;GAAK,CAAC,GACvD,OAAO;GAGR,IAAI,CAAC,oBAAoB;IAAE;IAAS;GAAQ,CAAC,GAC5C,OAAO;GAGR,KAAK,MAAM,UAAU,gBAQpB,IAAI,MAPoB,sBAAsB;IAC7C,gBAAgB,OAAO;IACvB,YAAY,OAAO;IACnB;IACA;GACD,CAAC,GAGA,OAAO;GAIT,MAAM,QAAQ,QAAQ,IAAI,WAAW;GAErC,IAAI;QAEC,CAAC,OAAO,SAAS,KAAkB,GACtC,OAAO;GAAA,OAEF,IACN,wBACC,MAAM,sBAAsB;IAC5B,gBAAgB,QAAQ,OAAO,MAAM;IACrC,YAAY,oBAAoB,QAAQ,OAAO,YAAY;IAC3D;IACA;GACD,CAAC,GAID,OAAO;GAGR,IAAI;IACH,MAAM,SAAS,MAAM,YAAY;KAAE;KAAS;IAAM,CAAC;IAInD,IAAI,OAAO,eAAe,QAAQ,CAAC,OAAO,IACzC,OAAO;IAGR,MAAM,aAAa,QAAQ,YAAY;IACvC,IAAI,CAAC,YACJ,OAAO;IAGR,MAAM,OAAQ,MAAM,QAAQ,SAAS;KACpC,IAAI,OAAO;KACX,YAAY;KACZ,OAAO,YAAY,IAAI,WAAW,OAAO,KAAK;IAC/C,CAAC;IAED,IAAI,CAAC,MACJ,OAAO;IAER,IAAI,WAAW,OAAO,KAAK,UAAU,CAAC,KAAK,WAC1C,OAAO;IAGR,IAAI,WAAW,OAAO,KAAK,aAAa;KAEvC,IAAI,EADa,KAAK,YAAY,CAAC,GAAG,MAAM,EAAE,SAAS,OAAO,OAAO,GAC1D,KAAK,CAAC,OAAO,KACvB,OAAO;KAER,KAAK,OAAO,OAAO;IACpB;IAEA,KAAK,aAAa;IAClB,KAAK,YAAY,gBAAgB;IAEjC,OAAO,EAAE,KAAK;GACf,QAAQ;IACP,OAAO;GACR;EACD;CACD;AACD"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { AuthScope } from "../types.js";
|
|
2
|
+
import { AUTH_SCOPE_HEADER, resolveAuthScope } from "../scope/resolveAuthScope.js";
|
|
3
|
+
import { AuthScopeProxyOptions, createAuthScopeProxy } from "../scope/proxy.js";
|
|
4
|
+
export { AUTH_SCOPE_HEADER, type AuthScope, type AuthScopeProxyOptions, createAuthScopeProxy, resolveAuthScope };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AuthScope, DualSessionPluginOptions, IsolatedCollection } from "./types.js";
|
|
2
|
+
import { AUTH_SCOPE_HEADER, resolveAuthScope } from "./scope/resolveAuthScope.js";
|
|
3
|
+
import { generateIsolatedCookie, getIsolatedCookieName } from "./auth/cookies.js";
|
|
4
|
+
import { generateIsolatedAuthCookie, resolveIsolatedCookieName } from "./auth/runtime.js";
|
|
5
|
+
import { PLUGIN_SLUG } from "./plugin/constants.js";
|
|
6
|
+
|
|
7
|
+
//#region src/index.d.ts
|
|
8
|
+
declare module 'payload' {
|
|
9
|
+
interface RegisteredPlugins {
|
|
10
|
+
'@10x-media/dual-session': DualSessionPluginOptions;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Moves the listed auth collections off Payload's single, config-wide
|
|
15
|
+
* `${cookiePrefix}-token` cookie and onto their own cookies, so a login on the website
|
|
16
|
+
* and a login in the admin panel can coexist.
|
|
17
|
+
*
|
|
18
|
+
* Payload signs every collection's token into that one shared cookie, so a frontend
|
|
19
|
+
* login overwrites the admin panel's session and vice versa. This plugin gives each
|
|
20
|
+
* listed collection its own cookie by:
|
|
21
|
+
*
|
|
22
|
+
* 1. shadowing the collection's built-in auth endpoints with replacements that read and
|
|
23
|
+
* write the scoped cookie (Payload matches collection-declared endpoints before its
|
|
24
|
+
* own built-ins), and
|
|
25
|
+
* 2. registering an auth strategy on the collection that authenticates from that cookie.
|
|
26
|
+
*
|
|
27
|
+
* The admin collection (`admin.user`) keeps the shared cookie. It may only be listed with
|
|
28
|
+
* an `isolate` predicate, which moves the users it selects onto a second cookie and leaves
|
|
29
|
+
* everyone else (the admins) exactly where core put them.
|
|
30
|
+
*
|
|
31
|
+
* For `req.user` to be fully deterministic, pair this with the auth-scope proxy.
|
|
32
|
+
* See `@10x-media/dual-session/proxy`.
|
|
33
|
+
*/
|
|
34
|
+
declare const dualSession: (options: DualSessionPluginOptions) => import("payload").Plugin;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { AUTH_SCOPE_HEADER, type AuthScope, type DualSessionPluginOptions, type IsolatedCollection, PLUGIN_SLUG, type DualSessionPluginOptions as PluginOptions, dualSession, generateIsolatedAuthCookie, generateIsolatedCookie, getIsolatedCookieName, resolveAuthScope, resolveIsolatedCookieName };
|
|
37
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { generateIsolatedCookie, getIsolatedCookieName } from "./auth/cookies.js";
|
|
2
|
+
import { buildIsolatedAuthEndpoints } from "./auth/endpoints.js";
|
|
3
|
+
import { createIsolatedAuthStrategy } from "./auth/strategy.js";
|
|
4
|
+
import { PLUGIN_SLUG } from "./plugin/constants.js";
|
|
5
|
+
import { registerTranslations } from "./plugin/registerTranslations.js";
|
|
6
|
+
import { resolveAdminUserSlug, selectCollections } from "./plugin/selectCollections.js";
|
|
7
|
+
import { AUTH_SCOPE_HEADER, resolveAuthScope } from "./scope/resolveAuthScope.js";
|
|
8
|
+
import { generateIsolatedAuthCookie, resolveIsolatedCookieName } from "./auth/runtime.js";
|
|
9
|
+
import { definePlugin } from "payload";
|
|
10
|
+
//#region src/index.ts
|
|
11
|
+
/**
|
|
12
|
+
* Moves the listed auth collections off Payload's single, config-wide
|
|
13
|
+
* `${cookiePrefix}-token` cookie and onto their own cookies, so a login on the website
|
|
14
|
+
* and a login in the admin panel can coexist.
|
|
15
|
+
*
|
|
16
|
+
* Payload signs every collection's token into that one shared cookie, so a frontend
|
|
17
|
+
* login overwrites the admin panel's session and vice versa. This plugin gives each
|
|
18
|
+
* listed collection its own cookie by:
|
|
19
|
+
*
|
|
20
|
+
* 1. shadowing the collection's built-in auth endpoints with replacements that read and
|
|
21
|
+
* write the scoped cookie (Payload matches collection-declared endpoints before its
|
|
22
|
+
* own built-ins), and
|
|
23
|
+
* 2. registering an auth strategy on the collection that authenticates from that cookie.
|
|
24
|
+
*
|
|
25
|
+
* The admin collection (`admin.user`) keeps the shared cookie. It may only be listed with
|
|
26
|
+
* an `isolate` predicate, which moves the users it selects onto a second cookie and leaves
|
|
27
|
+
* everyone else (the admins) exactly where core put them.
|
|
28
|
+
*
|
|
29
|
+
* For `req.user` to be fully deterministic, pair this with the auth-scope proxy.
|
|
30
|
+
* See `@10x-media/dual-session/proxy`.
|
|
31
|
+
*/
|
|
32
|
+
const dualSession = definePlugin({
|
|
33
|
+
slug: PLUGIN_SLUG,
|
|
34
|
+
plugin: ({ config: incomingConfig, plugins: _plugins, ...options }) => {
|
|
35
|
+
if (options.disabled === true) return incomingConfig;
|
|
36
|
+
const cookiePrefix = incomingConfig.cookiePrefix ?? "payload";
|
|
37
|
+
const scopeHeader = options.scopeHeader ?? "x-payload-auth-scope";
|
|
38
|
+
const adminSessionPriority = options.adminSessionPriority ?? true;
|
|
39
|
+
const incomingCollections = incomingConfig.collections ?? [];
|
|
40
|
+
const { collections: isolated, warnings } = selectCollections({
|
|
41
|
+
adminUserSlug: resolveAdminUserSlug(incomingConfig),
|
|
42
|
+
collections: options.collections,
|
|
43
|
+
cookiePrefix,
|
|
44
|
+
incoming: incomingCollections
|
|
45
|
+
});
|
|
46
|
+
const collections = incomingCollections.map((collection) => {
|
|
47
|
+
const match = isolated.find(({ slug }) => slug === collection.slug);
|
|
48
|
+
if (!match) return collection;
|
|
49
|
+
const auth = typeof collection.auth === "object" ? collection.auth : {};
|
|
50
|
+
const strategy = createIsolatedAuthStrategy({
|
|
51
|
+
adminSessionPriority,
|
|
52
|
+
cookieName: match.cookieName,
|
|
53
|
+
higherPriority: isolated.slice(0, isolated.indexOf(match)).map(({ cookieName, slug }) => ({
|
|
54
|
+
cookieName,
|
|
55
|
+
slug
|
|
56
|
+
})),
|
|
57
|
+
scopeHeader,
|
|
58
|
+
scopes: match.scopes,
|
|
59
|
+
slug: match.slug
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
...collection,
|
|
63
|
+
auth: {
|
|
64
|
+
...auth,
|
|
65
|
+
strategies: [...auth.strategies ?? [], strategy]
|
|
66
|
+
},
|
|
67
|
+
endpoints: collection.endpoints === false ? false : [...buildIsolatedAuthEndpoints({ entry: match }), ...collection.endpoints ?? []]
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
registerTranslations(incomingConfig, options.translations);
|
|
71
|
+
const config = {
|
|
72
|
+
...incomingConfig,
|
|
73
|
+
collections
|
|
74
|
+
};
|
|
75
|
+
if (warnings.length > 0) {
|
|
76
|
+
const priorOnInit = incomingConfig.onInit;
|
|
77
|
+
config.onInit = async (payload) => {
|
|
78
|
+
for (const warning of warnings) payload.logger.warn(warning);
|
|
79
|
+
await priorOnInit?.(payload);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
//#endregion
|
|
86
|
+
export { AUTH_SCOPE_HEADER, PLUGIN_SLUG, dualSession, generateIsolatedAuthCookie, generateIsolatedCookie, getIsolatedCookieName, resolveAuthScope, resolveIsolatedCookieName };
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { type CollectionConfig, type Config, definePlugin } from 'payload'\n\nimport { buildIsolatedAuthEndpoints } from './auth/endpoints'\nimport { createIsolatedAuthStrategy } from './auth/strategy'\nimport { PLUGIN_SLUG } from './plugin/constants'\nimport { registerTranslations } from './plugin/registerTranslations'\nimport { resolveAdminUserSlug, selectCollections } from './plugin/selectCollections'\nimport { AUTH_SCOPE_HEADER } from './scope/resolveAuthScope'\nimport type { DualSessionPluginOptions } from './types'\n\nexport { generateIsolatedCookie, getIsolatedCookieName } from './auth/cookies'\nexport { generateIsolatedAuthCookie, resolveIsolatedCookieName } from './auth/runtime'\nexport { PLUGIN_SLUG } from './plugin/constants'\nexport { AUTH_SCOPE_HEADER, resolveAuthScope } from './scope/resolveAuthScope'\nexport type {\n\tAuthScope,\n\tDualSessionPluginOptions,\n\tDualSessionPluginOptions as PluginOptions,\n\tIsolatedCollection,\n} from './types'\n\ndeclare module 'payload' {\n\tinterface RegisteredPlugins {\n\t\t'@10x-media/dual-session': DualSessionPluginOptions\n\t}\n}\n\n/**\n * Moves the listed auth collections off Payload's single, config-wide\n * `${cookiePrefix}-token` cookie and onto their own cookies, so a login on the website\n * and a login in the admin panel can coexist.\n *\n * Payload signs every collection's token into that one shared cookie, so a frontend\n * login overwrites the admin panel's session and vice versa. This plugin gives each\n * listed collection its own cookie by:\n *\n * 1. shadowing the collection's built-in auth endpoints with replacements that read and\n * write the scoped cookie (Payload matches collection-declared endpoints before its\n * own built-ins), and\n * 2. registering an auth strategy on the collection that authenticates from that cookie.\n *\n * The admin collection (`admin.user`) keeps the shared cookie. It may only be listed with\n * an `isolate` predicate, which moves the users it selects onto a second cookie and leaves\n * everyone else (the admins) exactly where core put them.\n *\n * For `req.user` to be fully deterministic, pair this with the auth-scope proxy.\n * See `@10x-media/dual-session/proxy`.\n */\nexport const dualSession = definePlugin<DualSessionPluginOptions>({\n\tslug: PLUGIN_SLUG,\n\tplugin: ({ config: incomingConfig, plugins: _plugins, ...options }): Config => {\n\t\tif (options.disabled === true) {\n\t\t\treturn incomingConfig\n\t\t}\n\n\t\tconst cookiePrefix = incomingConfig.cookiePrefix ?? 'payload'\n\t\tconst scopeHeader = options.scopeHeader ?? AUTH_SCOPE_HEADER\n\t\tconst adminSessionPriority = options.adminSessionPriority ?? true\n\t\tconst incomingCollections = incomingConfig.collections ?? []\n\n\t\tconst { collections: isolated, warnings } = selectCollections({\n\t\t\tadminUserSlug: resolveAdminUserSlug(incomingConfig),\n\t\t\tcollections: options.collections,\n\t\t\tcookiePrefix,\n\t\t\tincoming: incomingCollections,\n\t\t})\n\n\t\tconst collections: CollectionConfig[] = incomingCollections.map((collection) => {\n\t\t\tconst match = isolated.find(({ slug }) => slug === collection.slug)\n\t\t\tif (!match) {\n\t\t\t\treturn collection\n\t\t\t}\n\n\t\t\tconst auth = typeof collection.auth === 'object' ? collection.auth : {}\n\t\t\tconst strategy = createIsolatedAuthStrategy({\n\t\t\t\tadminSessionPriority,\n\t\t\t\tcookieName: match.cookieName,\n\t\t\t\t// Everything listed before this collection outranks it.\n\t\t\t\thigherPriority: isolated\n\t\t\t\t\t.slice(0, isolated.indexOf(match))\n\t\t\t\t\t.map(({ cookieName, slug }) => ({ cookieName, slug })),\n\t\t\t\tscopeHeader,\n\t\t\t\tscopes: match.scopes,\n\t\t\t\tslug: match.slug,\n\t\t\t})\n\n\t\t\treturn {\n\t\t\t\t...collection,\n\t\t\t\tauth: {\n\t\t\t\t\t...auth,\n\t\t\t\t\tstrategies: [...(auth.strategies ?? []), strategy],\n\t\t\t\t},\n\t\t\t\tendpoints:\n\t\t\t\t\tcollection.endpoints === false\n\t\t\t\t\t\t? false\n\t\t\t\t\t\t: [...buildIsolatedAuthEndpoints({ entry: match }), ...(collection.endpoints ?? [])],\n\t\t\t}\n\t\t})\n\n\t\tregisterTranslations(incomingConfig, options.translations)\n\n\t\tconst config: Config = { ...incomingConfig, collections }\n\n\t\tif (warnings.length > 0) {\n\t\t\t// `payload.logger` only exists once Payload has booted, so a config-time problem\n\t\t\t// has to wait for onInit to be reported through the project's own logger.\n\t\t\tconst priorOnInit = incomingConfig.onInit\n\t\t\tconfig.onInit = async (payload) => {\n\t\t\t\tfor (const warning of warnings) {\n\t\t\t\t\tpayload.logger.warn(warning)\n\t\t\t\t}\n\t\t\t\tawait priorOnInit?.(payload)\n\t\t\t}\n\t\t}\n\n\t\treturn config\n\t},\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,MAAa,cAAc,aAAuC;CACjE,MAAM;CACN,SAAS,EAAE,QAAQ,gBAAgB,SAAS,UAAU,GAAG,cAAsB;EAC9E,IAAI,QAAQ,aAAa,MACxB,OAAO;EAGR,MAAM,eAAe,eAAe,gBAAgB;EACpD,MAAM,cAAc,QAAQ,eAAA;EAC5B,MAAM,uBAAuB,QAAQ,wBAAwB;EAC7D,MAAM,sBAAsB,eAAe,eAAe,CAAC;EAE3D,MAAM,EAAE,aAAa,UAAU,aAAa,kBAAkB;GAC7D,eAAe,qBAAqB,cAAc;GAClD,aAAa,QAAQ;GACrB;GACA,UAAU;EACX,CAAC;EAED,MAAM,cAAkC,oBAAoB,KAAK,eAAe;GAC/E,MAAM,QAAQ,SAAS,MAAM,EAAE,WAAW,SAAS,WAAW,IAAI;GAClE,IAAI,CAAC,OACJ,OAAO;GAGR,MAAM,OAAO,OAAO,WAAW,SAAS,WAAW,WAAW,OAAO,CAAC;GACtE,MAAM,WAAW,2BAA2B;IAC3C;IACA,YAAY,MAAM;IAElB,gBAAgB,SACd,MAAM,GAAG,SAAS,QAAQ,KAAK,CAAC,EAChC,KAAK,EAAE,YAAY,YAAY;KAAE;KAAY;IAAK,EAAE;IACtD;IACA,QAAQ,MAAM;IACd,MAAM,MAAM;GACb,CAAC;GAED,OAAO;IACN,GAAG;IACH,MAAM;KACL,GAAG;KACH,YAAY,CAAC,GAAI,KAAK,cAAc,CAAC,GAAI,QAAQ;IAClD;IACA,WACC,WAAW,cAAc,QACtB,QACA,CAAC,GAAG,2BAA2B,EAAE,OAAO,MAAM,CAAC,GAAG,GAAI,WAAW,aAAa,CAAC,CAAE;GACtF;EACD,CAAC;EAED,qBAAqB,gBAAgB,QAAQ,YAAY;EAEzD,MAAM,SAAiB;GAAE,GAAG;GAAgB;EAAY;EAExD,IAAI,SAAS,SAAS,GAAG;GAGxB,MAAM,cAAc,eAAe;GACnC,OAAO,SAAS,OAAO,YAAY;IAClC,KAAK,MAAM,WAAW,UACrB,QAAQ,OAAO,KAAK,OAAO;IAE5B,MAAM,cAAc,OAAO;GAC5B;EACD;EAEA,OAAO;CACR;AACD,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","names":[],"sources":["../../src/plugin/constants.ts"],"sourcesContent":["/** This plugin's registered slug, used to find its options on a booted config. */\nexport const PLUGIN_SLUG = '@10x-media/dual-session'\n"],"mappings":";;AACA,MAAa,cAAc"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { toNested, translations } from "../translations/index.js";
|
|
2
|
+
import { deepMergeSimple } from "payload/shared";
|
|
3
|
+
//#region src/plugin/registerTranslations.ts
|
|
4
|
+
/**
|
|
5
|
+
* Merge this plugin's translations into the host config. Plugin-level
|
|
6
|
+
* `overrides` win over the built-ins key-by-key and may add locales the plugin
|
|
7
|
+
* does not ship. A host value wins over both (`deepMergeSimple` lets the second
|
|
8
|
+
* argument override), so projects can override any string.
|
|
9
|
+
*/
|
|
10
|
+
const registerTranslations = (config, overrides) => {
|
|
11
|
+
const nested = {};
|
|
12
|
+
for (const [locale, flat] of Object.entries(overrides ?? {})) nested[locale] = toNested(flat);
|
|
13
|
+
config.i18n ??= {};
|
|
14
|
+
config.i18n.translations = deepMergeSimple(deepMergeSimple(translations, nested), config.i18n.translations ?? {});
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { registerTranslations };
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=registerTranslations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registerTranslations.js","names":[],"sources":["../../src/plugin/registerTranslations.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport { deepMergeSimple } from 'payload/shared'\n\nimport { type TranslationsOption, toNested, translations } from '../translations'\n\ntype Translations = NonNullable<NonNullable<Config['i18n']>['translations']>\n\n/**\n * Merge this plugin's translations into the host config. Plugin-level\n * `overrides` win over the built-ins key-by-key and may add locales the plugin\n * does not ship. A host value wins over both (`deepMergeSimple` lets the second\n * argument override), so projects can override any string.\n */\nexport const registerTranslations = (config: Config, overrides?: TranslationsOption): void => {\n\tconst nested: Record<string, Record<string, Record<string, string>>> = {}\n\tfor (const [locale, flat] of Object.entries(overrides ?? {})) {\n\t\tnested[locale] = toNested(flat)\n\t}\n\tconfig.i18n ??= {}\n\tconfig.i18n.translations = deepMergeSimple<Translations>(\n\t\tdeepMergeSimple(translations, nested),\n\t\tconfig.i18n.translations ?? {}\n\t)\n}\n"],"mappings":";;;;;;;;;AAaA,MAAa,wBAAwB,QAAgB,cAAyC;CAC7F,MAAM,SAAiE,CAAC;CACxE,KAAK,MAAM,CAAC,QAAQ,SAAS,OAAO,QAAQ,aAAa,CAAC,CAAC,GAC1D,OAAO,UAAU,SAAS,IAAI;CAE/B,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,eAAe,gBAC1B,gBAAgB,cAAc,MAAM,GACpC,OAAO,KAAK,gBAAgB,CAAC,CAC9B;AACD"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getIsolatedCookieName } from "../auth/cookies.js";
|
|
2
|
+
//#region src/plugin/resolveCollections.ts
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes the `collections` option to full entries, filling the default cookie name
|
|
5
|
+
* and scopes. Order is preserved because it is the priority order between isolated
|
|
6
|
+
* collections.
|
|
7
|
+
*
|
|
8
|
+
* `isolate` is carried through as given rather than defaulted to `() => true`: its absence
|
|
9
|
+
* is the signal that this collection needs no user to pick a cookie, which several callers
|
|
10
|
+
* answer differently from a predicate that happens to always return true.
|
|
11
|
+
*/
|
|
12
|
+
const resolveCollections = ({ collections, cookiePrefix }) => collections.map((entry) => {
|
|
13
|
+
const { cookieName, isolate, scopes, slug } = typeof entry === "string" ? { slug: entry } : entry;
|
|
14
|
+
return {
|
|
15
|
+
slug,
|
|
16
|
+
cookieName: cookieName ?? getIsolatedCookieName({
|
|
17
|
+
cookiePrefix,
|
|
18
|
+
slug
|
|
19
|
+
}),
|
|
20
|
+
...isolate ? { isolate } : {},
|
|
21
|
+
scopes: scopes ?? ["frontend"]
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
export { resolveCollections };
|
|
26
|
+
|
|
27
|
+
//# sourceMappingURL=resolveCollections.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolveCollections.js","names":[],"sources":["../../src/plugin/resolveCollections.ts"],"sourcesContent":["import { getIsolatedCookieName } from '../auth/cookies'\nimport type {\n\tDualSessionPluginOptions,\n\tIsolatedCollection,\n\tResolvedIsolatedCollection,\n} from '../types'\n\n/**\n * Normalizes the `collections` option to full entries, filling the default cookie name\n * and scopes. Order is preserved because it is the priority order between isolated\n * collections.\n *\n * `isolate` is carried through as given rather than defaulted to `() => true`: its absence\n * is the signal that this collection needs no user to pick a cookie, which several callers\n * answer differently from a predicate that happens to always return true.\n */\nexport const resolveCollections = ({\n\tcollections,\n\tcookiePrefix,\n}: {\n\tcollections: DualSessionPluginOptions['collections']\n\tcookiePrefix: string\n}): ResolvedIsolatedCollection[] =>\n\tcollections.map((entry) => {\n\t\tconst { cookieName, isolate, scopes, slug }: IsolatedCollection =\n\t\t\ttypeof entry === 'string' ? { slug: entry } : entry\n\n\t\treturn {\n\t\t\tslug,\n\t\t\tcookieName: cookieName ?? getIsolatedCookieName({ cookiePrefix, slug }),\n\t\t\t...(isolate ? { isolate } : {}),\n\t\t\tscopes: scopes ?? ['frontend'],\n\t\t}\n\t})\n"],"mappings":";;;;;;;;;;;AAgBA,MAAa,sBAAsB,EAClC,aACA,mBAKA,YAAY,KAAK,UAAU;CAC1B,MAAM,EAAE,YAAY,SAAS,QAAQ,SACpC,OAAO,UAAU,WAAW,EAAE,MAAM,MAAM,IAAI;CAE/C,OAAO;EACN;EACA,YAAY,cAAc,sBAAsB;GAAE;GAAc;EAAK,CAAC;EACtE,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;EAC7B,QAAQ,UAAU,CAAC,UAAU;CAC9B;AACD,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { resolveCollections } from "./resolveCollections.js";
|
|
2
|
+
//#region src/plugin/selectCollections.ts
|
|
3
|
+
/**
|
|
4
|
+
* The slug Payload will treat as the admin panel's user collection.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `sanitizeConfig`: an explicit `admin.user` wins, otherwise the first collection
|
|
7
|
+
* declaring `auth`, otherwise the `users` collection core appends for you. Guessing
|
|
8
|
+
* `'users'` outright would miss the case the check exists for: a project whose admin
|
|
9
|
+
* collection is named something else and never set `admin.user`, where isolating it would
|
|
10
|
+
* take the admin panel down.
|
|
11
|
+
*/
|
|
12
|
+
const resolveAdminUserSlug = (config) => config.admin?.user ?? (config.collections ?? []).find(({ auth }) => Boolean(auth))?.slug ?? "users";
|
|
13
|
+
/**
|
|
14
|
+
* Picks the collections this plugin will actually isolate, dropping the ones it cannot.
|
|
15
|
+
*
|
|
16
|
+
* Plugins run before `sanitizeConfig` and in array order, so a collection contributed by a
|
|
17
|
+
* later plugin is genuinely absent here. That is a load-order problem, not a broken config,
|
|
18
|
+
* and refusing to boot over it would be worse than saying so and moving on. Isolating the
|
|
19
|
+
* admin collection wholesale stays fatal: it is the one mistake that silently breaks the
|
|
20
|
+
* admin panel.
|
|
21
|
+
*
|
|
22
|
+
* Warnings are returned rather than logged: `payload.logger` does not exist while the config
|
|
23
|
+
* is being built, so the caller replays them from `onInit`.
|
|
24
|
+
*/
|
|
25
|
+
const selectCollections = ({ adminUserSlug, collections, cookiePrefix, incoming }) => {
|
|
26
|
+
const resolved = resolveCollections({
|
|
27
|
+
collections,
|
|
28
|
+
cookiePrefix
|
|
29
|
+
});
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const adminEntry = resolved.find(({ slug }) => slug === adminUserSlug);
|
|
32
|
+
if (adminEntry && !adminEntry.isolate) throw new Error(`@10x-media/dual-session: "${adminUserSlug}" backs the admin panel and owns the shared "${cookiePrefix}-token" cookie, so isolating all of it takes the admin panel down. Give the entry an \`isolate\` predicate to move only some of its users onto a second cookie, or list the other auth collections instead.`);
|
|
33
|
+
if (adminEntry?.scopes.includes("admin")) throw new Error(`@10x-media/dual-session: "${adminUserSlug}" backs the admin panel, so its isolated cookie must not carry the "admin" scope. It would shadow the admin session it is supposed to sit beside. Use \`scopes: ['frontend']\`.`);
|
|
34
|
+
return {
|
|
35
|
+
collections: resolved.filter(({ slug }) => {
|
|
36
|
+
const collection = incoming.find((entry) => entry.slug === slug);
|
|
37
|
+
if (!collection) {
|
|
38
|
+
warnings.push(`@10x-media/dual-session: collection "${slug}" is not in the config, so it was skipped. If another plugin adds it, list dualSession after that plugin.`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (!collection.auth) {
|
|
42
|
+
warnings.push(`@10x-media/dual-session: collection "${slug}" does not have auth enabled, so it was skipped.`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (collection.endpoints === false) warnings.push(`@10x-media/dual-session: collection "${slug}" sets "endpoints: false", so it has no REST auth routes to shadow. Sessions for it can only be established outside REST, via generateIsolatedAuthCookie.`);
|
|
46
|
+
return true;
|
|
47
|
+
}),
|
|
48
|
+
warnings
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { resolveAdminUserSlug, selectCollections };
|
|
53
|
+
|
|
54
|
+
//# sourceMappingURL=selectCollections.js.map
|