@signal58/payload-oidc 0.0.0-stage → 0.2.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 +299 -2
- 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 +8 -0
- package/dist/index.js +7 -0
- package/dist/options.d.ts +159 -0
- package/dist/options.js +126 -0
- package/dist/plugin.d.ts +25 -0
- package/dist/plugin.js +217 -0
- package/dist/provider.d.ts +43 -0
- package/dist/provider.js +67 -0
- package/dist/reconcile-task.d.ts +12 -0
- package/dist/reconcile-task.js +48 -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 +90 -4
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createBeforeOperationHook, createRefreshHook, OIDC_REFUSAL_NAME } from './before-operation.js';
|
|
2
|
+
import { createEndpoints } from './endpoints.js';
|
|
3
|
+
import { createReconcileTask } from './reconcile-task.js';
|
|
4
|
+
import { resolveOptions } from './options.js';
|
|
5
|
+
import { createProviderClient } from './provider.js';
|
|
6
|
+
import { OIDC_FIELDS, OIDC_SYNC_CONTEXT } from './store.js';
|
|
7
|
+
/** Key under `config.custom` with the plugin's public (non-secret) state, for the login view. */
|
|
8
|
+
export const PUBLIC_STATE_KEY = 'payloadOidc';
|
|
9
|
+
/** Field access that lets only the plugin's sync write a field. */
|
|
10
|
+
export const syncWriteOnly = ({ req }) => req.context?.[OIDC_SYNC_CONTEXT] === true;
|
|
11
|
+
const denyField = () => false;
|
|
12
|
+
const pluginFields = (options) => [
|
|
13
|
+
{
|
|
14
|
+
name: OIDC_FIELDS.subject,
|
|
15
|
+
type: 'text',
|
|
16
|
+
label: 'Identity provider subject',
|
|
17
|
+
// Sparse unique index: the field is unset, never null, when there is no link.
|
|
18
|
+
unique: true,
|
|
19
|
+
index: true,
|
|
20
|
+
access: { create: denyField, update: denyField },
|
|
21
|
+
admin: {
|
|
22
|
+
description: 'The identity provider subject this account is linked to.',
|
|
23
|
+
position: 'sidebar',
|
|
24
|
+
readOnly: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: OIDC_FIELDS.disabledAt,
|
|
29
|
+
type: 'date',
|
|
30
|
+
label: 'Disabled by identity provider',
|
|
31
|
+
access: { create: denyField, update: denyField },
|
|
32
|
+
admin: {
|
|
33
|
+
date: { pickerAppearance: 'dayAndTime' },
|
|
34
|
+
description: 'Set when the identity provider removed this account’s role.',
|
|
35
|
+
position: 'sidebar',
|
|
36
|
+
readOnly: true,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: OIDC_FIELDS.linkable,
|
|
41
|
+
type: 'checkbox',
|
|
42
|
+
access: { create: denyField, update: denyField },
|
|
43
|
+
admin: { hidden: true },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
// "Remove user" on a disabled user; also hides "Change Password" while passwords are off.
|
|
47
|
+
// Stores nothing.
|
|
48
|
+
name: 'oidcRemove',
|
|
49
|
+
type: 'ui',
|
|
50
|
+
admin: {
|
|
51
|
+
components: {
|
|
52
|
+
Field: {
|
|
53
|
+
clientProps: { hidePasswordChange: options.enabled && !options.passwordLogin },
|
|
54
|
+
path: '@signal58/payload-oidc/client#RemoveUserButton',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
position: 'sidebar',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
const hasField = (fields, name) => fields.some((field) => ('name' in field && field.name === name) ||
|
|
62
|
+
((field.type === 'row' || field.type === 'collapsible') && hasField(field.fields, name)));
|
|
63
|
+
/**
|
|
64
|
+
* Copies the fields, giving each named top-level, row or collapsible data field `update` access
|
|
65
|
+
* that only sync passes. Copies, never edits: a field config the host shares with another
|
|
66
|
+
* collection must not be locked there too.
|
|
67
|
+
*/
|
|
68
|
+
const withSyncOnlyUpdate = (fields, names, found) => fields.map((field) => {
|
|
69
|
+
if ('name' in field && names.has(field.name)) {
|
|
70
|
+
found.add(field.name);
|
|
71
|
+
const access = field.access;
|
|
72
|
+
return { ...field, access: { ...access, update: syncWriteOnly } };
|
|
73
|
+
}
|
|
74
|
+
if (field.type === 'row' || field.type === 'collapsible') {
|
|
75
|
+
return { ...field, fields: withSyncOnlyUpdate(field.fields, names, found) };
|
|
76
|
+
}
|
|
77
|
+
return field;
|
|
78
|
+
});
|
|
79
|
+
/** Makes the provider-owned fields updatable only by sync, adding `email` so it can be locked too. */
|
|
80
|
+
const lockManagedFields = (collection, options) => {
|
|
81
|
+
const fields = [...(collection.fields ?? [])];
|
|
82
|
+
if (!hasField(fields, 'email'))
|
|
83
|
+
fields.push({ name: 'email', type: 'email' });
|
|
84
|
+
const names = new Set(['email', options.firstNameField, options.lastNameField, options.roleField]);
|
|
85
|
+
const found = new Set();
|
|
86
|
+
// Only update: creation is already limited to sync (and the break-glass first-register) by
|
|
87
|
+
// the beforeOperation hook, and first-register needs to write these fields.
|
|
88
|
+
const locked = withSyncOnlyUpdate(fields, names, found);
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
if (!found.has(name))
|
|
91
|
+
throw new Error(`payload-oidc: ${collection.slug}.${name} not found.`);
|
|
92
|
+
}
|
|
93
|
+
return locked;
|
|
94
|
+
};
|
|
95
|
+
/** Never store `oidcSubject: null`: the sparse unique index would make a second one collide. */
|
|
96
|
+
const dropNullSubject = ({ data }) => {
|
|
97
|
+
if (data && (data[OIDC_FIELDS.subject] === null || data[OIDC_FIELDS.subject] === '')) {
|
|
98
|
+
delete data[OIDC_FIELDS.subject];
|
|
99
|
+
}
|
|
100
|
+
return data;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Signs staff in to the Payload admin through an OpenID Connect provider, which becomes the source
|
|
104
|
+
* of truth for the auth collection's users and roles.
|
|
105
|
+
*
|
|
106
|
+
* With the issuer, client ID or client secret unset, the plugin adds its fields and inert
|
|
107
|
+
* endpoints so the schema stays the same, and changes nothing else: password sign-in works as
|
|
108
|
+
* before.
|
|
109
|
+
*/
|
|
110
|
+
export const oidcPlugin = (pluginOptions, testing) => (incoming) => {
|
|
111
|
+
const options = resolveOptions(pluginOptions);
|
|
112
|
+
const client = testing?.client ?? createProviderClient(options);
|
|
113
|
+
const collections = incoming.collections ?? [];
|
|
114
|
+
const target = collections.find(({ slug }) => slug === options.collection);
|
|
115
|
+
if (!target)
|
|
116
|
+
throw new Error(`payload-oidc: no "${options.collection}" collection.`);
|
|
117
|
+
if (!target.auth)
|
|
118
|
+
throw new Error(`payload-oidc: "${options.collection}" is not an auth collection.`);
|
|
119
|
+
// `endpoints: false` would be replaced by our list, and Payload would then add back every
|
|
120
|
+
// auth route, password ones included.
|
|
121
|
+
if (target.endpoints === false) {
|
|
122
|
+
throw new Error(`payload-oidc: "${options.collection}" must keep its REST endpoints.`);
|
|
123
|
+
}
|
|
124
|
+
options.verify = typeof target.auth === 'object' && Boolean(target.auth.verify);
|
|
125
|
+
const loginWithUsername = typeof target.auth === 'object' ? target.auth.loginWithUsername : undefined;
|
|
126
|
+
options.loginWithUsername = loginWithUsername
|
|
127
|
+
? {
|
|
128
|
+
allowEmailLogin: typeof loginWithUsername === 'object' && Boolean(loginWithUsername.allowEmailLogin),
|
|
129
|
+
}
|
|
130
|
+
: undefined;
|
|
131
|
+
const auth = typeof target.auth === 'object' ? target.auth : {};
|
|
132
|
+
const collection = {
|
|
133
|
+
...target,
|
|
134
|
+
// The admin then hides "Force Unlock"; the unlock operation is refused regardless.
|
|
135
|
+
access: options.enabled ? { ...target.access, unlock: () => false } : target.access,
|
|
136
|
+
auth: options.enabled
|
|
137
|
+
? { ...auth, tokenExpiration: options.sessionSeconds, useSessions: true }
|
|
138
|
+
: auth,
|
|
139
|
+
endpoints: [...(target.endpoints ?? []), ...createEndpoints(options, client, testing?.clock)],
|
|
140
|
+
fields: [
|
|
141
|
+
...(options.enabled ? lockManagedFields(target, options) : (target.fields ?? [])),
|
|
142
|
+
...pluginFields(options),
|
|
143
|
+
],
|
|
144
|
+
hooks: {
|
|
145
|
+
...target.hooks,
|
|
146
|
+
beforeChange: [...(target.hooks?.beforeChange ?? []), dropNullSubject],
|
|
147
|
+
beforeOperation: [
|
|
148
|
+
...(target.hooks?.beforeOperation ?? []),
|
|
149
|
+
createBeforeOperationHook(options, testing?.clock),
|
|
150
|
+
],
|
|
151
|
+
// First: Payload stops at the first refresh hook that returns a result, so a host hook
|
|
152
|
+
// placed before this one could sign an uncapped token.
|
|
153
|
+
refresh: [createRefreshHook(options, testing?.clock), ...(target.hooks?.refresh ?? [])],
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const apiRoute = incoming.routes?.api ?? '/api';
|
|
157
|
+
const publicState = {
|
|
158
|
+
authorizePath: `${apiRoute}/${options.collection}/oidc/authorize`,
|
|
159
|
+
buttonLabel: options.buttonLabel,
|
|
160
|
+
enabled: options.enabled,
|
|
161
|
+
passwordLogin: options.passwordLogin,
|
|
162
|
+
};
|
|
163
|
+
const onInit = incoming.onInit;
|
|
164
|
+
return {
|
|
165
|
+
...incoming,
|
|
166
|
+
// In an app that already has jobs, registered whether or not reconcile is configured, so the
|
|
167
|
+
// job schema doesn't depend on the environment (it is only scheduled when configured). In an
|
|
168
|
+
// app without jobs, only with a source, so the plugin never turns jobs on by itself.
|
|
169
|
+
...(incoming.jobs?.tasks?.length || options.reconcile
|
|
170
|
+
? {
|
|
171
|
+
jobs: {
|
|
172
|
+
...incoming.jobs,
|
|
173
|
+
tasks: [
|
|
174
|
+
...(incoming.jobs?.tasks ?? []),
|
|
175
|
+
createReconcileTask(options, {
|
|
176
|
+
clock: testing?.clock,
|
|
177
|
+
concurrencyControl: Boolean(incoming.jobs?.enableConcurrencyControl),
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
admin: {
|
|
184
|
+
...incoming.admin,
|
|
185
|
+
components: {
|
|
186
|
+
...incoming.admin?.components,
|
|
187
|
+
beforeLogin: [
|
|
188
|
+
...(incoming.admin?.components?.beforeLogin ?? []),
|
|
189
|
+
'@signal58/payload-oidc/rsc#OidcLoginButton',
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
collections: collections.map((candidate) => (candidate === target ? collection : candidate)),
|
|
194
|
+
// Refused password sign-ins and ended sessions are expected: not errors.
|
|
195
|
+
loggingLevels: {
|
|
196
|
+
...incoming.loggingLevels,
|
|
197
|
+
[OIDC_REFUSAL_NAME]: 'info',
|
|
198
|
+
},
|
|
199
|
+
custom: { ...incoming.custom, [PUBLIC_STATE_KEY]: publicState },
|
|
200
|
+
onInit: async (payload) => {
|
|
201
|
+
if (onInit)
|
|
202
|
+
await onInit(payload);
|
|
203
|
+
payload.logger.info(options.enabled
|
|
204
|
+
? `payload-oidc: single sign-on is on for "${options.collection}"; password sign-in is ${options.passwordLogin ? 'open to the break-glass account' : 'off'}.`
|
|
205
|
+
: `payload-oidc: off (issuer, client ID or client secret unset); password sign-in is unchanged.`);
|
|
206
|
+
if (options.enabled) {
|
|
207
|
+
const table = Object.entries(options.roleTable)
|
|
208
|
+
.map(([role, groups]) => `${role} ← ${groups.join(', ')}`)
|
|
209
|
+
.join('; ');
|
|
210
|
+
payload.logger.info(`payload-oidc: role table (highest first): ${table}.`);
|
|
211
|
+
payload.logger.info(options.reconcile
|
|
212
|
+
? `payload-oidc: reconcile runs on queue "${options.reconcile.queue}" (${options.reconcile.cron}); the app must auto-run that queue.`
|
|
213
|
+
: 'payload-oidc: reconcile is off (no source configured); removals apply at the next sign-in only.');
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as oidc from 'openid-client';
|
|
2
|
+
import type { ResolvedOidcOptions } from './options.js';
|
|
3
|
+
/** The verified claims the plugin uses from a sign-in. */
|
|
4
|
+
export type VerifiedIdentity = {
|
|
5
|
+
email: string;
|
|
6
|
+
emailVerified: boolean;
|
|
7
|
+
familyName?: string;
|
|
8
|
+
givenName?: string;
|
|
9
|
+
groups: unknown;
|
|
10
|
+
name?: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
};
|
|
13
|
+
export type ProviderClient = {
|
|
14
|
+
/** Builds the provider's authorization URL, discovering the provider on first use. */
|
|
15
|
+
authorizationUrl: (input: {
|
|
16
|
+
codeChallenge: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
state: string;
|
|
20
|
+
}) => Promise<URL>;
|
|
21
|
+
/**
|
|
22
|
+
* Exchanges the callback's code and validates the ID token: signature against the provider's
|
|
23
|
+
* JWKS, issuer, audience, expiry, `nonce`, plus `state` and the PKCE verifier.
|
|
24
|
+
*/
|
|
25
|
+
exchange: (input: {
|
|
26
|
+
callbackUrl: URL;
|
|
27
|
+
nonce: string;
|
|
28
|
+
state: string;
|
|
29
|
+
verifier: string;
|
|
30
|
+
}) => Promise<VerifiedIdentity>;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Creates the provider client. Discovery runs the first time sign-in needs it, never at startup
|
|
34
|
+
* or during a build, and a failed discovery isn't cached, so the next sign-in tries again.
|
|
35
|
+
*/
|
|
36
|
+
export declare const createProviderClient: (options: ResolvedOidcOptions, discover?: typeof oidc.discovery) => ProviderClient;
|
|
37
|
+
/** Fresh `state`, `nonce` and PKCE verifier for one sign-in. */
|
|
38
|
+
export declare const randomSignInSecrets: () => Promise<{
|
|
39
|
+
codeChallenge: string;
|
|
40
|
+
nonce: string;
|
|
41
|
+
state: string;
|
|
42
|
+
verifier: string;
|
|
43
|
+
}>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as oidc from 'openid-client';
|
|
2
|
+
const text = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Creates the provider client. Discovery runs the first time sign-in needs it, never at startup
|
|
5
|
+
* or during a build, and a failed discovery isn't cached, so the next sign-in tries again.
|
|
6
|
+
*/
|
|
7
|
+
export const createProviderClient = (options, discover = oidc.discovery) => {
|
|
8
|
+
let configuration;
|
|
9
|
+
// openid-client skips the ID token's signature when the token comes straight from the token
|
|
10
|
+
// endpoint (OIDC Core 3.1.3.7 lets a client rely on TLS); non-repudiation checks verify it
|
|
11
|
+
// against the provider's JWKS as well.
|
|
12
|
+
const execute = [
|
|
13
|
+
oidc.enableNonRepudiationChecks,
|
|
14
|
+
...(options.allowInsecureIssuer ? [oidc.allowInsecureRequests] : []),
|
|
15
|
+
];
|
|
16
|
+
const load = () => {
|
|
17
|
+
configuration ??= discover(new URL(options.issuer), options.clientId, options.clientSecret, undefined, { execute }).catch((error) => {
|
|
18
|
+
configuration = undefined;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
return configuration;
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
authorizationUrl: async ({ codeChallenge, nonce, redirectUri, state }) => oidc.buildAuthorizationUrl(await load(), {
|
|
25
|
+
code_challenge: codeChallenge,
|
|
26
|
+
code_challenge_method: 'S256',
|
|
27
|
+
nonce,
|
|
28
|
+
redirect_uri: redirectUri,
|
|
29
|
+
response_type: 'code',
|
|
30
|
+
scope: options.scopes.join(' '),
|
|
31
|
+
state,
|
|
32
|
+
}),
|
|
33
|
+
exchange: async ({ callbackUrl, nonce, state, verifier }) => {
|
|
34
|
+
const tokens = await oidc.authorizationCodeGrant(await load(), callbackUrl, {
|
|
35
|
+
expectedNonce: nonce,
|
|
36
|
+
expectedState: state,
|
|
37
|
+
idTokenExpected: true,
|
|
38
|
+
pkceCodeVerifier: verifier,
|
|
39
|
+
});
|
|
40
|
+
const claims = tokens.claims();
|
|
41
|
+
if (!claims)
|
|
42
|
+
throw new Error('payload-oidc: the provider returned no ID token.');
|
|
43
|
+
const email = text(claims.email)?.toLowerCase();
|
|
44
|
+
if (!email)
|
|
45
|
+
throw new Error('payload-oidc: the ID token has no email.');
|
|
46
|
+
return {
|
|
47
|
+
email,
|
|
48
|
+
emailVerified: claims.email_verified === true,
|
|
49
|
+
familyName: text(claims.family_name),
|
|
50
|
+
givenName: text(claims.given_name),
|
|
51
|
+
groups: claims.groups,
|
|
52
|
+
name: text(claims.name),
|
|
53
|
+
subject: claims.sub,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/** Fresh `state`, `nonce` and PKCE verifier for one sign-in. */
|
|
59
|
+
export const randomSignInSecrets = async () => {
|
|
60
|
+
const verifier = oidc.randomPKCECodeVerifier();
|
|
61
|
+
return {
|
|
62
|
+
codeChallenge: await oidc.calculatePKCECodeChallenge(verifier),
|
|
63
|
+
nonce: oidc.randomNonce(),
|
|
64
|
+
state: oidc.randomState(),
|
|
65
|
+
verifier,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TaskConfig } from 'payload';
|
|
2
|
+
import type { ResolvedOidcOptions } from './options.js';
|
|
3
|
+
export declare const RECONCILE_TASK_SLUG = "payload-oidc-reconcile";
|
|
4
|
+
/**
|
|
5
|
+
* The reconcile job. Payload schedules it only on its own queue, and runs schedules only for
|
|
6
|
+
* queues listed in `jobs.autoRun`, so the app must add that queue there. A failed run (a group
|
|
7
|
+
* that can't be read, a safety stop) fails the job and records no heartbeat.
|
|
8
|
+
*/
|
|
9
|
+
export declare const createReconcileTask: (options: ResolvedOidcOptions, { clock, concurrencyControl }?: {
|
|
10
|
+
clock?: (() => Date) | undefined;
|
|
11
|
+
concurrencyControl?: boolean | undefined;
|
|
12
|
+
}) => TaskConfig;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { reconcileUsers } from './reconcile.js';
|
|
2
|
+
export const RECONCILE_TASK_SLUG = 'payload-oidc-reconcile';
|
|
3
|
+
/**
|
|
4
|
+
* The reconcile job. Payload schedules it only on its own queue, and runs schedules only for
|
|
5
|
+
* queues listed in `jobs.autoRun`, so the app must add that queue there. A failed run (a group
|
|
6
|
+
* that can't be read, a safety stop) fails the job and records no heartbeat.
|
|
7
|
+
*/
|
|
8
|
+
export const createReconcileTask = (options, { clock = () => new Date(), concurrencyControl = false } = {}) => ({
|
|
9
|
+
slug: RECONCILE_TASK_SLUG,
|
|
10
|
+
label: 'Reconcile CMS users with the identity provider',
|
|
11
|
+
// One run at a time where the app enables Payload's concurrency control; otherwise Payload's
|
|
12
|
+
// scheduler still skips a schedule while a run is queued or running.
|
|
13
|
+
...(concurrencyControl ? { concurrency: () => RECONCILE_TASK_SLUG } : {}),
|
|
14
|
+
inputSchema: [],
|
|
15
|
+
outputSchema: [
|
|
16
|
+
{ name: 'disabled', type: 'number', required: true },
|
|
17
|
+
{ name: 'roleChanges', type: 'number', required: true },
|
|
18
|
+
{ name: 'users', type: 'number', required: true },
|
|
19
|
+
],
|
|
20
|
+
retries: 0,
|
|
21
|
+
...(options.enabled && options.reconcile
|
|
22
|
+
? {
|
|
23
|
+
schedule: [
|
|
24
|
+
{
|
|
25
|
+
cron: options.reconcile.cron,
|
|
26
|
+
hooks: {
|
|
27
|
+
...options.reconcileScheduleHooks,
|
|
28
|
+
// Payload can make a new job wait for the next cron slot. That slot's tick only runs
|
|
29
|
+
// the job, as one is still waiting, so reconcile would run on every other tick.
|
|
30
|
+
// With no wait, the tick that queues the job also runs it.
|
|
31
|
+
beforeSchedule: async (args) => ({
|
|
32
|
+
...(await (options.reconcileScheduleHooks?.beforeSchedule ?? args.defaultBeforeSchedule)(args)),
|
|
33
|
+
waitUntil: undefined,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
queue: options.reconcile.queue,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
: {}),
|
|
41
|
+
handler: async ({ req }) => {
|
|
42
|
+
if (!options.enabled || !options.reconcile) {
|
|
43
|
+
return { output: { disabled: 0, roleChanges: 0, users: 0 } };
|
|
44
|
+
}
|
|
45
|
+
const { disabled, roleChanges, users } = await reconcileUsers(req.payload, options, clock());
|
|
46
|
+
return { output: { disabled, roleChanges, users } };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -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>;
|