@signal58/payload-oidc 0.2.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/README.md CHANGED
@@ -7,14 +7,16 @@ truth for the auth collection's users and roles.
7
7
  the ID token at every sign-in and can't be edited in Payload.
8
8
  - Roles come from the provider's `groups` claim through one role table, highest role first.
9
9
  - A user whose groups grant no role is refused and disabled: role and sessions cleared,
10
- `oidcDisabledAt` set, record kept.
10
+ `oidcDisabledAt` set, record kept. A disabled user stays editable even if the role field is
11
+ `required`.
11
12
  - Password sign-in is closed for the collection: `login`, `forgotPassword`, `resetPassword`,
12
13
  `unlock`, and setting a password through `update`, are refused through REST, the admin and the
13
14
  Local API alike. A break-glass account can be let back in with a flag.
14
15
  - A reconcile job (optional) reads the provider's role-table groups every 15 minutes and disables
15
16
  users who left them, so removal doesn't wait for a sign-in. It changes nothing unless every
16
17
  group was read, stops if it would disable too many users at once, and records a heartbeat that
17
- an uptime check can watch.
18
+ an uptime check can watch. A run a deploy cut off is released at the next schedule.
19
+ - A migration helper marks existing users as linkable by email, once, for a linking window.
18
20
  - A superadmin (the app decides who) can remove a disabled user: deleted if nothing refers to
19
21
  them, anonymised otherwise.
20
22
  - Sessions are ordinary Payload sessions: an idle timeout (`sessionHours`) that activity extends,
@@ -38,7 +40,7 @@ dependencies. The package is ESM only.
38
40
  import { oidcPlugin } from '@signal58/payload-oidc'
39
41
 
