@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 +95 -36
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migration.d.ts +47 -0
- package/dist/migration.js +89 -0
- package/dist/options.d.ts +14 -1
- package/dist/options.js +11 -0
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +40 -9
- package/dist/reconcile-task.d.ts +17 -1
- package/dist/reconcile-task.js +123 -8
- package/package.json +1 -1
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, //
|
|
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
|
-
| `
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
216
|
-
| `
|
|
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.
|
|
231
|
-
its own
|
|
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.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
users
|
|
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
|
|
251
|
-
so
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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: {
|
package/dist/reconcile-task.d.ts
CHANGED
|
@@ -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
|
package/dist/reconcile-task.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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