@signal58/payload-oidc 0.0.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Signal58
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,359 @@
1
+ # @signal58/payload-oidc
2
+
3
+ Sign in to the Payload admin with an OpenID Connect provider, and make the provider the source of
4
+ truth for the auth collection's users and roles.
5
+
6
+ - Users are created at their first sign-in. Email, first name, last name and role are taken from
7
+ the ID token at every sign-in and can't be edited in Payload.
8
+ - Roles come from the provider's `groups` claim through one role table, highest role first.
9
+ - A user whose groups grant no role is refused and disabled: role and sessions cleared,
10
+ `oidcDisabledAt` set, record kept. A disabled user stays editable even if the role field is
11
+ `required`.
12
+ - Password sign-in is closed for the collection: `login`, `forgotPassword`, `resetPassword`,
13
+ `unlock`, and setting a password through `update`, are refused through REST, the admin and the
14
+ Local API alike. A break-glass account can be let back in with a flag.
15
+ - A reconcile job (optional) reads the provider's role-table groups every 15 minutes and disables
16
+ users who left them, so removal doesn't wait for a sign-in. It changes nothing unless every
17
+ group was read, stops if it would disable too many users at once, and records a heartbeat that
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.
20
+ - A superadmin (the app decides who) can remove a disabled user: deleted if nothing refers to
21
+ them, anonymised otherwise.
22
+ - Sessions are ordinary Payload sessions: an idle timeout (`sessionHours`) that activity extends,
23
+ up to an absolute cap from sign-in (`sessionMaxHours`). A refresh near the cap gets a token that
24
+ ends at the cap, not `sessionHours` later.
25
+
26
+ Payload `^3.91` with the MongoDB adapter, on Node 24. MIT licence.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pnpm add @signal58/payload-oidc
32
+ ```
33
+
34
+ `payload` and `@payloadcms/ui` (`^3.91`, at the same version as each other) and `react` 19 are peer
35
+ dependencies. The package is ESM only.
36
+
37
+ ## Setup
38
+
39
+ ```ts
40
+ import { oidcPlugin } from '@signal58/payload-oidc'
41
+
42
+ export default buildConfig({
43
+ serverURL: process.env.SERVER_URL, // or the plugin's serverURL: the redirect URI is built from it
44
+ plugins: [
45
+ oidcPlugin({
46
+ collection: 'users',
47
+ issuer: process.env.OIDC_ISSUER, // https://idp.example/application/o/my-cms/
48
+ clientId: process.env.OIDC_CLIENT_ID,
49
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
50
+ roles: {
51
+ field: 'roles',
52
+ table: {
53
+ admin: ['cms-admins'],
54
+ editor: ['cms-editors'],
55
+ },
56
+ },
57
+ buttonLabel: 'Sign in with Example SSO',
58
+ breakGlassEmail: process.env.BREAK_GLASS_EMAIL,
59
+ passwordLogin: process.env.PASSWORD_LOGIN === '1',
60
+ }),
61
+ ],
62
+ })
63
+ ```
64
+
65
+ Register `${serverURL}${routes.api}/<collection>/oidc/callback` as the client's redirect URI (with
66
+ Payload's default `routes.api`, `https://cms.example/api/users/oidc/callback`), and the same path
67
+ on each of `additionalServerURLs`, with the Authorization Code grant, a confidential client, and an
68
+ RS256 signing key. Request scopes `openid email profile` (the default); the provider must send
69
+ `email_verified: true` and a `groups` claim.
70
+
71
+ With the issuer, client ID or client secret unset, the plugin is off: it still adds its fields and
72
+ endpoints (which answer 404), so the schema doesn't change, but sign-in works exactly as before.
73
+ That is the rollback. Discovery happens at the first sign-in, never at startup or during a build.
74
+
75
+ Run `payload generate:importmap` after adding the plugin: the sign-in button is a server
76
+ component, `@signal58/payload-oidc/rsc#OidcLoginButton`, added to `admin.components.beforeLogin`.
77
+
78
+ ## What it adds
79
+
80
+ Endpoint paths are relative to the collection's REST route, `${routes.api}/<collection>`.
81
+
82
+ | Addition | Purpose |
83
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- |
84
+ | `oidcSubject` field | The provider's `sub`, unique. Unset (never `null`) when unlinked, because the index is sparse. |
85
+ | `oidcDisabledAt` field | Set when the user is disabled. |
86
+ | `oidcLinkable` field | Marks pre-existing users that may be linked by email once, before `linkByEmailUntil`. |
87
+ | `GET /oidc/authorize` | Starts sign-in. `?redirect=` is kept only if it is a path under the admin route. |
88
+ | `GET /oidc/callback` | Finishes sign-in and sets the Payload session cookie. |
89
+ | `beforeOperation` hook | Closes password operations and creation outside sync; refuses `refresh` after the cap. |
90
+ | `refresh` hook | Signs a refreshed token that ends at the absolute cap. |
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). |
93
+ | `beforeLogin` component | The sign-in button and error message; hides the password form while it's closed. |
94
+ | `POST /oidc/remove/:id` | Removes a disabled user (`removeAccess`, default nobody); a "Remove user" button in the sidebar calls it. |
95
+ | `unlock` access denied | Payload then hides "Force Unlock"; the operation is refused anyway. |
96
+ | Sidebar `oidcRemove` ui field | "Remove user" on a disabled user; hides "Change Password" while password sign-in is closed. |
97
+ | `loggingLevels.OidcRefusal` | `info`: refused password operations and ended sessions are expected, not errors. |
98
+ | `GET /oidc/reconcile/health` | 200 while the last successful reconcile is recent, 503 once it isn't; for an uptime check. |
99
+ | `payload-oidc-reconcile` task | Registered always; scheduled on `reconcile.queue` only when a source is configured. |
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
+
106
+ The managed fields (`email`, first and last name, the role field) get `update` access that only
107
+ the plugin's sync passes (`req.context.oidcSync`). **If your app replaces field or collection
108
+ access after plugins run, keep those rules**: no updates to the managed fields outside sync, no
109
+ `create` outside sync, no `delete` except through your own removal path, and no `unlock` (or the
110
+ admin shows a "Force Unlock" the server refuses).
111
+
112
+ ## Sign-in
113
+
114
+ 1. `authorize` creates `state`, `nonce` and a PKCE verifier and seals them, with the return path,
115
+ in a short-lived, HTTP-only, `SameSite=Lax` cookie scoped to the callback path (AES-256-GCM,
116
+ key derived from the Payload secret).
117
+ 2. `callback` exchanges the code and validates the ID token with `openid-client`, including its
118
+ signature against the provider's JWKS (non-repudiation checks are on: by default the library
119
+ relies on TLS for tokens from the token endpoint).
120
+ 3. The role comes from the role table. No role: refused, and disabled if the user exists.
121
+ 4. The user is found by `oidcSubject`. If there is none and linking is still open, a user with
122
+ `oidcLinkable` and the same email is linked in one atomic update that also clears its sessions
123
+ and replaces its password with one nobody knows. Otherwise a new user is created, unless the
124
+ email already belongs to someone (refused, never taken over).
125
+ 5. Email, names and role are written and `oidcDisabledAt` cleared. For an existing user that is
126
+ one database update of just those paths (never a whole-document write, which could restore
127
+ sessions a disable just cleared), so **no collection hooks or validation run** for it; a new
128
+ user is created through Payload with `req.context.oidcSync` set, so hooks do run.
129
+ The break-glass email is never linked or synced: a provider account with it is refused.
130
+ 6. A session is pushed with one conditional update (only while the user isn't disabled and is
131
+ still linked to that subject), the JWT signed over `getFieldsToSign` with that `sid`, and the
132
+ cookie set.
133
+
134
+ Failures redirect to the login page with `?oidc_error=<code>`: `email_not_verified`, `no_role`,
135
+ `account_conflict`, `disabled`, `expired`, `provider`, `unavailable`, `failed`. Provider detail is
136
+ only logged.
137
+
138
+ **The plugin trusts `email_verified`.** Linking by email (before the cut-off) gives a provider
139
+ account the existing CMS record with that email. That is only safe if users can't change their
140
+ own email at the provider, or the provider marks a changed email unverified.
141
+
142
+ ## Reconcile
143
+
144
+ ```ts
145
+ import { authentikSource, oidcPlugin } from '@signal58/payload-oidc'
146
+
147
+ oidcPlugin({
148
+ // …
149
+ reconcile: authentikSource({
150
+ url: process.env.OIDC_RECONCILE_URL,
151
+ token: process.env.OIDC_RECONCILE_TOKEN,
152
+ }),
153
+ })
154
+
155
+ // and, because Payload runs schedules only for listed queues:
156
+ jobs: {
157
+ autoRun: [{ cron: '*/15 * * * *', queue: 'oidc-reconcile' }]
158
+ }
159
+ ```
160
+
161
+ `authentikSource` reads each role-table group (`GET /api/v3/core/groups/?name=…&include_users=true`)
162
+ with a token that only needs object-level `view_group` on those groups; a member's `uid` is the
163
+ OIDC `sub` when the provider's subject mode is the hashed user ID. Any other directory can be a
164
+ source: implement `ReconcileSource.fetchGroupMembers`.
165
+
166
+ Each run:
167
+
168
+ - disables linked users whose subject is in no role-table group or only as an inactive member
169
+ (again, if something restored their role or sessions);
170
+ - updates enabled users whose role changed; a disabled user stays disabled until they sign in;
171
+ - never creates users, and never touches the break-glass account or unlinked users;
172
+ - changes nothing if any group can't be read, and stops without changes if it would disable more
173
+ than `reconcileMaxDisableShare` of the linked users (default 25%; one is always allowed);
174
+ - records a heartbeat in Payload's KV store; `GET /oidc/reconcile/health` answers 503 once it is
175
+ older than `reconcileHeartbeatMaxMinutes` (default 45).
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
+
223
+ ## Removing users
224
+
225
+ `POST ${routes.api}/<collection>/oidc/remove/:id`, or the sidebar's "Remove user" on a disabled
226
+ user. Only disabled users: an active one would be created again at their next sign-in, and the
227
+ break-glass account never. The plugin scans the config for relationship and upload fields (in arrays, groups,
228
+ blocks and tabs, polymorphic included, and in versions) that can point at the collection. If none
229
+ refers to the user they are deleted; otherwise their names become "Removed user", their email
230
+ `removed-<id>@removed.invalid`, and every other profile field (photo, links, anything the app
231
+ added; `keepOnRemove` keeps named ones), their subject, password, sessions and reset or lockout
232
+ state go, so bylines still resolve but nothing else identifies them.
233
+ Payload's own bookkeeping (preferences, locks) isn't counted; rich-text links aren't seen.
234
+
235
+ ## Break-glass
236
+
237
+ With `passwordLogin: true`, the `breakGlassEmail` account may use its password (sign-in, forgotten
238
+ and reset password, and changing its own password), and, until `linkByEmailUntil`, so may
239
+ pre-existing users that haven't linked yet. Nobody may set another user's password. Anyone else
240
+ gets the status and body an unknown address gets (a 401, or a forgotten-password success that
241
+ sends nothing). Timing and lockout still differ (only allowed users' passwords are checked and
242
+ their failures counted), so keep the flag's windows short. Sync never disables the break-glass
243
+ account. On an empty database, the break-glass account can register itself through Payload's
244
+ first-register screen while the flag is on; it can't set the plugin's fields there.
245
+
246
+ ## Options
247
+
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`. |
273
+
274
+ ## Adopting in an app
275
+
276
+ The plugin owns sign-in, the managed fields and the password refusals. These are the app's:
277
+
278
+ 1. **Provider.** One confidential client per environment, each with its own secret: Authorization
279
+ Code with PKCE, RS256, and the callback above registered for every origin the environment
280
+ serves. The ID token must carry `email_verified: true` and a `groups` claim limited to the role
281
+ table's groups. With Authentik, use the "verified email" scope mapping (it sends `false`
282
+ otherwise) and pin the subject mode to the hashed user ID, which is what reconcile compares.
283
+ 2. **Dependency.** Pin an exact version. Check that `pnpm why @payloadcms/ui` shows one version: a
284
+ second copy breaks the admin components' React context.
285
+ 3. **`serverURL` in every environment**, Payload's or the plugin's option, because the redirect URI
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.
289
+ 4. **Access your app sets after plugins run.** If a later step replaces collection or field access,
290
+ keep the plugin's rules there: `create` only for sync writes (`req.context[OIDC_SYNC_CONTEXT]`),
291
+ `delete` denied (removal goes through `removeAccess` and the plugin's endpoint), `unlock`
292
+ denied, and the email, name and role fields updatable only by sync (`syncWriteOnly`). A patch
293
+ to the role field must keep that rule rather than overwrite it.
294
+ 5. **Endpoint allowlists.** If the app limits which REST endpoints a collection exposes, add the
295
+ plugin's four to the auth collection only.
296
+ 6. **Role guards.** A hook that limits role changes (for example, one that protects the last top
297
+ admin) must let sync writes through in both directions: the provider is right, and the
298
+ break-glass account remains. Sign-in updates existing users directly in MongoDB without hooks;
299
+ creating a user goes through Payload, so hooks run then.
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.
305
+ 9. **Jobs.** With `reconcile`, add its queue (`oidc-reconcile` for `authentikSource`) to
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.
311
+ 10. **Generated files.** Run `payload generate:importmap` and `payload generate:types`, and commit
312
+ both.
313
+ 11. **Break-glass.** A dedicated account that is never linked, with its password stored safely.
314
+ Turn `passwordLogin` on only for the linking window and emergencies.
315
+ 12. **Tests and scripts.** Tests that expect a password sign-in to work, or a wrong password's
316
+ 401, now meet the refusal. Scripts that grant roles directly drift from the provider: retire
317
+ them or limit them to the break-glass account. Add an integration test that signs in through
318
+ `@signal58/payload-oidc/testing` (`startFakeProvider`).
319
+ 13. **Monitoring.** Point an uptime check at `${routes.api}/<collection>/oidc/reconcile/health`.
320
+ 14. **Rollout.** Deploy with `passwordLogin` on and `linkByEmailUntil` set; existing users link at
321
+ their first single sign-on. After the window, unset `passwordLogin`, then check that login,
322
+ forgotten and reset password are refused for everyone.
323
+
324
+ ## Known limits
325
+
326
+ - MongoDB only: session pushes and disables are conditional, atomic updates.
327
+ - Payload's own `refresh` writes the whole user document. If a disable lands in the same moment,
328
+ that write can restore the role and sessions; the next disable (sign-in or reconcile) repeats it.
329
+ - The auth collection must keep its REST endpoints (`endpoints: false` is refused).
330
+ - Tested with email login. `loginWithUsername` collections are handled (the user is looked up the
331
+ way Payload will, per operation) but not yet covered by the end-to-end suites.
332
+ - A Payload write can't clear a linked `oidcSubject` (a `null` or `''` is dropped); code that
333
+ unlinks a user must `$unset` it directly.
334
+ - Signing out of the CMS doesn't sign out of the provider.
335
+ - "Change Password" is hidden with CSS (Payload has no option for it), and only while password
336
+ sign-in is closed: with `passwordLogin` on it shows on every account, though the server lets only
337
+ the break-glass account change its own password.
338
+
339
+ ## Development
340
+
341
+ ```bash
342
+ pnpm install
343
+ pnpm check # typecheck and lint
344
+ pnpm test # unit tests
345
+ pnpm test:integration # end to end: a real Payload on MongoDB and a fake OIDC provider
346
+ pnpm build # dist/: ESM and declarations
347
+ node scripts/check-dist.mjs # the built entry points load in plain Node
348
+ pnpm check:package # publint and arethetypeswrong
349
+ ```
350
+
351
+ The integration suite needs a MongoDB replica set (`PAYLOAD_OIDC_DATABASE_URI`, or a local one on
352
+ `127.0.0.1:27017` by default, named by `PAYLOAD_OIDC_REPLICA_SET` if set) and owns its database: it
353
+ refuses one that holds anything else.
354
+
355
+ ## Releases and security
356
+
357
+ Versions follow semver, recorded in [CHANGELOG.md](CHANGELOG.md). A `v*` tag publishes to npm from
358
+ GitHub Actions, with provenance. Report vulnerabilities privately, as [SECURITY.md](SECURITY.md)
359
+ describes.
@@ -0,0 +1,35 @@
1
+ import { APIError, type CollectionBeforeOperationHook, type CollectionRefreshHook } from 'payload';
2
+ import { type ResolvedOidcOptions } from './options.js';
3
+ /** `name` of the plugin's refusals, which the plugin logs at `info` (`config.loggingLevels`). */
4
+ export declare const OIDC_REFUSAL_NAME = "OidcRefusal";
5
+ /**
6
+ * An expected refusal (password sign-in off, session ended): a public 403. Its own `name`, set
7
+ * explicitly like Payload's errors so minifying can't change it, keeps these out of error logs.
8
+ */
9
+ export declare class OidcRefusal extends APIError {
10
+ constructor(message: string);
11
+ }
12
+ /**
13
+ * Closes password sign-in for the auth collection while the plugin is on. A `beforeOperation`
14
+ * hook runs for the REST routes, the admin views and the Local API alike; wrapping endpoints
15
+ * can't, and `disableLocalStrategy` would also break Payload's JWT strategy, refresh and logout.
16
+ *
17
+ * - `login`, `forgotPassword`, `resetPassword`: refused while `passwordLogin` is off. While it is
18
+ * on, a user who may not use a password gets the status and body an unknown email gets (401,
19
+ * or a success that sends no email for a forgotten password). Timing and lockout still differ:
20
+ * only allowed users' passwords are hashed and their failed attempts counted.
21
+ * - `update` with a password: refused, except the break-glass account changing its own password
22
+ * while `passwordLogin` is on. Sync never sets passwords this way.
23
+ * - `unlock`: refused.
24
+ * - `create`: refused unless the plugin's sync makes it, or the collection is empty and the
25
+ * break-glass account registers itself with password sign-in on (first-register), which may
26
+ * not set the plugin's fields.
27
+ * - `refresh`: refused once the session is older than `sessionMaxHours`, or the user is disabled.
28
+ */
29
+ export declare const createBeforeOperationHook: (options: ResolvedOidcOptions, clock?: () => Date) => CollectionBeforeOperationHook;
30
+ /**
31
+ * Caps a refreshed token at the session's absolute limit. Payload signs every refreshed token for
32
+ * the full `tokenExpiration`, and its JWT strategy never reads the session's `expiresAt`, so
33
+ * without this a refresh just before `sessionMaxHours` would last another `sessionHours`.
34
+ */
35
+ export declare const createRefreshHook: (options: ResolvedOidcOptions, clock?: () => Date) => CollectionRefreshHook;
@@ -0,0 +1,211 @@
1
+ import { APIError, AuthenticationError, getFieldsToSign, jwtSign, } from 'payload';
2
+ import { canLinkByEmail, isBreakGlassEmail } from './options.js';
3
+ import { authCollectionModel, findSession, OIDC_FIELDS, OIDC_SYNC_CONTEXT, } from './store.js';
4
+ /** `name` of the plugin's refusals, which the plugin logs at `info` (`config.loggingLevels`). */
5
+ export const OIDC_REFUSAL_NAME = 'OidcRefusal';
6
+ /**
7
+ * An expected refusal (password sign-in off, session ended): a public 403. Its own `name`, set
8
+ * explicitly like Payload's errors so minifying can't change it, keeps these out of error logs.
9
+ */
10
+ export class OidcRefusal extends APIError {
11
+ constructor(message) {
12
+ super(message, 403, undefined, true);
13
+ this.name = OIDC_REFUSAL_NAME;
14
+ }
15
+ }
16
+ const refused = (message) => new OidcRefusal(message);
17
+ const PASSWORD_OFF = 'Password sign-in is turned off. Use the single sign-on button.';
18
+ const SESSION_ENDED = 'Your session has ended. Sign in again.';
19
+ const PASSWORD_FIELDS = { email: 1, [OIDC_FIELDS.linkable]: 1, [OIDC_FIELDS.subject]: 1 };
20
+ /**
21
+ * Finds a stored user matching any of `alternatives`. `login` reads through Payload's adapter
22
+ * with `req`, inside the request's transaction, because first-register creates the user and logs
23
+ * in within one. Other operations read outside it: a read inside forgotPassword's transaction
24
+ * makes Payload's own update of the same user fail with a write conflict.
25
+ */
26
+ const findStored = async (req, options, alternatives, inTransaction) => {
27
+ if (alternatives.length === 0)
28
+ return null;
29
+ if (inTransaction) {
30
+ const where = {
31
+ or: alternatives.map((filter) => Object.fromEntries(Object.entries(filter).map(([key, value]) => [key, { equals: value }]))),
32
+ };
33
+ return (await req.payload.db.findOne({
34
+ collection: options.collection,
35
+ req,
36
+ where: where,
37
+ }));
38
+ }
39
+ return authCollectionModel(req.payload, options.collection)
40
+ .findOne({ $or: alternatives }, PASSWORD_FIELDS)
41
+ .lean();
42
+ };
43
+ const normalised = (value) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined;
44
+ /**
45
+ * The stored user a login or forgotten-password request targets, looked up the way Payload will
46
+ * (`auth/operations/login.js`, `forgotPassword.js`). Without `loginWithUsername` only the email
47
+ * counts, and a stray `username` is ignored. With it, login matches the username, or either field
48
+ * when email login is also allowed; a forgotten password prefers the email when that is allowed.
49
+ */
50
+ const targetUser = async (req, options, operation, data) => {
51
+ const email = normalised(data?.email);
52
+ const username = normalised(data?.username);
53
+ const inTransaction = operation === 'login';
54
+ if (!options.loginWithUsername) {
55
+ return email ? findStored(req, options, [{ email }], inTransaction) : null;
56
+ }
57
+ const emailAllowed = options.loginWithUsername.allowEmailLogin;
58
+ if (operation === 'forgotPassword') {
59
+ if (emailAllowed && email)
60
+ return findStored(req, options, [{ email }], false);
61
+ return username ? findStored(req, options, [{ username }], false) : null;
62
+ }
63
+ if (username) {
64
+ return findStored(req, options, emailAllowed ? [{ username }, { email: username }] : [{ username }], true);
65
+ }
66
+ return emailAllowed && email ? findStored(req, options, [{ email }], true) : null;
67
+ };
68
+ /**
69
+ * Whether a user may use a password while `passwordLogin` is on: the break-glass account, or,
70
+ * until the linking cut-off, a snapshot user who hasn't linked yet.
71
+ */
72
+ const passwordAllowedFor = (options, user, now) => {
73
+ if (!options.passwordLogin || !user)
74
+ return false;
75
+ if (isBreakGlassEmail(options, user.email))
76
+ return true;
77
+ return canLinkByEmail(options, now) && Boolean(user.oidcLinkable && !user.oidcSubject);
78
+ };
79
+ /**
80
+ * Closes password sign-in for the auth collection while the plugin is on. A `beforeOperation`
81
+ * hook runs for the REST routes, the admin views and the Local API alike; wrapping endpoints
82
+ * can't, and `disableLocalStrategy` would also break Payload's JWT strategy, refresh and logout.
83
+ *
84
+ * - `login`, `forgotPassword`, `resetPassword`: refused while `passwordLogin` is off. While it is
85
+ * on, a user who may not use a password gets the status and body an unknown email gets (401,
86
+ * or a success that sends no email for a forgotten password). Timing and lockout still differ:
87
+ * only allowed users' passwords are hashed and their failed attempts counted.
88
+ * - `update` with a password: refused, except the break-glass account changing its own password
89
+ * while `passwordLogin` is on. Sync never sets passwords this way.
90
+ * - `unlock`: refused.
91
+ * - `create`: refused unless the plugin's sync makes it, or the collection is empty and the
92
+ * break-glass account registers itself with password sign-in on (first-register), which may
93
+ * not set the plugin's fields.
94
+ * - `refresh`: refused once the session is older than `sessionMaxHours`, or the user is disabled.
95
+ */
96
+ export const createBeforeOperationHook = (options, clock = () => new Date()) => async ({ args, operation, req }) => {
97
+ if (!options.enabled)
98
+ return args;
99
+ const now = clock();
100
+ const data = args.data;
101
+ const isSync = req.context?.[OIDC_SYNC_CONTEXT] === true;
102
+ switch (operation) {
103
+ case 'login':
104
+ case 'forgotPassword': {
105
+ if (!options.passwordLogin)
106
+ throw refused(PASSWORD_OFF);
107
+ const user = await targetUser(req, options, operation, data);
108
+ if (passwordAllowedFor(options, user, now))
109
+ return args;
110
+ if (operation === 'login')
111
+ throw new AuthenticationError(req.t);
112
+ // Payload has already read the address, so answer as usual but send nothing; resetting
113
+ // with the token it stores is refused below.
114
+ return { ...args, disableEmail: true };
115
+ }
116
+ case 'resetPassword': {
117
+ if (!options.passwordLogin)
118
+ throw refused(PASSWORD_OFF);
119
+ const token = data?.token;
120
+ const user = typeof token === 'string' && token
121
+ ? await findStored(req, options, [{ resetPasswordToken: token }], false)
122
+ : null;
123
+ if (passwordAllowedFor(options, user, now))
124
+ return args;
125
+ throw refused(PASSWORD_OFF);
126
+ }
127
+ case 'update': {
128
+ if (isSync || data?.password === undefined || data.password === null)
129
+ return args;
130
+ const self = req.user;
131
+ const id = args.id;
132
+ const ownBreakGlass = options.passwordLogin &&
133
+ id !== undefined &&
134
+ self?.id !== undefined &&
135
+ String(self.id) === String(id) &&
136
+ isBreakGlassEmail(options, self.email);
137
+ if (ownBreakGlass)
138
+ return args;
139
+ throw refused('Passwords are managed by the identity provider.');
140
+ }
141
+ case 'unlock':
142
+ throw refused(PASSWORD_OFF);
143
+ case 'create': {
144
+ if (isSync)
145
+ return args;
146
+ if (options.passwordLogin && isBreakGlassEmail(options, data?.email)) {
147
+ const { totalDocs } = await req.payload.count({
148
+ collection: options.collection,
149
+ overrideAccess: true,
150
+ });
151
+ if (totalDocs === 0) {
152
+ const { [OIDC_FIELDS.disabledAt]: _disabledAt, [OIDC_FIELDS.linkable]: _linkable, [OIDC_FIELDS.subject]: _subject, ...rest } = data ?? {};
153
+ return { ...args, data: rest };
154
+ }
155
+ }
156
+ throw refused('Users are created when they first sign in with single sign-on.');
157
+ }
158
+ case 'refresh': {
159
+ const user = req.user;
160
+ if (!user?._sid || user.collection !== options.collection)
161
+ return args;
162
+ const session = await findSession(req.payload, options, user.id, user._sid);
163
+ const age = session ? now.getTime() - session.createdAt.getTime() : Number.NaN;
164
+ // A session without a readable createdAt fails closed (NaN compares false).
165
+ if (!session || session.disabled || !(age >= 0 && age < options.sessionMaxSeconds * 1000)) {
166
+ throw refused(SESSION_ENDED);
167
+ }
168
+ return args;
169
+ }
170
+ default:
171
+ return args;
172
+ }
173
+ };
174
+ /**
175
+ * Caps a refreshed token at the session's absolute limit. Payload signs every refreshed token for
176
+ * the full `tokenExpiration`, and its JWT strategy never reads the session's `expiresAt`, so
177
+ * without this a refresh just before `sessionMaxHours` would last another `sessionHours`.
178
+ */
179
+ export const createRefreshHook = (options, clock = () => new Date()) => async ({ args, user }) => {
180
+ if (!options.enabled)
181
+ return undefined;
182
+ const reqUser = args.req.user;
183
+ if (!reqUser?._sid)
184
+ return undefined;
185
+ const session = await findSession(args.req.payload, options, reqUser.id, reqUser._sid);
186
+ if (!session)
187
+ throw refused(SESSION_ENDED);
188
+ const remaining = Math.floor((session.createdAt.getTime() + options.sessionMaxSeconds * 1000 - clock().getTime()) / 1000);
189
+ if (!(remaining > 0))
190
+ throw refused(SESSION_ENDED);
191
+ if (remaining >= options.sessionSeconds)
192
+ return undefined;
193
+ const collectionConfig = args.collection.config;
194
+ const { exp, token } = await jwtSign({
195
+ fieldsToSign: getFieldsToSign({
196
+ collectionConfig,
197
+ email: user.email ?? '',
198
+ sid: reqUser._sid,
199
+ user: args.req.user,
200
+ }),
201
+ secret: args.req.payload.secret,
202
+ tokenExpiration: remaining,
203
+ });
204
+ return {
205
+ exp,
206
+ refreshedToken: token,
207
+ setCookie: true,
208
+ strategy: reqUser._strategy,
209
+ user,
210
+ };
211
+ };
@@ -0,0 +1,8 @@
1
+ import type { Endpoint, PayloadRequest } from 'payload';
2
+ import type { ResolvedOidcOptions } from './options.js';
3
+ import { type ProviderClient } from './provider.js';
4
+ /** Query parameter on the login page that carries a refusal code. */
5
+ export declare const SIGN_IN_ERROR_PARAM = "oidc_error";
6
+ /** The redirect URI registered with the provider. */
7
+ export declare const redirectUri: (req: PayloadRequest, options: ResolvedOidcOptions) => string;
8
+ export declare const createEndpoints: (options: ResolvedOidcOptions, client: ProviderClient, clock?: () => Date) => Endpoint[];