40
42
  export default buildConfig({
41
- serverURL: process.env.SERVER_URL, // required: the redirect URI is built from it
43
+ serverURL: process.env.SERVER_URL, // or the plugin's serverURL: the redirect URI is built from it
42
44
  plugins: [
43
45
  oidcPlugin({
44
46
  collection: 'users',
@@ -87,6 +89,7 @@ Endpoint paths are relative to the collection's REST route, `${routes.api}/<coll
87
89
  | `beforeOperation` hook | Closes password operations and creation outside sync; refuses `refresh` after the cap. |
88
90
  | `refresh` hook | Signs a refreshed token that ends at the absolute cap. |
89
91
  | `beforeChange` hook | Drops an `oidcSubject` of `null` or `''`, which would collide in the sparse index. |
92
+ | Role field `validate` | No role passes only on a disabled user; anything else goes to the field's own validation (see below). |
90
93
  | `beforeLogin` component | The sign-in button and error message; hides the password form while it's closed. |
91
94
  | `POST /oidc/remove/:id` | Removes a disabled user (`removeAccess`, default nobody); a "Remove user" button in the sidebar calls it. |
92
95
  | `unlock` access denied | Payload then hides "Force Unlock"; the operation is refused anyway. |
@@ -95,6 +98,11 @@ Endpoint paths are relative to the collection's REST route, `${routes.api}/<coll
95
98
  | `GET /oidc/reconcile/health` | 200 while the last successful reconcile is recent, 503 once it isn't; for an uptime check. |
96
99
  | `payload-oidc-reconcile` task | Registered always; scheduled on `reconcile.queue` only when a source is configured. |
97
100
 
101
+ The role field's validation is wrapped, whether or not sign-in is on: an empty role (an empty
102
+ list, or no single value) passes while `oidcDisabledAt` is set, so a `required` role field doesn't
103
+ stop an admin saving a disabled user. Anything else goes to the field's own `validate`, or to
104
+ Payload's default for its type (`required`, known options).
105
+
98
106
  The managed fields (`email`, first and last name, the role field) get `update` access that only
99
107
  the plugin's sync passes (`req.context.oidcSync`). **If your app replaces field or collection
100
108
  access after plugins run, keep those rules**: no updates to the managed fields outside sync, no
@@ -166,6 +174,52 @@ Each run:
166
174
  - records a heartbeat in Payload's KV store; `GET /oidc/reconcile/health` answers 503 once it is
167
175
  older than `reconcileHeartbeatMaxMinutes` (default 45).
168
176
 
177
+ A run cut off mid-way, for example by a deploy that replaced the process, would stay marked as
178
+ processing, and Payload's scheduler queues nothing while it does. So before each schedule, the
179
+ plugin marks reconcile runs on the schedule's queue still processing after
180
+ `reconcileStaleAfterMinutes` (default 10, at least 1) as failed, with Payload's own cancel, and the
181
+ same tick queues a new run. The dead run stays in the jobs collection as a failed job.
182
+
183
+ Payload doesn't touch a job while its handler runs, so a live run that went on past the threshold
184
+ would look the same. To rule that out, a run whose source hasn't answered within 80% of the
185
+ threshold (8 minutes by default; `authentikSource` gives up on a request after 30 seconds) stops
186
+ before changing anything and fails, and the next tick tries again. Cancelling only marks the job:
187
+ a process that was paused rather than stopped, and resumes, finishes its run alongside the new
188
+ one. Reconcile's writes are conditional, so the two don't undo each other, but the cancelled job
189
+ still shows as failed.
190
+
191
+ `reconcileStaleAfterMinutes: false` turns off both the release and the time limit, as in 0.2.0. A
192
+ `beforeSchedule` in `reconcileScheduleHooks` runs after the release, in place of Payload's default
193
+ rule.
194
+
195
+ ## Existing users
196
+
197
+ Users who were in the collection before the plugin can be linked to their provider account once,
198
+ by verified email, until `linkByEmailUntil`. Mark them from the app's own migration:
199
+
200
+ ```ts
201
+ import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-mongodb'
202
+ import { clearLinkingSnapshot, takeLinkingSnapshot } from '@signal58/payload-oidc'
203
+
204
+ export async function up(args: MigrateUpArgs): Promise<void> {
205
+ await takeLinkingSnapshot(args) // { collection: 'users' } by default
206
+ }
207
+
208
+ export async function down(args: MigrateDownArgs): Promise<void> {
209
+ await clearLinkingSnapshot(args)
210
+ }
211
+ ```
212
+
213
+ `takeLinkingSnapshot` builds the collection's indexes first (among them the sparse unique
214
+ `oidcSubject` index, outside the migration's transaction), refuses a user that stores
215
+ `oidcSubject: null`, then sets `oidcLinkable: true` on every user without an `oidcSubject`. A rerun
216
+ marks nobody new. **Call it first in the migration**, before any other operation in its
217
+ transaction, on any collection: a transaction reads from the moment of its first operation, so
218
+ after an index build that had work to do, its write fails with a write conflict (the error says
219
+ so, and running the migration again passes). An index that fails to build, the plugin's or not,
220
+ fails the migration with the collection named. Users created later, at their first sign-in, are
221
+ never linkable. `clearLinkingSnapshot` removes the marks and keeps links and disables.
222
+
169
223
  ## Removing users
170
224
 
171
225
  `POST ${routes.api}/<collection>/oidc/remove/:id`, or the sidebar's "Remove user" on a disabled
@@ -191,29 +245,31 @@ first-register screen while the flag is on; it can't set the plugin's fields the
191
245
 
192
246
  ## Options
193
247
 
194
- | Option | Default | Notes |
195
- | ------------------------------------ | ------------------------------- | ----------------------------------------------------------------- |
196
- | `collection` | `'users'` | An auth collection. |
197
- | `issuer`, `clientId`, `clientSecret` | — | All three, or the plugin is off. |
198
- | `roles.field` | — | The role field. |
199
- | `roles.table` | — | Role → groups, highest role first. |
200
- | `roles.hasMany` | `true` | `false` stores a single value. |
201
- | `fields` | `firstName`, `lastName` | Names of the name fields. |
202
- | `lastNamePlaceholder` | `'-'` | Used when the provider sends no `family_name`. |
203
- | `sessionHours` | `8` | Becomes the collection's `tokenExpiration`. |
204
- | `sessionMaxHours` | `12` | Refresh is refused after this, from sign-in. |
205
- | `passwordLogin` | `false` | Needs `breakGlassEmail`. |
206
- | `breakGlassEmail` | — | |
207
- | `linkByEmailUntil` | — | ISO date; without it nobody is linked by email. |
208
- | `scopes` | `['openid','email','profile']` | |
209
- | `additionalServerURLs` | — | Other public origins, used when a request arrives on one of them. |
210
- | `reconcile` | — | A `ReconcileSource`, such as `authentikSource(...)`. |
211
- | `reconcileMaxDisableShare` | `0.25` | |
212
- | `reconcileHeartbeatMaxMinutes` | `45` | |
213
- | `removeAccess` | nobody | `(req) => boolean`. |
214
- | `serverURL` | Payload's `serverURL` | The public origin the callback is registered under. |
215
- | `buttonLabel` | `'Sign in with single sign-on'` | |
216
- | `allowInsecureIssuer` | `false` | Allows an `http:` issuer, never when `NODE_ENV=production`. |
248
+ | Option | Default | Notes |
249
+ | ------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------ |
250
+ | `collection` | `'users'` | An auth collection. |
251
+ | `issuer`, `clientId`, `clientSecret` | — | All three, or the plugin is off. |
252
+ | `roles.field` | — | The role field. |
253
+ | `roles.table` | — | Role → groups, highest role first. |
254
+ | `roles.hasMany` | `true` | `false` stores a single value. |
255
+ | `fields` | `firstName`, `lastName` | Names of the name fields. |
256
+ | `lastNamePlaceholder` | `'-'` | Used when the provider sends no `family_name`. |
257
+ | `sessionHours` | `8` | Becomes the collection's `tokenExpiration`. |
258
+ | `sessionMaxHours` | `12` | Refresh is refused after this, from sign-in. |
259
+ | `passwordLogin` | `false` | Needs `breakGlassEmail`. |
260
+ | `breakGlassEmail` | — | |
261
+ | `linkByEmailUntil` | — | ISO date; without it nobody is linked by email. |
262
+ | `scopes` | `['openid','email','profile']` | |
263
+ | `additionalServerURLs` | — | Other public origins, used when a request arrives on one of them. |
264
+ | `reconcile` | — | A `ReconcileSource`, such as `authentikSource(...)`. |
265
+ | `reconcileMaxDisableShare` | `0.25` | |
266
+ | `reconcileHeartbeatMaxMinutes` | `45` | |
267
+ | `reconcileStaleAfterMinutes` | `10` | Releases a run still processing after this (at least 1); `false` turns it off. |
268
+ | `reconcileScheduleHooks` | — | Payload schedule hooks for the reconcile task. |
269
+ | `removeAccess` | nobody | `(req) => boolean`. |
270
+ | `serverURL` | Payload's `serverURL` | The public origin the callback is registered under. |
271
+ | `buttonLabel` | `'Sign in with single sign-on'` | |
272
+ | `allowInsecureIssuer` | `false` | Allows an `http:` issuer, never when `NODE_ENV=production`. |
217
273
 
218
274
  ## Adopting in an app
219
275
 
@@ -227,8 +283,9 @@ The plugin owns sign-in, the managed fields and the password refusals. These are
227
283
  2. **Dependency.** Pin an exact version. Check that `pnpm why @payloadcms/ui` shows one version: a
228
284
  second copy breaks the admin components' React context.
229
285
  3. **`serverURL` in every environment**, Payload's or the plugin's option, because the redirect URI
230
- is built from it. A second public origin (a LAN host, say) goes in `additionalServerURLs`, with
231
- its own callback registered.
286
+ is built from it. An app that leaves Payload's unset outside production passes the plugin's
287
+ instead. A second public origin (a LAN host, say) goes in `additionalServerURLs`, with its own
288
+ callback registered.
232
289
  4. **Access your app sets after plugins run.** If a later step replaces collection or field access,
233
290
  keep the plugin's rules there: `create` only for sync writes (`req.context[OIDC_SYNC_CONTEXT]`),
234
291
  `delete` denied (removal goes through `removeAccess` and the plugin's endpoint), `unlock`
@@ -240,15 +297,17 @@ The plugin owns sign-in, the managed fields and the password refusals. These are
240
297
  admin) must let sync writes through in both directions: the provider is right, and the
241
298
  break-glass account remains. Sign-in updates existing users directly in MongoDB without hooks;
242
299
  creating a user goes through Payload, so hooks run then.
243
- 7. **Empty roles.** A disabled user has no role. If the role field is `required`, its validation
244
- must allow an empty list while `oidcDisabledAt` is set; the plugin doesn't do this yet.
245
- 8. **Existing users.** Write a migration that builds the collection's indexes (the sparse unique
246
- `oidcSubject` index, before writing in a transaction) and sets `oidcLinkable: true` on the
247
- users who may link by email, skipping any already linked. Set `linkByEmailUntil` to the end of
248
- the linking window. Users created later are never linkable.
300
+ 7. **Empty roles.** A disabled user has no role. The plugin's validation allows that on a
301
+ `required` role field (since 0.3.0), and keeps the field's own `validate`. An app-level rule
302
+ that does the same can go.
303
+ 8. **Existing users.** Add a migration that calls `takeLinkingSnapshot` first (since 0.3.0; see
304
+ [Existing users](#existing-users)), and set `linkByEmailUntil` to the end of the linking window.
249
305
  9. **Jobs.** With `reconcile`, add its queue (`oidc-reconcile` for `authentikSource`) to
250
- `jobs.autoRun` every 15 minutes. A run cut off by a deploy stays locked and blocks the queue,
251
- so release stale runs before each schedule; the plugin doesn't do this yet.
306
+ `jobs.autoRun` every 15 minutes. A run a deploy cut off is released before the next schedule
307
+ (since 0.3.0), so an app-level hook that does this for the reconcile task can go. A custom
308
+ `ReconcileSource` must answer within 80% of `reconcileStaleAfterMinutes`. If the app
309
+ post-processes its jobs config (locks the jobs-stats global, say), check the reconcile task's
310
+ schedule still works.
252
311
  10. **Generated files.** Run `payload generate:importmap` and `payload generate:types`, and commit
253
312
  both.
254
313
  11. **Break-glass.** A dedicated account that is never linked, with its password stored safely.
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export { authentikSource } from './sources/authentik.js';
6
6
  export { reconcileUsers, RECONCILE_HEARTBEAT_KEY } from './reconcile.js';
7
7
  export { RECONCILE_TASK_SLUG } from './reconcile-task.js';
8
8
  export { findUserReferences, removeUser } from './remove.js';
9
+ export { clearLinkingSnapshot, takeLinkingSnapshot, type OidcMigrationArgs, type OidcMigrationOptions, } from './migration.js';
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export { authentikSource } from './sources/authentik.js';
5
5
  export { reconcileUsers, RECONCILE_HEARTBEAT_KEY } from './reconcile.js';
6
6
  export { RECONCILE_TASK_SLUG } from './reconcile-task.js';
7
7
  export { findUserReferences, removeUser } from './remove.js';
8
+ export { clearLinkingSnapshot, takeLinkingSnapshot, } from './migration.js';
@@ -0,0 +1,47 @@
1
+ import type { Payload } from 'payload';
2
+ /**
3
+ * What the helpers need from a migration's arguments (`MigrateUpArgs` or `MigrateDownArgs` from
4
+ * `@payloadcms/db-mongodb`): pass those straight through.
5
+ */
6
+ export type OidcMigrationArgs = {
7
+ payload: Payload;
8
+ /** The migration's MongoDB session, when `payload migrate` runs it in a transaction. */
9
+ session?: unknown;
10
+ };
11
+ export type OidcMigrationOptions = {
12
+ /** The auth collection the plugin is on. */
13
+ collection?: string;
14
+ };
15
+ /**
16
+ * For an app's migration, before sign-in is turned on: marks every existing user that isn't
17
+ * linked yet `oidcLinkable`, so each can be linked once, by verified email, until
18
+ * `linkByEmailUntil`. Users created later (at their first sign-in) are never linkable, so a new
19
+ * provider account can never claim one of them by email.
20
+ *
21
+ * It builds the collection's indexes first, among them the sparse unique `oidcSubject` index:
22
+ * Mongoose starts that build in the background when `payload migrate` boots, and writing in the
23
+ * migration's transaction while it runs fails with a transient catalog-change error. The build is
24
+ * idempotent and runs outside the transaction. **Call this first in the migration**, before any
25
+ * other operation in its transaction, on any collection: a transaction reads from the moment of
26
+ * its first operation, so after a build that had work to do its write to the collection fails
27
+ * (a write conflict, and a rerun of the migration then passes). It refuses, before any change, a
28
+ * user that stores `oidcSubject: null`, which the sparse index would still index (and collide
29
+ * on). A user with an `oidcSubject` is already linked and left alone, so a rerun is a no-op.
30
+ *
31
+ * ```ts
32
+ * export async function up(args: MigrateUpArgs): Promise<void> {
33
+ * await takeLinkingSnapshot(args)
34
+ * }
35
+ * ```
36
+ *
37
+ * @returns How many users were marked.
38
+ */
39
+ export declare const takeLinkingSnapshot: ({ payload, session }: OidcMigrationArgs, { collection: slug }?: OidcMigrationOptions) => Promise<number>;
40
+ /**
41
+ * The `down` of {@link takeLinkingSnapshot}: clears every `oidcLinkable` mark. Links already made
42
+ * (`oidcSubject`) and disables (`oidcDisabledAt`) stay: they are the provider's record of who
43
+ * these users are, and with the plugin off they do nothing.
44
+ *
45
+ * @returns How many marks were cleared.
46
+ */
47
+ export declare const clearLinkingSnapshot: ({ payload, session }: OidcMigrationArgs, { collection: slug }?: OidcMigrationOptions) => Promise<number>;
@@ -0,0 +1,89 @@
1
+ import { OIDC_FIELDS } from './store.js';
2
+ /** The collection's MongoDB collection, written directly, and its compiled model. */
3
+ const usersCollection = (payload, slug) => {
4
+ const db = payload.db;
5
+ const model = db.collections?.[slug];
6
+ const name = model?.collection?.name;
7
+ const database = db.connection?.db;
8
+ if (!model || !name || !database) {
9
+ throw new Error(`payload-oidc: the migration needs the MongoDB adapter (no model for "${slug}").`);
10
+ }
11
+ const config = payload.collections[slug]?.config;
12
+ const hasPluginFields = config?.fields.some((field) => field.name === OIDC_FIELDS.subject);
13
+ if (!hasPluginFields) {
14
+ throw new Error(`payload-oidc: "${slug}" has no ${OIDC_FIELDS.subject} field; add the plugin to the config before this migration runs.`);
15
+ }
16
+ // Direct writes: through the model, Mongoose would also stamp `updatedAt` on every user.
17
+ return { collection: database.collection(name), model };
18
+ };
19
+ /** MongoDB's write conflict, which a transaction gets when the collection changed under it. */
20
+ const isWriteConflict = (error) => {
21
+ const candidate = error;
22
+ return candidate?.code === 112 || candidate?.hasErrorLabel?.('TransientTransactionError') === true;
23
+ };
24
+ /**
25
+ * For an app's migration, before sign-in is turned on: marks every existing user that isn't
26
+ * linked yet `oidcLinkable`, so each can be linked once, by verified email, until
27
+ * `linkByEmailUntil`. Users created later (at their first sign-in) are never linkable, so a new
28
+ * provider account can never claim one of them by email.
29
+ *
30
+ * It builds the collection's indexes first, among them the sparse unique `oidcSubject` index:
31
+ * Mongoose starts that build in the background when `payload migrate` boots, and writing in the
32
+ * migration's transaction while it runs fails with a transient catalog-change error. The build is
33
+ * idempotent and runs outside the transaction. **Call this first in the migration**, before any
34
+ * other operation in its transaction, on any collection: a transaction reads from the moment of
35
+ * its first operation, so after a build that had work to do its write to the collection fails
36
+ * (a write conflict, and a rerun of the migration then passes). It refuses, before any change, a
37
+ * user that stores `oidcSubject: null`, which the sparse index would still index (and collide
38
+ * on). A user with an `oidcSubject` is already linked and left alone, so a rerun is a no-op.
39
+ *
40
+ * ```ts
41
+ * export async function up(args: MigrateUpArgs): Promise<void> {
42
+ * await takeLinkingSnapshot(args)
43
+ * }
44
+ * ```
45
+ *
46
+ * @returns How many users were marked.
47
+ */
48
+ export const takeLinkingSnapshot = async ({ payload, session }, { collection: slug = 'users' } = {}) => {
49
+ const { collection, model } = usersCollection(payload, slug);
50
+ // Outside the transaction, like the build: a read in it would hold the collection until commit.
51
+ const nullSubjects = await collection.countDocuments({ [OIDC_FIELDS.subject]: { $type: 'null' } }, {});
52
+ if (nullSubjects > 0) {
53
+ throw new Error(`payload-oidc: a user of "${slug}" stores ${OIDC_FIELDS.subject} as null.`);
54
+ }
55
+ try {
56
+ await model.createIndexes();
57
+ }
58
+ catch (error) {
59
+ // Every index on the collection is built here, not only the plugin's.
60
+ throw new Error(`payload-oidc: building the indexes of "${slug}" failed: ${String(error)}`, {
61
+ cause: error,
62
+ });
63
+ }
64
+ let modifiedCount;
65
+ try {
66
+ ;
67
+ ({ modifiedCount } = await collection.updateMany({ [OIDC_FIELDS.linkable]: { $exists: false }, [OIDC_FIELDS.subject]: { $exists: false } }, { $set: { [OIDC_FIELDS.linkable]: true } }, { session }));
68
+ }
69
+ catch (error) {
70
+ if (!isWriteConflict(error))
71
+ throw error;
72
+ throw new Error(`payload-oidc: the linking snapshot conflicted with the index build on "${slug}". Call takeLinkingSnapshot before any other operation in the migration, or run the migration again.`, { cause: error });
73
+ }
74
+ payload.logger.info(`payload-oidc: marked ${modifiedCount} users of "${slug}" linkable.`);
75
+ return modifiedCount;
76
+ };
77
+ /**
78
+ * The `down` of {@link takeLinkingSnapshot}: clears every `oidcLinkable` mark. Links already made
79
+ * (`oidcSubject`) and disables (`oidcDisabledAt`) stay: they are the provider's record of who
80
+ * these users are, and with the plugin off they do nothing.
81
+ *
82
+ * @returns How many marks were cleared.
83
+ */
84
+ export const clearLinkingSnapshot = async ({ payload, session }, { collection: slug = 'users' } = {}) => {
85
+ const { collection } = usersCollection(payload, slug);
86
+ const { modifiedCount } = await collection.updateMany({ [OIDC_FIELDS.linkable]: { $exists: true } }, { $unset: { [OIDC_FIELDS.linkable]: '' } }, { session });
87
+ payload.logger.info(`payload-oidc: cleared ${modifiedCount} linkable marks on "${slug}".`);
88
+ return modifiedCount;
89
+ };
package/dist/options.d.ts CHANGED
@@ -67,8 +67,19 @@ export type OidcPluginOptions = {
67
67
  reconcileHeartbeatMaxMinutes?: number;
68
68
  /** Fields an anonymised user keeps (by default every profile field is cleared). */
69
69
  keepOnRemove?: string[];
70
- /** Hooks for the reconcile schedule, such as one that releases jobs a deploy cut off. */
70
+ /**
71
+ * Hooks for the reconcile schedule. A `beforeSchedule` hook runs after stale runs are released
72
+ * (`reconcileStaleAfterMinutes`), in place of Payload's default rule.
73
+ */
71
74
  reconcileScheduleHooks?: ReconcileScheduleHooks;
75
+ /**
76
+ * A reconcile run still processing after this many minutes was cut off, for example by a deploy
77
+ * that replaced the process mid-run, and would block the queue for good. Before each schedule,
78
+ * such runs are marked failed with Payload's own cancel, so a new one can be queued. A live run
79
+ * therefore stops, before changing anything, if the source hasn't answered within 80% of this.
80
+ * `false` turns both off. At least 1.
81
+ */
82
+ reconcileStaleAfterMinutes?: number | false;
72
83
  /** Who may remove a disabled user. Defaults to nobody, so an app must decide. */
73
84
  removeAccess?: (req: PayloadRequest) => boolean | Promise<boolean>;
74
85
  roles: {
@@ -118,6 +129,8 @@ export type ResolvedOidcOptions = {
118
129
  reconcile?: ReconcileSource;
119
130
  keepOnRemove: string[];
120
131
  reconcileScheduleHooks?: ReconcileScheduleHooks;
132
+ /** Unset when releasing stale reconcile runs is off. */
133
+ reconcileStaleAfterSeconds?: number;
121
134
  reconcileHeartbeatMaxSeconds: number;
122
135
  reconcileMaxDisableShare: number;
123
136
  removeAccess: (req: PayloadRequest) => boolean | Promise<boolean>;
package/dist/options.js CHANGED
@@ -5,6 +5,13 @@ const positiveHours = (value, fallback, name) => {
5
5
  }
6
6
  return hours;
7
7
  };
8
+ const atLeastOneMinute = (value, fallback, name) => {
9
+ const minutes = value ?? fallback;
10
+ if (!Number.isFinite(minutes) || minutes < 1) {
11
+ throw new Error(`payload-oidc: ${name} must be a number of minutes, at least 1.`);
12
+ }
13
+ return minutes;
14
+ };
8
15
  const toOrigin = (url, name) => {
9
16
  try {
10
17
  return new URL(url.trim()).origin;
@@ -80,6 +87,10 @@ export const resolveOptions = (options) => {
80
87
  reconcile: options.reconcile,
81
88
  keepOnRemove: options.keepOnRemove ?? [],
82
89
  reconcileScheduleHooks: options.reconcileScheduleHooks,
90
+ reconcileStaleAfterSeconds: options.reconcileStaleAfterMinutes === false
91
+ ? undefined
92
+ : Math.round(atLeastOneMinute(options.reconcileStaleAfterMinutes, 10, 'reconcileStaleAfterMinutes') *
93
+ 60),
83
94
  reconcileHeartbeatMaxSeconds: Math.round(positiveHours((options.reconcileHeartbeatMaxMinutes ?? 45) / 60, 0.75, 'reconcileHeartbeatMaxMinutes') * 3600),
84
95
  reconcileMaxDisableShare: shareBetweenZeroAndOne(options.reconcileMaxDisableShare ?? 0.25),
85
96
  removeAccess: options.removeAccess ?? (() => false),
package/dist/plugin.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FieldAccess, Plugin } from 'payload';
1
+ import { type FieldAccess, type Plugin } from 'payload';
2
2
  import { type OidcPluginOptions } from './options.js';
3
3
  import { type ProviderClient } from './provider.js';
4
4
  /** Key under `config.custom` with the plugin's public (non-secret) state, for the login view. */
package/dist/plugin.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validations, } from 'payload';
1
2
  import { createBeforeOperationHook, createRefreshHook, OIDC_REFUSAL_NAME } from './before-operation.js';
2
3
  import { createEndpoints } from './endpoints.js';
3
4
  import { createReconcileTask } from './reconcile-task.js';
@@ -61,21 +62,49 @@ const pluginFields = (options) => [
61
62
  const hasField = (fields, name) => fields.some((field) => ('name' in field && field.name === name) ||
62
63
  ((field.type === 'row' || field.type === 'collapsible') && hasField(field.fields, name)));
63
64
  /**
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.
65
+ * Copies the fields, replacing each named top-level, row or collapsible data field with
66
+ * `replace(field)`. Copies, never edits: a field config the host shares with another collection
67
+ * must not change there too.
67
68
  */
68
- const withSyncOnlyUpdate = (fields, names, found) => fields.map((field) => {
69
+ const mapNamedFields = (fields, names, found, replace) => fields.map((field) => {
69
70
  if ('name' in field && names.has(field.name)) {
70
71
  found.add(field.name);
71
- const access = field.access;
72
- return { ...field, access: { ...access, update: syncWriteOnly } };
72
+ return replace(field);
73
73
  }
74
74
  if (field.type === 'row' || field.type === 'collapsible') {
75
- return { ...field, fields: withSyncOnlyUpdate(field.fields, names, found) };
75
+ return { ...field, fields: mapNamedFields(field.fields, names, found, replace) };
76
76
  }
77
77
  return field;
78
78
  });
79
+ /** Gives a field `update` access that only sync passes. */
80
+ const syncOnlyUpdate = (field) => {
81
+ const access = field.access;
82
+ return { ...field, access: { ...access, update: syncWriteOnly } };
83
+ };
84
+ /** No role: an empty list, or no single value. */
85
+ const isNoRole = (value) => Array.isArray(value) ? value.length === 0 : value === null || value === undefined || value === '';
86
+ /**
87
+ * Lets a disabled user have no role. Disabling clears the role, so with a `required` role field
88
+ * every later save of that user, such as an admin editing a profile field, would fail. No role
89
+ * passes only while `oidcDisabledAt` is set; anything else goes to the field's own `validate`, or
90
+ * to Payload's default for its type (known options, `required` and so on).
91
+ */
92
+ const allowNoRoleWhenDisabled = (field) => {
93
+ const own = field.validate;
94
+ const fallback = validations[field.type];
95
+ const validate = (value, args) => {
96
+ const siblingData = args?.siblingData;
97
+ if (isNoRole(value) && Boolean(siblingData?.[OIDC_FIELDS.disabledAt]))
98
+ return true;
99
+ if (own)
100
+ return own(value, args);
101
+ // As Payload applies its default (it does so only to fields without a `validate`).
102
+ return fallback ? fallback(value, { ...field, ...args }) : true;
103
+ };
104
+ return { ...field, validate };
105
+ };
106
+ /** The fields with the role field's validation allowing no role on a disabled user. */
107
+ const withRoleValidation = (fields, options) => mapNamedFields(fields, new Set([options.roleField]), new Set(), allowNoRoleWhenDisabled);
79
108
  /** Makes the provider-owned fields updatable only by sync, adding `email` so it can be locked too. */
80
109
  const lockManagedFields = (collection, options) => {
81
110
  const fields = [...(collection.fields ?? [])];
@@ -85,7 +114,7 @@ const lockManagedFields = (collection, options) => {
85
114
  const found = new Set();
86
115
  // Only update: creation is already limited to sync (and the break-glass first-register) by
87
116
  // the beforeOperation hook, and first-register needs to write these fields.
88
- const locked = withSyncOnlyUpdate(fields, names, found);
117
+ const locked = mapNamedFields(fields, names, found, syncOnlyUpdate);
89
118
  for (const name of names) {
90
119
  if (!found.has(name))
91
120
  throw new Error(`payload-oidc: ${collection.slug}.${name} not found.`);
@@ -138,7 +167,9 @@ export const oidcPlugin = (pluginOptions, testing) => (incoming) => {
138
167
  : auth,
139
168
  endpoints: [...(target.endpoints ?? []), ...createEndpoints(options, client, testing?.clock)],
140
169
  fields: [
141
- ...(options.enabled ? lockManagedFields(target, options) : (target.fields ?? [])),
170
+ // Role validation whether or not sign-in is on: users disabled before it was turned off
171
+ // keep no role, and must still be editable.
172
+ ...withRoleValidation(options.enabled ? lockManagedFields(target, options) : (target.fields ?? []), options),
142
173
  ...pluginFields(options),
143
174
  ],
144
175
  hooks: {
@@ -1,6 +1,22 @@
1
- import type { TaskConfig } from 'payload';
1
+ import type { Payload, TaskConfig } from 'payload';
2
2
  import type { ResolvedOidcOptions } from './options.js';
3
3
  export declare const RECONCILE_TASK_SLUG = "payload-oidc-reconcile";
4
+ /**
5
+ * Marks reconcile runs on `queue` that are still processing after `olderThanSeconds` as failed,
6
+ * and returns the IDs it marked.
7
+ *
8
+ * Payload's scheduler queues a run only while no scheduled run of the task is waiting or
9
+ * processing, and its runner only picks up jobs that aren't processing. A run left processing by a
10
+ * process that stopped mid-run (a container a deploy replaced) would therefore block the queue for
11
+ * good: Payload never expires it. Payload's own cancel writes its final-failure shape
12
+ * (`hasError`, `processing: false`, `error.cancelled`), so the dead run stays visible as a failed
13
+ * job and the scheduler stops counting it.
14
+ */
15
+ export declare const releaseStaleRuns: (payload: Payload, { now, olderThanSeconds, queue }: {
16
+ now: Date;
17
+ olderThanSeconds: number;
18
+ queue: string;
19
+ }) => Promise<Array<number | string>>;
4
20
  /**
5
21
  * The reconcile job. Payload schedules it only on its own queue, and runs schedules only for
6
22
  * queues listed in `jobs.autoRun`, so the app must add that queue there. A failed run (a group
@@ -1,5 +1,94 @@
1
1
  import { reconcileUsers } from './reconcile.js';
2
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
+ };
3
92
  /**
4
93
  * The reconcile job. Payload schedules it only on its own queue, and runs schedules only for
5
94
  * queues listed in `jobs.autoRun`, so the app must add that queue there. A failed run (a group
@@ -25,13 +114,34 @@ export const createReconcileTask = (options, { clock = () => new Date(), concurr
25
114
  cron: options.reconcile.cron,
26
115
  hooks: {
27
116
  ...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
- }),
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
+ },
35
145
  },
36
146
  queue: options.reconcile.queue,
37
147
  },
@@ -42,7 +152,12 @@ export const createReconcileTask = (options, { clock = () => new Date(), concurr
42
152
  if (!options.enabled || !options.reconcile) {
43
153
  return { output: { disabled: 0, roleChanges: 0, users: 0 } };
44
154
  }
45
- const { disabled, roleChanges, users } = await reconcileUsers(req.payload, options, clock());
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());
46
161
  return { output: { disabled, roleChanges, users } };
47
162
  },
48
163
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal58/payload-oidc",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sign in to the Payload admin with an OpenID Connect provider, with the provider as the source of truth for users and roles.",
5
5
  "keywords": [
6
6
  "authentik",