@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 +21 -0
- package/README.md +359 -0
- package/dist/before-operation.d.ts +35 -0
- package/dist/before-operation.js +211 -0
- package/dist/endpoints.d.ts +8 -0
- package/dist/endpoints.js +193 -0
- package/dist/exports/client.d.ts +8 -0
- package/dist/exports/client.js +45 -0
- package/dist/exports/rsc.d.ts +9 -0
- package/dist/exports/rsc.js +30 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/migration.d.ts +47 -0
- package/dist/migration.js +89 -0
- package/dist/options.d.ts +172 -0
- package/dist/options.js +137 -0
- package/dist/plugin.d.ts +25 -0
- package/dist/plugin.js +248 -0
- package/dist/provider.d.ts +43 -0
- package/dist/provider.js +67 -0
- package/dist/reconcile-task.d.ts +28 -0
- package/dist/reconcile-task.js +163 -0
- package/dist/reconcile.d.ts +32 -0
- package/dist/reconcile.js +110 -0
- package/dist/remove.d.ts +29 -0
- package/dist/remove.js +161 -0
- package/dist/sign-in.d.ts +27 -0
- package/dist/sign-in.js +122 -0
- package/dist/sources/authentik.d.ts +26 -0
- package/dist/sources/authentik.js +60 -0
- package/dist/store.d.ts +105 -0
- package/dist/store.js +161 -0
- package/dist/test/fake-provider.d.ts +27 -0
- package/dist/test/fake-provider.js +103 -0
- package/dist/transaction.d.ts +35 -0
- package/dist/transaction.js +89 -0
- package/package.json +92 -1
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[];
|