@signal58/payload-oidc 0.0.0 → 0.3.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/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/before-operation.d.ts +35 -0
- package/dist/before-operation.js +211 -0
- package/dist/endpoints.d.ts +8 -0
- package/dist/endpoints.js +193 -0
- package/dist/exports/client.d.ts +8 -0
- package/dist/exports/client.js +45 -0
- package/dist/exports/rsc.d.ts +9 -0
- package/dist/exports/rsc.js +30 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/migration.d.ts +47 -0
- package/dist/migration.js +89 -0
- package/dist/options.d.ts +172 -0
- package/dist/options.js +137 -0
- package/dist/plugin.d.ts +25 -0
- package/dist/plugin.js +248 -0
- package/dist/provider.d.ts +43 -0
- package/dist/provider.js +67 -0
- package/dist/reconcile-task.d.ts +28 -0
- package/dist/reconcile-task.js +163 -0
- package/dist/reconcile.d.ts +32 -0
- package/dist/reconcile.js +110 -0
- package/dist/remove.d.ts +29 -0
- package/dist/remove.js +161 -0
- package/dist/sign-in.d.ts +27 -0
- package/dist/sign-in.js +122 -0
- package/dist/sources/authentik.d.ts +26 -0
- package/dist/sources/authentik.js +60 -0
- package/dist/store.d.ts +105 -0
- package/dist/store.js +161 -0
- package/dist/test/fake-provider.d.ts +27 -0
- package/dist/test/fake-provider.js +103 -0
- package/dist/transaction.d.ts +35 -0
- package/dist/transaction.js +89 -0
- package/package.json +92 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { reconcileUsers } from './reconcile.js';
|
|
2
|
+
export const RECONCILE_TASK_SLUG = 'payload-oidc-reconcile';
|
|
3
|
+
/** Payload's jobs collection. */
|
|
4
|
+
const JOBS_COLLECTION = 'payload-jobs';
|
|
5
|
+
/**
|
|
6
|
+
* Marks reconcile runs on `queue` that are still processing after `olderThanSeconds` as failed,
|
|
7
|
+
* and returns the IDs it marked.
|
|
8
|
+
*
|
|
9
|
+
* Payload's scheduler queues a run only while no scheduled run of the task is waiting or
|
|
10
|
+
* processing, and its runner only picks up jobs that aren't processing. A run left processing by a
|
|
11
|
+
* process that stopped mid-run (a container a deploy replaced) would therefore block the queue for
|
|
12
|
+
* good: Payload never expires it. Payload's own cancel writes its final-failure shape
|
|
13
|
+
* (`hasError`, `processing: false`, `error.cancelled`), so the dead run stays visible as a failed
|
|
14
|
+
* job and the scheduler stops counting it.
|
|
15
|
+
*/
|
|
16
|
+
export const releaseStaleRuns = async (payload, { now, olderThanSeconds, queue }) => {
|
|
17
|
+
const where = {
|
|
18
|
+
and: [
|
|
19
|
+
{ processing: { equals: true } },
|
|
20
|
+
{ completedAt: { exists: false } },
|
|
21
|
+
{ hasError: { not_equals: true } },
|
|
22
|
+
{ queue: { equals: queue } },
|
|
23
|
+
{ taskSlug: { equals: RECONCILE_TASK_SLUG } },
|
|
24
|
+
{ updatedAt: { less_than: new Date(now.getTime() - olderThanSeconds * 1000).toISOString() } },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
const select = { createdAt: true, updatedAt: true };
|
|
28
|
+
const { docs: stale } = (await payload.find({
|
|
29
|
+
collection: JOBS_COLLECTION,
|
|
30
|
+
depth: 0,
|
|
31
|
+
overrideAccess: true,
|
|
32
|
+
pagination: false,
|
|
33
|
+
select,
|
|
34
|
+
where,
|
|
35
|
+
}));
|
|
36
|
+
if (stale.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const ids = stale.map((job) => job.id);
|
|
39
|
+
// Only those still processing: a run that finished since the find is left alone.
|
|
40
|
+
await payload.jobs.cancel({
|
|
41
|
+
overrideAccess: true,
|
|
42
|
+
where: { and: [{ id: { in: ids } }, { processing: { equals: true } }] },
|
|
43
|
+
});
|
|
44
|
+
// Cancel doesn't say which jobs it changed, so read back the ones it marked.
|
|
45
|
+
const { docs: released } = (await payload.find({
|
|
46
|
+
collection: JOBS_COLLECTION,
|
|
47
|
+
depth: 0,
|
|
48
|
+
overrideAccess: true,
|
|
49
|
+
pagination: false,
|
|
50
|
+
select,
|
|
51
|
+
where: { and: [{ id: { in: ids } }, { 'error.cancelled': { equals: true } }] },
|
|
52
|
+
}));
|
|
53
|
+
for (const job of released) {
|
|
54
|
+
payload.logger.warn({
|
|
55
|
+
createdAt: job.createdAt,
|
|
56
|
+
jobId: job.id,
|
|
57
|
+
lastUpdatedAt: job.updatedAt,
|
|
58
|
+
msg: `payload-oidc: marked reconcile run ${job.id} failed: still processing after ${olderThanSeconds} seconds`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return released.map((job) => job.id);
|
|
62
|
+
};
|
|
63
|
+
/** The share of the stale threshold a run's source may take before the run gives up. */
|
|
64
|
+
const SOURCE_TIME_SHARE = 0.8;
|
|
65
|
+
/**
|
|
66
|
+
* The options with the reconcile source's answer limited to `limitMs`. Payload doesn't touch a
|
|
67
|
+
* job while its handler runs, so a run that outlasted the stale threshold would look cut off,
|
|
68
|
+
* be released, and overlap the new run. Reconcile writes nothing until every group is read, so
|
|
69
|
+
* stopping at the read changes nothing.
|
|
70
|
+
*/
|
|
71
|
+
const withSourceTimeLimit = (options, limitMs) => {
|
|
72
|
+
const source = options.reconcile;
|
|
73
|
+
return {
|
|
74
|
+
...options,
|
|
75
|
+
reconcile: {
|
|
76
|
+
...source,
|
|
77
|
+
fetchGroupMembers: async (groups) => {
|
|
78
|
+
let timer;
|
|
79
|
+
const late = new Promise((_, reject) => {
|
|
80
|
+
timer = setTimeout(() => reject(new Error(`payload-oidc: the reconcile source took longer than ${limitMs / 1000} seconds; the run stopped before changing anything, so it can't outlast reconcileStaleAfterMinutes and be taken for a cut-off run.`)), limitMs);
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
return await Promise.race([source.fetchGroupMembers(groups), late]);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* The reconcile job. Payload schedules it only on its own queue, and runs schedules only for
|
|
94
|
+
* queues listed in `jobs.autoRun`, so the app must add that queue there. A failed run (a group
|
|
95
|
+
* that can't be read, a safety stop) fails the job and records no heartbeat.
|
|
96
|
+
*/
|
|
97
|
+
export const createReconcileTask = (options, { clock = () => new Date(), concurrencyControl = false } = {}) => ({
|
|
98
|
+
slug: RECONCILE_TASK_SLUG,
|
|
99
|
+
label: 'Reconcile CMS users with the identity provider',
|
|
100
|
+
// One run at a time where the app enables Payload's concurrency control; otherwise Payload's
|
|
101
|
+
// scheduler still skips a schedule while a run is queued or running.
|
|
102
|
+
...(concurrencyControl ? { concurrency: () => RECONCILE_TASK_SLUG } : {}),
|
|
103
|
+
inputSchema: [],
|
|
104
|
+
outputSchema: [
|
|
105
|
+
{ name: 'disabled', type: 'number', required: true },
|
|
106
|
+
{ name: 'roleChanges', type: 'number', required: true },
|
|
107
|
+
{ name: 'users', type: 'number', required: true },
|
|
108
|
+
],
|
|
109
|
+
retries: 0,
|
|
110
|
+
...(options.enabled && options.reconcile
|
|
111
|
+
? {
|
|
112
|
+
schedule: [
|
|
113
|
+
{
|
|
114
|
+
cron: options.reconcile.cron,
|
|
115
|
+
hooks: {
|
|
116
|
+
...options.reconcileScheduleHooks,
|
|
117
|
+
beforeSchedule: async (args) => {
|
|
118
|
+
// On every tick, not only at startup: with start-first deploys the run a deploy
|
|
119
|
+
// cuts off usually belongs to the old process, after this one started.
|
|
120
|
+
const olderThanSeconds = options.reconcileStaleAfterSeconds;
|
|
121
|
+
if (olderThanSeconds !== undefined) {
|
|
122
|
+
try {
|
|
123
|
+
await releaseStaleRuns(args.req.payload, {
|
|
124
|
+
now: clock(),
|
|
125
|
+
olderThanSeconds,
|
|
126
|
+
queue: args.queueable.scheduleConfig.queue,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// Still make the scheduling decision; the next tick tries again.
|
|
131
|
+
args.req.payload.logger.error({
|
|
132
|
+
err: error,
|
|
133
|
+
msg: 'payload-oidc: could not release stale reconcile runs',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...(await (options.reconcileScheduleHooks?.beforeSchedule ?? args.defaultBeforeSchedule)(args)),
|
|
139
|
+
// Payload can make a new job wait for the next cron slot. That slot's tick only
|
|
140
|
+
// runs the job, as one is still waiting, so reconcile would run on every other
|
|
141
|
+
// tick. With no wait, the tick that queues the job also runs it.
|
|
142
|
+
waitUntil: undefined,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
queue: options.reconcile.queue,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
151
|
+
handler: async ({ req }) => {
|
|
152
|
+
if (!options.enabled || !options.reconcile) {
|
|
153
|
+
return { output: { disabled: 0, roleChanges: 0, users: 0 } };
|
|
154
|
+
}
|
|
155
|
+
const staleAfterSeconds = options.reconcileStaleAfterSeconds;
|
|
156
|
+
const { disabled, roleChanges, users } = await reconcileUsers(req.payload,
|
|
157
|
+
// While stale runs are released, a live run must end well before it would look stale.
|
|
158
|
+
staleAfterSeconds === undefined
|
|
159
|
+
? options
|
|
160
|
+
: withSourceTimeLimit(options, staleAfterSeconds * 1000 * SOURCE_TIME_SHARE), clock());
|
|
161
|
+
return { output: { disabled, roleChanges, users } };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
import { type ResolvedOidcOptions } from './options.js';
|
|
3
|
+
/** KV key of the last successful reconcile run. */
|
|
4
|
+
export declare const RECONCILE_HEARTBEAT_KEY = "payload-oidc:reconcile";
|
|
5
|
+
export type ReconcileHeartbeat = {
|
|
6
|
+
disabled: number;
|
|
7
|
+
finishedAt: string;
|
|
8
|
+
roleChanges: number;
|
|
9
|
+
users: number;
|
|
10
|
+
};
|
|
11
|
+
export type ReconcileResult = ReconcileHeartbeat;
|
|
12
|
+
/**
|
|
13
|
+
* Brings every linked user in line with the provider's groups:
|
|
14
|
+
*
|
|
15
|
+
* - A user whose subject is in no role-table group, or only as an inactive member, is disabled
|
|
16
|
+
* (again, if a whole-document write restored their role or sessions).
|
|
17
|
+
* - An enabled user whose role changed gets the new role. A disabled user stays disabled until
|
|
18
|
+
* they sign in again.
|
|
19
|
+
* - Users are never created, and the break-glass account and unlinked users are never touched.
|
|
20
|
+
*
|
|
21
|
+
* It changes nothing unless every group was read, and stops without changes if it would disable
|
|
22
|
+
* more than `maxDisableShare` of the linked users (at least one is always allowed), so a renamed
|
|
23
|
+
* group or a lost permission can't disable a whole tier. Each successful run records a heartbeat.
|
|
24
|
+
*
|
|
25
|
+
* @throws When a group can't be read or a safety stop trips: the job fails and alerts.
|
|
26
|
+
*/
|
|
27
|
+
export declare const reconcileUsers: (payload: Payload, options: ResolvedOidcOptions, now?: Date) => Promise<ReconcileResult>;
|
|
28
|
+
/** Whether the last successful run is recent enough, for a status check such as Gatus. */
|
|
29
|
+
export declare const reconcileHealth: (payload: Payload, options: ResolvedOidcOptions, now?: Date) => Promise<{
|
|
30
|
+
healthy: boolean;
|
|
31
|
+
lastSuccessAt: string | null;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isBreakGlassEmail, roleForGroups, roleTableGroups, } from './options.js';
|
|
2
|
+
import { authCollectionModel, disableUser, OIDC_FIELDS, roleValue } from './store.js';
|
|
3
|
+
/** KV key of the last successful reconcile run. */
|
|
4
|
+
export const RECONCILE_HEARTBEAT_KEY = 'payload-oidc:reconcile';
|
|
5
|
+
/**
|
|
6
|
+
* Brings every linked user in line with the provider's groups:
|
|
7
|
+
*
|
|
8
|
+
* - A user whose subject is in no role-table group, or only as an inactive member, is disabled
|
|
9
|
+
* (again, if a whole-document write restored their role or sessions).
|
|
10
|
+
* - An enabled user whose role changed gets the new role. A disabled user stays disabled until
|
|
11
|
+
* they sign in again.
|
|
12
|
+
* - Users are never created, and the break-glass account and unlinked users are never touched.
|
|
13
|
+
*
|
|
14
|
+
* It changes nothing unless every group was read, and stops without changes if it would disable
|
|
15
|
+
* more than `maxDisableShare` of the linked users (at least one is always allowed), so a renamed
|
|
16
|
+
* group or a lost permission can't disable a whole tier. Each successful run records a heartbeat.
|
|
17
|
+
*
|
|
18
|
+
* @throws When a group can't be read or a safety stop trips: the job fails and alerts.
|
|
19
|
+
*/
|
|
20
|
+
export const reconcileUsers = async (payload, options, now = new Date()) => {
|
|
21
|
+
const source = options.reconcile;
|
|
22
|
+
if (!source)
|
|
23
|
+
throw new Error('payload-oidc: reconcile is not configured.');
|
|
24
|
+
// Users first, then groups: every write below is conditional on the user being unchanged
|
|
25
|
+
// since this read, so someone who signs in (and is synced) while the groups are fetched is left
|
|
26
|
+
// for the next run instead of being judged on a stale snapshot.
|
|
27
|
+
const model = authCollectionModel(payload, options.collection);
|
|
28
|
+
const linked = await model.find({ [OIDC_FIELDS.subject]: { $exists: true } }).lean();
|
|
29
|
+
const groups = roleTableGroups(options.roleTable);
|
|
30
|
+
const members = await source.fetchGroupMembers(groups);
|
|
31
|
+
const groupsBySubject = new Map();
|
|
32
|
+
for (const group of groups) {
|
|
33
|
+
const list = members.get(group);
|
|
34
|
+
if (!list)
|
|
35
|
+
throw new Error(`payload-oidc: reconcile got no answer for group ${group}.`);
|
|
36
|
+
for (const member of list) {
|
|
37
|
+
if (!member.active)
|
|
38
|
+
continue;
|
|
39
|
+
groupsBySubject.set(member.subject, [...(groupsBySubject.get(member.subject) ?? []), group]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const unchanged = (user) => ({
|
|
43
|
+
_id: user._id,
|
|
44
|
+
[OIDC_FIELDS.subject]: user[OIDC_FIELDS.subject],
|
|
45
|
+
updatedAt: user.updatedAt ?? null,
|
|
46
|
+
});
|
|
47
|
+
const newlyDisabled = [];
|
|
48
|
+
const redisabled = [];
|
|
49
|
+
const toChange = [];
|
|
50
|
+
let enabled = 0;
|
|
51
|
+
for (const user of linked) {
|
|
52
|
+
if (isBreakGlassEmail(options, user.email))
|
|
53
|
+
continue;
|
|
54
|
+
const isDisabled = Boolean(user[OIDC_FIELDS.disabledAt]);
|
|
55
|
+
if (!isDisabled)
|
|
56
|
+
enabled += 1;
|
|
57
|
+
const role = roleForGroups(options.roleTable, groupsBySubject.get(String(user[OIDC_FIELDS.subject])));
|
|
58
|
+
const stored = user[options.roleField];
|
|
59
|
+
const hasRole = Array.isArray(stored) ? stored.length > 0 : Boolean(stored);
|
|
60
|
+
const sessions = Array.isArray(user.sessions) ? user.sessions.length : 0;
|
|
61
|
+
if (!role) {
|
|
62
|
+
if (!isDisabled)
|
|
63
|
+
newlyDisabled.push(user);
|
|
64
|
+
// Disabled already, but a whole-document write restored the role or sessions.
|
|
65
|
+
else if (hasRole || sessions > 0)
|
|
66
|
+
redisabled.push(user);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (isDisabled)
|
|
70
|
+
continue;
|
|
71
|
+
if (JSON.stringify(stored) !== JSON.stringify(roleValue(options, role))) {
|
|
72
|
+
toChange.push({ role, user });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// The share counts enabled users only: disabled ones that were never removed would otherwise
|
|
76
|
+
// inflate it until a whole tier fits under the limit.
|
|
77
|
+
const allowed = Math.max(1, Math.floor(enabled * options.reconcileMaxDisableShare));
|
|
78
|
+
if (newlyDisabled.length > allowed) {
|
|
79
|
+
throw new Error(`payload-oidc: reconcile would disable ${newlyDisabled.length} of ${enabled} enabled users (more than ${allowed}); nothing was changed. If that is intended, raise reconcileMaxDisableShare for one run.`);
|
|
80
|
+
}
|
|
81
|
+
let disabled = 0;
|
|
82
|
+
for (const user of [...newlyDisabled, ...redisabled]) {
|
|
83
|
+
if (await disableUser(payload, options, user._id, now, unchanged(user)))
|
|
84
|
+
disabled += 1;
|
|
85
|
+
}
|
|
86
|
+
let roleChanges = 0;
|
|
87
|
+
for (const { role, user } of toChange) {
|
|
88
|
+
// Only while still enabled and unchanged: a disable or a sign-in that landed meanwhile wins.
|
|
89
|
+
const result = await model.updateOne({ ...unchanged(user), [OIDC_FIELDS.disabledAt]: null }, { $set: { [options.roleField]: roleValue(options, role), updatedAt: now } });
|
|
90
|
+
if (result.matchedCount > 0)
|
|
91
|
+
roleChanges += 1;
|
|
92
|
+
}
|
|
93
|
+
const heartbeat = {
|
|
94
|
+
disabled,
|
|
95
|
+
finishedAt: now.toISOString(),
|
|
96
|
+
roleChanges,
|
|
97
|
+
users: linked.length,
|
|
98
|
+
};
|
|
99
|
+
await payload.kv.set(RECONCILE_HEARTBEAT_KEY, heartbeat);
|
|
100
|
+
payload.logger.info({ msg: 'payload-oidc: reconcile finished', ...heartbeat });
|
|
101
|
+
return heartbeat;
|
|
102
|
+
};
|
|
103
|
+
/** Whether the last successful run is recent enough, for a status check such as Gatus. */
|
|
104
|
+
export const reconcileHealth = async (payload, options, now = new Date()) => {
|
|
105
|
+
const heartbeat = await payload.kv.get(RECONCILE_HEARTBEAT_KEY);
|
|
106
|
+
const finishedAt = heartbeat?.finishedAt ? new Date(heartbeat.finishedAt) : undefined;
|
|
107
|
+
const healthy = Boolean(finishedAt &&
|
|
108
|
+
now.getTime() - finishedAt.getTime() <= options.reconcileHeartbeatMaxSeconds * 1000);
|
|
109
|
+
return { healthy, lastSuccessAt: heartbeat?.finishedAt ?? null };
|
|
110
|
+
};
|
package/dist/remove.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Payload, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload';
|
|
2
|
+
import { type ResolvedOidcOptions } from './options.js';
|
|
3
|
+
/** A place a document can point at a user: a collection or global, and a dotted field path. */
|
|
4
|
+
export type UserReference = {
|
|
5
|
+
entity: 'collection' | 'global';
|
|
6
|
+
path: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
versions: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Every field in the config that can refer to a user of `collection`, except Payload's own
|
|
12
|
+
* bookkeeping (preferences, locks, jobs, migrations), which belongs to the user and goes with it.
|
|
13
|
+
* Rich-text relationships aren't seen; anything referenced only there is left as a dangling link.
|
|
14
|
+
*/
|
|
15
|
+
export declare const findUserReferences: (collections: SanitizedCollectionConfig[], globals: SanitizedGlobalConfig[], collection: string) => UserReference[];
|
|
16
|
+
export type RemoveOutcome = {
|
|
17
|
+
kind: 'anonymised' | 'deleted';
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'refused';
|
|
20
|
+
reason: 'break_glass' | 'not_disabled' | 'not_found';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Removes a disabled user. Only disabled users: an active one would be created again at their
|
|
24
|
+
* next sign-in. If nothing refers to the user they are deleted; otherwise they are anonymised
|
|
25
|
+
* (names "Removed user", email `removed-<id>@removed.invalid`, every other profile field, the
|
|
26
|
+
* subject, password and sessions gone) so bylines and audit rows still resolve. Someone added back at the provider later signs
|
|
27
|
+
* in as a new user.
|
|
28
|
+
*/
|
|
29
|
+
export declare const removeUser: (payload: Payload, options: ResolvedOidcOptions, id: string | number) => Promise<RemoveOutcome>;
|
package/dist/remove.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isBreakGlassEmail } from './options.js';
|
|
2
|
+
import { authCollectionModel, OIDC_FIELDS, OIDC_SYNC_CONTEXT, unusablePassword } from './store.js';
|
|
3
|
+
const pointsAt = (relationTo, collection) => Array.isArray(relationTo) ? relationTo.includes(collection) : relationTo === collection;
|
|
4
|
+
/** Every relationship or upload field under `fields` that can point at `collection`. */
|
|
5
|
+
const referencePaths = (fields, collection, prefix = '') => fields.flatMap((field) => {
|
|
6
|
+
const named = 'name' in field && field.name ? `${prefix}${field.name}` : undefined;
|
|
7
|
+
if ((field.type === 'relationship' || field.type === 'upload') && named) {
|
|
8
|
+
if (!pointsAt(field.relationTo, collection))
|
|
9
|
+
return [];
|
|
10
|
+
// A polymorphic relationship stores `{ relationTo, value }`.
|
|
11
|
+
return [Array.isArray(field.relationTo) ? `${named}.value` : named];
|
|
12
|
+
}
|
|
13
|
+
if (field.type === 'array' || field.type === 'group') {
|
|
14
|
+
return referencePaths(field.fields, collection, named ? `${named}.` : prefix);
|
|
15
|
+
}
|
|
16
|
+
if (field.type === 'blocks' && named) {
|
|
17
|
+
return field.blocks.flatMap((block) => referencePaths(typeof block === 'string' ? [] : block.fields, collection, `${named}.`));
|
|
18
|
+
}
|
|
19
|
+
if (field.type === 'tabs') {
|
|
20
|
+
return field.tabs.flatMap((tab) => referencePaths(tab.fields, collection, 'name' in tab && tab.name ? `${prefix}${tab.name}.` : prefix));
|
|
21
|
+
}
|
|
22
|
+
if ((field.type === 'row' || field.type === 'collapsible') && 'fields' in field) {
|
|
23
|
+
return referencePaths(field.fields, collection, prefix);
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Every field in the config that can refer to a user of `collection`, except Payload's own
|
|
29
|
+
* bookkeeping (preferences, locks, jobs, migrations), which belongs to the user and goes with it.
|
|
30
|
+
* Rich-text relationships aren't seen; anything referenced only there is left as a dangling link.
|
|
31
|
+
*/
|
|
32
|
+
export const findUserReferences = (collections, globals, collection) => [
|
|
33
|
+
...collections
|
|
34
|
+
.filter(({ slug }) => !slug.startsWith('payload-'))
|
|
35
|
+
.flatMap((config) => referencePaths(config.fields, collection).map((path) => ({
|
|
36
|
+
entity: 'collection',
|
|
37
|
+
path,
|
|
38
|
+
slug: config.slug,
|
|
39
|
+
versions: Boolean(config.versions),
|
|
40
|
+
}))),
|
|
41
|
+
...globals.flatMap((config) => referencePaths(config.fields, collection).map((path) => ({
|
|
42
|
+
entity: 'global',
|
|
43
|
+
path,
|
|
44
|
+
slug: config.slug,
|
|
45
|
+
versions: Boolean(config.versions),
|
|
46
|
+
}))),
|
|
47
|
+
];
|
|
48
|
+
const isReferenced = async (payload, references, id) => {
|
|
49
|
+
for (const reference of references) {
|
|
50
|
+
if (reference.entity === 'global') {
|
|
51
|
+
const global = (await payload.findGlobal({
|
|
52
|
+
depth: 0,
|
|
53
|
+
overrideAccess: true,
|
|
54
|
+
slug: reference.slug,
|
|
55
|
+
}));
|
|
56
|
+
if (JSON.stringify(global).includes(JSON.stringify(String(id))))
|
|
57
|
+
return true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const where = { [reference.path]: { equals: id } };
|
|
61
|
+
const { totalDocs } = await payload.count({
|
|
62
|
+
collection: reference.slug,
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
where,
|
|
65
|
+
});
|
|
66
|
+
if (totalDocs > 0)
|
|
67
|
+
return true;
|
|
68
|
+
if (reference.versions) {
|
|
69
|
+
const versions = await payload.countVersions({
|
|
70
|
+
collection: reference.slug,
|
|
71
|
+
overrideAccess: true,
|
|
72
|
+
where: { [`version.${reference.path}`]: { equals: id } },
|
|
73
|
+
});
|
|
74
|
+
if (versions.totalDocs > 0)
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
/** Payload's auth bookkeeping that names or can act for the person. */
|
|
81
|
+
const AUTH_FIELDS_TO_CLEAR = [
|
|
82
|
+
'_verificationToken',
|
|
83
|
+
'lockUntil',
|
|
84
|
+
'loginAttempts',
|
|
85
|
+
'resetPasswordExpiration',
|
|
86
|
+
'resetPasswordRequestedAt',
|
|
87
|
+
'resetPasswordToken',
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Everything an anonymised user loses: every top-level data field of the collection (photo,
|
|
91
|
+
* social links, anything an app added) except the ones anonymising sets or keeps, plus Payload's
|
|
92
|
+
* auth bookkeeping. `keepOnRemove` names fields an app wants kept.
|
|
93
|
+
*/
|
|
94
|
+
const fieldsToClear = (payload, options) => {
|
|
95
|
+
const keep = new Set([
|
|
96
|
+
'createdAt',
|
|
97
|
+
'email',
|
|
98
|
+
'hash',
|
|
99
|
+
'id',
|
|
100
|
+
'salt',
|
|
101
|
+
'sessions',
|
|
102
|
+
'updatedAt',
|
|
103
|
+
OIDC_FIELDS.disabledAt,
|
|
104
|
+
options.firstNameField,
|
|
105
|
+
options.lastNameField,
|
|
106
|
+
options.roleField,
|
|
107
|
+
...options.keepOnRemove,
|
|
108
|
+
]);
|
|
109
|
+
const config = payload.collections[options.collection];
|
|
110
|
+
const dataFields = config.config.flattenedFields
|
|
111
|
+
.filter((field) => 'name' in field && field.type !== 'join')
|
|
112
|
+
.map((field) => field.name);
|
|
113
|
+
return [...new Set([...dataFields, ...AUTH_FIELDS_TO_CLEAR])].filter((name) => !keep.has(name));
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Removes a disabled user. Only disabled users: an active one would be created again at their
|
|
117
|
+
* next sign-in. If nothing refers to the user they are deleted; otherwise they are anonymised
|
|
118
|
+
* (names "Removed user", email `removed-<id>@removed.invalid`, every other profile field, the
|
|
119
|
+
* subject, password and sessions gone) so bylines and audit rows still resolve. Someone added back at the provider later signs
|
|
120
|
+
* in as a new user.
|
|
121
|
+
*/
|
|
122
|
+
export const removeUser = async (payload, options, id) => {
|
|
123
|
+
const model = authCollectionModel(payload, options.collection);
|
|
124
|
+
const user = await model.findOne({ _id: id }, { email: 1, [OIDC_FIELDS.disabledAt]: 1 }).lean();
|
|
125
|
+
if (!user)
|
|
126
|
+
return { kind: 'refused', reason: 'not_found' };
|
|
127
|
+
if (isBreakGlassEmail(options, user.email))
|
|
128
|
+
return { kind: 'refused', reason: 'break_glass' };
|
|
129
|
+
if (!user.oidcDisabledAt)
|
|
130
|
+
return { kind: 'refused', reason: 'not_disabled' };
|
|
131
|
+
const references = findUserReferences(payload.config.collections, payload.config.globals, options.collection);
|
|
132
|
+
if (!(await isReferenced(payload, references, id))) {
|
|
133
|
+
// Re-checked in the delete itself: a user who signed in again since the read above is no
|
|
134
|
+
// longer disabled and is kept.
|
|
135
|
+
const { docs } = await payload.delete({
|
|
136
|
+
collection: options.collection,
|
|
137
|
+
context: { [OIDC_SYNC_CONTEXT]: true },
|
|
138
|
+
overrideAccess: true,
|
|
139
|
+
where: {
|
|
140
|
+
and: [{ id: { equals: id } }, { [OIDC_FIELDS.disabledAt]: { exists: true } }],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return docs.length > 0 ? { kind: 'deleted' } : { kind: 'refused', reason: 'not_disabled' };
|
|
144
|
+
}
|
|
145
|
+
const clear = fieldsToClear(payload, options);
|
|
146
|
+
const result = await model.updateOne({ _id: id, [OIDC_FIELDS.disabledAt]: { $ne: null } }, {
|
|
147
|
+
$set: {
|
|
148
|
+
// A valid address (Payload's email check needs a dotted domain), unique per user, and
|
|
149
|
+
// under a reserved TLD so it can never receive mail.
|
|
150
|
+
email: `removed-${String(id)}@removed.invalid`,
|
|
151
|
+
[options.firstNameField]: 'Removed',
|
|
152
|
+
[options.lastNameField]: 'user',
|
|
153
|
+
sessions: [],
|
|
154
|
+
...unusablePassword(),
|
|
155
|
+
},
|
|
156
|
+
$unset: Object.fromEntries([OIDC_FIELDS.linkable, OIDC_FIELDS.subject, ...clear].map((field) => [field, ''])),
|
|
157
|
+
});
|
|
158
|
+
return result.matchedCount > 0
|
|
159
|
+
? { kind: 'anonymised' }
|
|
160
|
+
: { kind: 'refused', reason: 'not_disabled' };
|
|
161
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Payload } from 'payload';
|
|
2
|
+
import { type ResolvedOidcOptions } from './options.js';
|
|
3
|
+
import type { VerifiedIdentity } from './provider.js';
|
|
4
|
+
/**
|
|
5
|
+
* Why a sign-in was refused. The login page shows a short message per code; provider detail is
|
|
6
|
+
* only logged.
|
|
7
|
+
*/
|
|
8
|
+
export type SignInRefusal = 'account_conflict' | 'failed' | 'disabled' | 'email_not_verified' | 'no_role' | 'provider' | 'expired' | 'unavailable';
|
|
9
|
+
export type SignInOutcome = {
|
|
10
|
+
kind: 'refused';
|
|
11
|
+
reason: SignInRefusal;
|
|
12
|
+
} | {
|
|
13
|
+
expiresAt: Date;
|
|
14
|
+
kind: 'success';
|
|
15
|
+
token: string;
|
|
16
|
+
userId: string | number;
|
|
17
|
+
};
|
|
18
|
+
/** First and last name from the claims; `lastName` is required, so it falls back to a placeholder. */
|
|
19
|
+
export declare const namesFromIdentity: (options: ResolvedOidcOptions, identity: VerifiedIdentity) => {
|
|
20
|
+
firstName: string;
|
|
21
|
+
lastName: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Finishes a sign-in after the ID token has been validated: decides the role, finds, links or
|
|
25
|
+
* creates the user, writes the provider-owned fields, and issues a Payload session.
|
|
26
|
+
*/
|
|
27
|
+
export declare const completeSignIn: (payload: Payload, options: ResolvedOidcOptions, identity: VerifiedIdentity, now: Date) => Promise<SignInOutcome>;
|
package/dist/sign-in.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { getFieldsToSign, jwtSign } from 'payload';
|
|
3
|
+
import { canLinkByEmail, isBreakGlassEmail, roleForGroups, } from './options.js';
|
|
4
|
+
import { authCollectionConfig, disableUser, isDuplicateKeyError, linkByEmail, markVerified, OIDC_FIELDS, OIDC_SYNC_CONTEXT, pushSession, roleValue, writeManagedFields, } from './store.js';
|
|
5
|
+
const syncContext = { [OIDC_SYNC_CONTEXT]: true };
|
|
6
|
+
const findOne = async (payload, collection, where) => {
|
|
7
|
+
const { docs } = await payload.find({
|
|
8
|
+
collection: collection,
|
|
9
|
+
depth: 0,
|
|
10
|
+
limit: 1,
|
|
11
|
+
overrideAccess: true,
|
|
12
|
+
pagination: false,
|
|
13
|
+
where: where,
|
|
14
|
+
});
|
|
15
|
+
return docs[0];
|
|
16
|
+
};
|
|
17
|
+
/** First and last name from the claims; `lastName` is required, so it falls back to a placeholder. */
|
|
18
|
+
export const namesFromIdentity = (options, identity) => {
|
|
19
|
+
const firstName = identity.givenName ?? identity.name?.split(/\s+/)[0] ?? identity.email.split('@')[0];
|
|
20
|
+
return { firstName, lastName: identity.familyName ?? options.lastNamePlaceholder };
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Finishes a sign-in after the ID token has been validated: decides the role, finds, links or
|
|
24
|
+
* creates the user, writes the provider-owned fields, and issues a Payload session.
|
|
25
|
+
*/
|
|
26
|
+
export const completeSignIn = async (payload, options, identity, now) => {
|
|
27
|
+
const refuse = (reason) => ({ kind: 'refused', reason });
|
|
28
|
+
if (!identity.emailVerified)
|
|
29
|
+
return refuse('email_not_verified');
|
|
30
|
+
// The break-glass account signs in with its password only. A provider account with its email
|
|
31
|
+
// must never link to it (which would rotate its password away) or sync it.
|
|
32
|
+
if (isBreakGlassEmail(options, identity.email))
|
|
33
|
+
return refuse('account_conflict');
|
|
34
|
+
const collection = options.collection;
|
|
35
|
+
const bySubject = { [OIDC_FIELDS.subject]: { equals: identity.subject } };
|
|
36
|
+
const role = roleForGroups(options.roleTable, identity.groups);
|
|
37
|
+
let user = await findOne(payload, collection, bySubject);
|
|
38
|
+
if (!role) {
|
|
39
|
+
if (user && !isBreakGlassEmail(options, user.email)) {
|
|
40
|
+
await disableUser(payload, options, user.id, now);
|
|
41
|
+
}
|
|
42
|
+
return refuse('no_role');
|
|
43
|
+
}
|
|
44
|
+
if (!user &&
|
|
45
|
+
canLinkByEmail(options, now) &&
|
|
46
|
+
(await linkByEmail(payload, options, identity.email, identity.subject))) {
|
|
47
|
+
user = await findOne(payload, collection, bySubject);
|
|
48
|
+
}
|
|
49
|
+
const names = namesFromIdentity(options, identity);
|
|
50
|
+
const managed = {
|
|
51
|
+
email: identity.email,
|
|
52
|
+
[options.firstNameField]: names.firstName,
|
|
53
|
+
[options.lastNameField]: names.lastName,
|
|
54
|
+
[options.roleField]: roleValue(options, role),
|
|
55
|
+
};
|
|
56
|
+
if (!user) {
|
|
57
|
+
// Someone else's email (a user who isn't linkable, or is linked to another subject): never
|
|
58
|
+
// take the account over and never create a duplicate.
|
|
59
|
+
if (await findOne(payload, collection, { email: { equals: identity.email } })) {
|
|
60
|
+
return refuse('account_conflict');
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const created = (await payload.create({
|
|
64
|
+
collection: collection,
|
|
65
|
+
context: syncContext,
|
|
66
|
+
data: {
|
|
67
|
+
...managed,
|
|
68
|
+
[OIDC_FIELDS.subject]: identity.subject,
|
|
69
|
+
// Required by local auth; nobody knows it, and password sign-in is closed anyway.
|
|
70
|
+
password: crypto.randomBytes(48).toString('base64url'),
|
|
71
|
+
},
|
|
72
|
+
depth: 0,
|
|
73
|
+
disableVerificationEmail: true,
|
|
74
|
+
overrideAccess: true,
|
|
75
|
+
}));
|
|
76
|
+
await markVerified(payload, options, created.id);
|
|
77
|
+
user = created;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!isDuplicateKeyError(error, [OIDC_FIELDS.subject, 'email']))
|
|
81
|
+
throw error;
|
|
82
|
+
// A second first sign-in for the same subject won the race (it may not have committed
|
|
83
|
+
// yet), or the email was just taken.
|
|
84
|
+
for (let attempt = 0; !user && attempt < 5; attempt += 1) {
|
|
85
|
+
if (attempt > 0)
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
87
|
+
user = await findOne(payload, collection, bySubject);
|
|
88
|
+
}
|
|
89
|
+
if (!user)
|
|
90
|
+
return refuse('account_conflict');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
try {
|
|
95
|
+
if (!(await writeManagedFields(payload, options, user.id, identity.subject, managed, now))) {
|
|
96
|
+
return refuse('disabled');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// The provider's email now belongs to another CMS user.
|
|
101
|
+
if (isDuplicateKeyError(error))
|
|
102
|
+
return refuse('account_conflict');
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
user = (await findOne(payload, collection, bySubject)) ?? user;
|
|
106
|
+
}
|
|
107
|
+
const sid = await pushSession(payload, options, user.id, identity.subject, now);
|
|
108
|
+
if (!sid)
|
|
109
|
+
return refuse('disabled');
|
|
110
|
+
const collectionConfig = authCollectionConfig(payload, collection);
|
|
111
|
+
const { exp, token } = await jwtSign({
|
|
112
|
+
fieldsToSign: getFieldsToSign({
|
|
113
|
+
collectionConfig,
|
|
114
|
+
email: identity.email,
|
|
115
|
+
sid,
|
|
116
|
+
user: { ...user, collection },
|
|
117
|
+
}),
|
|
118
|
+
secret: payload.secret,
|
|
119
|
+
tokenExpiration: options.sessionSeconds,
|
|
120
|
+
});
|
|
121
|
+
return { expiresAt: new Date(exp * 1000), kind: 'success', token, userId: user.id };
|
|
122
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReconcileSource } from '../options.js';
|
|
2
|
+
/**
|
|
3
|
+
* A reconcile source that reads the role-table groups' members from Authentik's API.
|
|
4
|
+
*
|
|
5
|
+
* The token needs only object-level `view_group` on those groups: reading a group returns its
|
|
6
|
+
* direct members with their `uid`, which equals the OIDC `sub` when the provider's subject mode
|
|
7
|
+
* is "based on the user's hashed ID". Every group must exist and be readable, or the fetch throws
|
|
8
|
+
* and reconcile changes nothing.
|
|
9
|
+
*
|
|
10
|
+
* @param options.url - Authentik's base URL, such as `https://auth.example.com`.
|
|
11
|
+
* @param options.token - An API token for a service account with `view_group` on the groups.
|
|
12
|
+
* @param options.queue - The Payload jobs queue the reconcile task runs on.
|
|
13
|
+
* @param options.cron - How often it runs (default every 15 minutes).
|
|
14
|
+
* @param options.fetch - For tests.
|
|
15
|
+
*/
|
|
16
|
+
export declare const authentikSource: (options: {
|
|
17
|
+
/** Allows an `http:` URL, for a local Authentik; refused when `NODE_ENV` is `production`. */
|
|
18
|
+
allowInsecure?: boolean;
|
|
19
|
+
cron?: string;
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
queue?: string;
|
|
22
|
+
/** Per request, in milliseconds (default 30 seconds). */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
token?: string;
|
|
25
|
+
url?: string;
|
|
26
|
+
}) => ReconcileSource | undefined;
|