@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.
@@ -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
+ };
@@ -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>;
@@ -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;