@signal58/payload-oidc 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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 CHANGED
@@ -1,3 +1,300 @@
1
- # Temporary Holding Version
1
+ # @signal58/payload-oidc
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
11
+ - Password sign-in is closed for the collection: `login`, `forgotPassword`, `resetPassword`,
12
+ `unlock`, and setting a password through `update`, are refused through REST, the admin and the
13
+ Local API alike. A break-glass account can be let back in with a flag.
14
+ - A reconcile job (optional) reads the provider's role-table groups every 15 minutes and disables
15
+ users who left them, so removal doesn't wait for a sign-in. It changes nothing unless every
16
+ 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
+ - A superadmin (the app decides who) can remove a disabled user: deleted if nothing refers to
19
+ them, anonymised otherwise.
20
+ - Sessions are ordinary Payload sessions: an idle timeout (`sessionHours`) that activity extends,
21
+ up to an absolute cap from sign-in (`sessionMaxHours`). A refresh near the cap gets a token that
22
+ ends at the cap, not `sessionHours` later.
23
+
24
+ Payload `^3.91` with the MongoDB adapter, on Node 24. MIT licence.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pnpm add @signal58/payload-oidc
30
+ ```
31
+
32
+ `payload` and `@payloadcms/ui` (`^3.91`, at the same version as each other) and `react` 19 are peer
33
+ dependencies. The package is ESM only.
34
+
35
+ ## Setup
36
+
37
+ ```ts
38
+ import { oidcPlugin } from '@signal58/payload-oidc'
39
+
40
+ export default buildConfig({
41
+ serverURL: process.env.SERVER_URL, // required: the redirect URI is built from it
42
+ plugins: [
43
+ oidcPlugin({
44
+ collection: 'users',
45
+ issuer: process.env.OIDC_ISSUER, // https://idp.example/application/o/my-cms/
46
+ clientId: process.env.OIDC_CLIENT_ID,
47
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
48
+ roles: {
49
+ field: 'roles',
50
+ table: {
51
+ admin: ['cms-admins'],
52
+ editor: ['cms-editors'],
53
+ },
54
+ },
55
+ buttonLabel: 'Sign in with Example SSO',
56
+ breakGlassEmail: process.env.BREAK_GLASS_EMAIL,
57
+ passwordLogin: process.env.PASSWORD_LOGIN === '1',
58
+ }),
59
+ ],
60
+ })
61
+ ```
62
+
63
+ Register `${serverURL}${routes.api}/<collection>/oidc/callback` as the client's redirect URI (with
64
+ Payload's default `routes.api`, `https://cms.example/api/users/oidc/callback`), and the same path
65
+ on each of `additionalServerURLs`, with the Authorization Code grant, a confidential client, and an
66
+ RS256 signing key. Request scopes `openid email profile` (the default); the provider must send
67
+ `email_verified: true` and a `groups` claim.
68
+
69
+ With the issuer, client ID or client secret unset, the plugin is off: it still adds its fields and
70
+ endpoints (which answer 404), so the schema doesn't change, but sign-in works exactly as before.
71
+ That is the rollback. Discovery happens at the first sign-in, never at startup or during a build.
72
+
73
+ Run `payload generate:importmap` after adding the plugin: the sign-in button is a server
74
+ component, `@signal58/payload-oidc/rsc#OidcLoginButton`, added to `admin.components.beforeLogin`.
75
+
76
+ ## What it adds
77
+
78
+ Endpoint paths are relative to the collection's REST route, `${routes.api}/<collection>`.
79
+
80
+ | Addition | Purpose |
81
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- |
82
+ | `oidcSubject` field | The provider's `sub`, unique. Unset (never `null`) when unlinked, because the index is sparse. |
83
+ | `oidcDisabledAt` field | Set when the user is disabled. |
84
+ | `oidcLinkable` field | Marks pre-existing users that may be linked by email once, before `linkByEmailUntil`. |
85
+ | `GET /oidc/authorize` | Starts sign-in. `?redirect=` is kept only if it is a path under the admin route. |
86
+ | `GET /oidc/callback` | Finishes sign-in and sets the Payload session cookie. |
87
+ | `beforeOperation` hook | Closes password operations and creation outside sync; refuses `refresh` after the cap. |
88
+ | `refresh` hook | Signs a refreshed token that ends at the absolute cap. |
89
+ | `beforeChange` hook | Drops an `oidcSubject` of `null` or `''`, which would collide in the sparse index. |
90
+ | `beforeLogin` component | The sign-in button and error message; hides the password form while it's closed. |
91
+ | `POST /oidc/remove/:id` | Removes a disabled user (`removeAccess`, default nobody); a "Remove user" button in the sidebar calls it. |
92
+ | `unlock` access denied | Payload then hides "Force Unlock"; the operation is refused anyway. |
93
+ | Sidebar `oidcRemove` ui field | "Remove user" on a disabled user; hides "Change Password" while password sign-in is closed. |
94
+ | `loggingLevels.OidcRefusal` | `info`: refused password operations and ended sessions are expected, not errors. |
95
+ | `GET /oidc/reconcile/health` | 200 while the last successful reconcile is recent, 503 once it isn't; for an uptime check. |
96
+ | `payload-oidc-reconcile` task | Registered always; scheduled on `reconcile.queue` only when a source is configured. |
97
+
98
+ The managed fields (`email`, first and last name, the role field) get `update` access that only
99
+ the plugin's sync passes (`req.context.oidcSync`). **If your app replaces field or collection
100
+ access after plugins run, keep those rules**: no updates to the managed fields outside sync, no
101
+ `create` outside sync, no `delete` except through your own removal path, and no `unlock` (or the
102
+ admin shows a "Force Unlock" the server refuses).
103
+
104
+ ## Sign-in
105
+
106
+ 1. `authorize` creates `state`, `nonce` and a PKCE verifier and seals them, with the return path,
107
+ in a short-lived, HTTP-only, `SameSite=Lax` cookie scoped to the callback path (AES-256-GCM,
108
+ key derived from the Payload secret).
109
+ 2. `callback` exchanges the code and validates the ID token with `openid-client`, including its
110
+ signature against the provider's JWKS (non-repudiation checks are on: by default the library
111
+ relies on TLS for tokens from the token endpoint).
112
+ 3. The role comes from the role table. No role: refused, and disabled if the user exists.
113
+ 4. The user is found by `oidcSubject`. If there is none and linking is still open, a user with
114
+ `oidcLinkable` and the same email is linked in one atomic update that also clears its sessions
115
+ and replaces its password with one nobody knows. Otherwise a new user is created, unless the
116
+ email already belongs to someone (refused, never taken over).
117
+ 5. Email, names and role are written and `oidcDisabledAt` cleared. For an existing user that is
118
+ one database update of just those paths (never a whole-document write, which could restore
119
+ sessions a disable just cleared), so **no collection hooks or validation run** for it; a new
120
+ user is created through Payload with `req.context.oidcSync` set, so hooks do run.
121
+ The break-glass email is never linked or synced: a provider account with it is refused.
122
+ 6. A session is pushed with one conditional update (only while the user isn't disabled and is
123
+ still linked to that subject), the JWT signed over `getFieldsToSign` with that `sid`, and the
124
+ cookie set.
125
+
126
+ Failures redirect to the login page with `?oidc_error=<code>`: `email_not_verified`, `no_role`,
127
+ `account_conflict`, `disabled`, `expired`, `provider`, `unavailable`, `failed`. Provider detail is
128
+ only logged.
129
+
130
+ **The plugin trusts `email_verified`.** Linking by email (before the cut-off) gives a provider
131
+ account the existing CMS record with that email. That is only safe if users can't change their
132
+ own email at the provider, or the provider marks a changed email unverified.
133
+
134
+ ## Reconcile
135
+
136
+ ```ts
137
+ import { authentikSource, oidcPlugin } from '@signal58/payload-oidc'
138
+
139
+ oidcPlugin({
140
+ // …
141
+ reconcile: authentikSource({
142
+ url: process.env.OIDC_RECONCILE_URL,
143
+ token: process.env.OIDC_RECONCILE_TOKEN,
144
+ }),
145
+ })
146
+
147
+ // and, because Payload runs schedules only for listed queues:
148
+ jobs: {
149
+ autoRun: [{ cron: '*/15 * * * *', queue: 'oidc-reconcile' }]
150
+ }
151
+ ```
152
+
153
+ `authentikSource` reads each role-table group (`GET /api/v3/core/groups/?name=…&include_users=true`)
154
+ with a token that only needs object-level `view_group` on those groups; a member's `uid` is the
155
+ OIDC `sub` when the provider's subject mode is the hashed user ID. Any other directory can be a
156
+ source: implement `ReconcileSource.fetchGroupMembers`.
157
+
158
+ Each run:
159
+
160
+ - disables linked users whose subject is in no role-table group or only as an inactive member
161
+ (again, if something restored their role or sessions);
162
+ - updates enabled users whose role changed; a disabled user stays disabled until they sign in;
163
+ - never creates users, and never touches the break-glass account or unlinked users;
164
+ - changes nothing if any group can't be read, and stops without changes if it would disable more
165
+ than `reconcileMaxDisableShare` of the linked users (default 25%; one is always allowed);
166
+ - records a heartbeat in Payload's KV store; `GET /oidc/reconcile/health` answers 503 once it is
167
+ older than `reconcileHeartbeatMaxMinutes` (default 45).
168
+
169
+ ## Removing users
170
+
171
+ `POST ${routes.api}/<collection>/oidc/remove/:id`, or the sidebar's "Remove user" on a disabled
172
+ user. Only disabled users: an active one would be created again at their next sign-in, and the
173
+ break-glass account never. The plugin scans the config for relationship and upload fields (in arrays, groups,
174
+ blocks and tabs, polymorphic included, and in versions) that can point at the collection. If none
175
+ refers to the user they are deleted; otherwise their names become "Removed user", their email
176
+ `removed-<id>@removed.invalid`, and every other profile field (photo, links, anything the app
177
+ added; `keepOnRemove` keeps named ones), their subject, password, sessions and reset or lockout
178
+ state go, so bylines still resolve but nothing else identifies them.
179
+ Payload's own bookkeeping (preferences, locks) isn't counted; rich-text links aren't seen.
180
+
181
+ ## Break-glass
182
+
183
+ With `passwordLogin: true`, the `breakGlassEmail` account may use its password (sign-in, forgotten
184
+ and reset password, and changing its own password), and, until `linkByEmailUntil`, so may
185
+ pre-existing users that haven't linked yet. Nobody may set another user's password. Anyone else
186
+ gets the status and body an unknown address gets (a 401, or a forgotten-password success that
187
+ sends nothing). Timing and lockout still differ (only allowed users' passwords are checked and
188
+ their failures counted), so keep the flag's windows short. Sync never disables the break-glass
189
+ account. On an empty database, the break-glass account can register itself through Payload's
190
+ first-register screen while the flag is on; it can't set the plugin's fields there.
191
+
192
+ ## Options
193
+
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`. |
217
+
218
+ ## Adopting in an app
219
+
220
+ The plugin owns sign-in, the managed fields and the password refusals. These are the app's:
221
+
222
+ 1. **Provider.** One confidential client per environment, each with its own secret: Authorization
223
+ Code with PKCE, RS256, and the callback above registered for every origin the environment
224
+ serves. The ID token must carry `email_verified: true` and a `groups` claim limited to the role
225
+ table's groups. With Authentik, use the "verified email" scope mapping (it sends `false`
226
+ otherwise) and pin the subject mode to the hashed user ID, which is what reconcile compares.
227
+ 2. **Dependency.** Pin an exact version. Check that `pnpm why @payloadcms/ui` shows one version: a
228
+ second copy breaks the admin components' React context.
229
+ 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.
232
+ 4. **Access your app sets after plugins run.** If a later step replaces collection or field access,
233
+ keep the plugin's rules there: `create` only for sync writes (`req.context[OIDC_SYNC_CONTEXT]`),
234
+ `delete` denied (removal goes through `removeAccess` and the plugin's endpoint), `unlock`
235
+ denied, and the email, name and role fields updatable only by sync (`syncWriteOnly`). A patch
236
+ to the role field must keep that rule rather than overwrite it.
237
+ 5. **Endpoint allowlists.** If the app limits which REST endpoints a collection exposes, add the
238
+ plugin's four to the auth collection only.
239
+ 6. **Role guards.** A hook that limits role changes (for example, one that protects the last top
240
+ admin) must let sync writes through in both directions: the provider is right, and the
241
+ break-glass account remains. Sign-in updates existing users directly in MongoDB without hooks;
242
+ 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.
249
+ 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.
252
+ 10. **Generated files.** Run `payload generate:importmap` and `payload generate:types`, and commit
253
+ both.
254
+ 11. **Break-glass.** A dedicated account that is never linked, with its password stored safely.
255
+ Turn `passwordLogin` on only for the linking window and emergencies.
256
+ 12. **Tests and scripts.** Tests that expect a password sign-in to work, or a wrong password's
257
+ 401, now meet the refusal. Scripts that grant roles directly drift from the provider: retire
258
+ them or limit them to the break-glass account. Add an integration test that signs in through
259
+ `@signal58/payload-oidc/testing` (`startFakeProvider`).
260
+ 13. **Monitoring.** Point an uptime check at `${routes.api}/<collection>/oidc/reconcile/health`.
261
+ 14. **Rollout.** Deploy with `passwordLogin` on and `linkByEmailUntil` set; existing users link at
262
+ their first single sign-on. After the window, unset `passwordLogin`, then check that login,
263
+ forgotten and reset password are refused for everyone.
264
+
265
+ ## Known limits
266
+
267
+ - MongoDB only: session pushes and disables are conditional, atomic updates.
268
+ - Payload's own `refresh` writes the whole user document. If a disable lands in the same moment,
269
+ that write can restore the role and sessions; the next disable (sign-in or reconcile) repeats it.
270
+ - The auth collection must keep its REST endpoints (`endpoints: false` is refused).
271
+ - Tested with email login. `loginWithUsername` collections are handled (the user is looked up the
272
+ way Payload will, per operation) but not yet covered by the end-to-end suites.
273
+ - A Payload write can't clear a linked `oidcSubject` (a `null` or `''` is dropped); code that
274
+ unlinks a user must `$unset` it directly.
275
+ - Signing out of the CMS doesn't sign out of the provider.
276
+ - "Change Password" is hidden with CSS (Payload has no option for it), and only while password
277
+ sign-in is closed: with `passwordLogin` on it shows on every account, though the server lets only
278
+ the break-glass account change its own password.
279
+
280
+ ## Development
281
+
282
+ ```bash
283
+ pnpm install
284
+ pnpm check # typecheck and lint
285
+ pnpm test # unit tests
286
+ pnpm test:integration # end to end: a real Payload on MongoDB and a fake OIDC provider
287
+ pnpm build # dist/: ESM and declarations
288
+ node scripts/check-dist.mjs # the built entry points load in plain Node
289
+ pnpm check:package # publint and arethetypeswrong
290
+ ```
291
+
292
+ The integration suite needs a MongoDB replica set (`PAYLOAD_OIDC_DATABASE_URI`, or a local one on
293
+ `127.0.0.1:27017` by default, named by `PAYLOAD_OIDC_REPLICA_SET` if set) and owns its database: it
294
+ refuses one that holds anything else.
295
+
296
+ ## Releases and security
297
+
298
+ Versions follow semver, recorded in [CHANGELOG.md](CHANGELOG.md). A `v*` tag publishes to npm from
299
+ GitHub Actions, with provenance. Report vulnerabilities privately, as [SECURITY.md](SECURITY.md)
300
+ 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[];