@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.
@@ -0,0 +1,137 @@
1
+ const positiveHours = (value, fallback, name) => {
2
+ const hours = value ?? fallback;
3
+ if (!Number.isFinite(hours) || hours <= 0) {
4
+ throw new Error(`payload-oidc: ${name} must be a positive number of hours.`);
5
+ }
6
+ return hours;
7
+ };
8
+ const atLeastOneMinute = (value, fallback, name) => {
9
+ const minutes = value ?? fallback;
10
+ if (!Number.isFinite(minutes) || minutes < 1) {
11
+ throw new Error(`payload-oidc: ${name} must be a number of minutes, at least 1.`);
12
+ }
13
+ return minutes;
14
+ };
15
+ const toOrigin = (url, name) => {
16
+ try {
17
+ return new URL(url.trim()).origin;
18
+ }
19
+ catch {
20
+ throw new Error(`payload-oidc: ${name} must be absolute URLs.`);
21
+ }
22
+ };
23
+ const shareBetweenZeroAndOne = (share) => {
24
+ if (!Number.isFinite(share) || share < 0 || share > 1) {
25
+ throw new Error('payload-oidc: reconcileMaxDisableShare must be between 0 and 1.');
26
+ }
27
+ return share;
28
+ };
29
+ /**
30
+ * Validates the options and fills in defaults. Throws for a configuration that can't be safe,
31
+ * so a mistake fails at startup rather than at someone's first sign-in.
32
+ */
33
+ export const resolveOptions = (options) => {
34
+ const issuer = options.issuer?.trim() ?? '';
35
+ const clientId = options.clientId?.trim() ?? '';
36
+ const clientSecret = options.clientSecret ?? '';
37
+ const enabled = Boolean(issuer && clientId && clientSecret);
38
+ const roleTable = options.roles?.table ?? {};
39
+ if (!options.roles?.field)
40
+ throw new Error('payload-oidc: roles.field is required.');
41
+ if (Object.keys(roleTable).length === 0) {
42
+ throw new Error('payload-oidc: roles.table needs at least one role.');
43
+ }
44
+ for (const [role, groups] of Object.entries(roleTable)) {
45
+ if (!Array.isArray(groups) || groups.length === 0 || groups.some((group) => !group)) {
46
+ throw new Error(`payload-oidc: role "${role}" needs at least one group.`);
47
+ }
48
+ }
49
+ const sessionHours = positiveHours(options.sessionHours, 8, 'sessionHours');
50
+ const sessionMaxHours = positiveHours(options.sessionMaxHours, 12, 'sessionMaxHours');
51
+ if (sessionMaxHours < sessionHours) {
52
+ throw new Error('payload-oidc: sessionMaxHours must be at least sessionHours.');
53
+ }
54
+ let linkByEmailUntil;
55
+ if (options.linkByEmailUntil) {
56
+ linkByEmailUntil = new Date(options.linkByEmailUntil);
57
+ if (Number.isNaN(linkByEmailUntil.getTime())) {
58
+ throw new Error('payload-oidc: linkByEmailUntil must be an ISO 8601 date.');
59
+ }
60
+ }
61
+ if (enabled) {
62
+ const url = new URL(issuer);
63
+ if (url.protocol !== 'https:') {
64
+ if (!options.allowInsecureIssuer || process.env.NODE_ENV === 'production') {
65
+ throw new Error('payload-oidc: the issuer must use https.');
66
+ }
67
+ }
68
+ }
69
+ const breakGlassEmail = options.breakGlassEmail?.trim().toLowerCase() || undefined;
70
+ if (enabled && options.passwordLogin && !breakGlassEmail) {
71
+ throw new Error('payload-oidc: passwordLogin needs breakGlassEmail.');
72
+ }
73
+ return {
74
+ allowInsecureIssuer: Boolean(options.allowInsecureIssuer),
75
+ breakGlassEmail,
76
+ buttonLabel: options.buttonLabel ?? 'Sign in with single sign-on',
77
+ clientId,
78
+ clientSecret,
79
+ collection: options.collection ?? 'users',
80
+ enabled,
81
+ firstNameField: options.fields?.firstName ?? 'firstName',
82
+ issuer,
83
+ lastNameField: options.fields?.lastName ?? 'lastName',
84
+ lastNamePlaceholder: options.lastNamePlaceholder ?? '-',
85
+ linkByEmailUntil,
86
+ passwordLogin: Boolean(options.passwordLogin),
87
+ reconcile: options.reconcile,
88
+ keepOnRemove: options.keepOnRemove ?? [],
89
+ reconcileScheduleHooks: options.reconcileScheduleHooks,
90
+ reconcileStaleAfterSeconds: options.reconcileStaleAfterMinutes === false
91
+ ? undefined
92
+ : Math.round(atLeastOneMinute(options.reconcileStaleAfterMinutes, 10, 'reconcileStaleAfterMinutes') *
93
+ 60),
94
+ reconcileHeartbeatMaxSeconds: Math.round(positiveHours((options.reconcileHeartbeatMaxMinutes ?? 45) / 60, 0.75, 'reconcileHeartbeatMaxMinutes') * 3600),
95
+ reconcileMaxDisableShare: shareBetweenZeroAndOne(options.reconcileMaxDisableShare ?? 0.25),
96
+ removeAccess: options.removeAccess ?? (() => false),
97
+ roleField: options.roles.field,
98
+ roleHasMany: options.roles.hasMany ?? true,
99
+ roleTable,
100
+ scopes: options.scopes ?? ['openid', 'email', 'profile'],
101
+ sessionMaxSeconds: Math.round(sessionMaxHours * 3600),
102
+ sessionSeconds: Math.round(sessionHours * 3600),
103
+ serverURL: options.serverURL ? toOrigin(options.serverURL, 'serverURL') : undefined,
104
+ additionalServerURLs: (options.additionalServerURLs ?? [])
105
+ .map((url) => url.trim())
106
+ .filter(Boolean)
107
+ .map((url) => toOrigin(url, 'additionalServerURLs')),
108
+ verify: false,
109
+ };
110
+ };
111
+ /**
112
+ * The role a user's groups grant: the first role in the table with a matching group.
113
+ *
114
+ * @param table - The role table, highest role first.
115
+ * @param groups - The `groups` claim (or a reconcile member's groups); anything else is no role.
116
+ * @returns The role, or `undefined` for none.
117
+ */
118
+ export const roleForGroups = (table, groups) => {
119
+ if (!Array.isArray(groups))
120
+ return undefined;
121
+ const names = new Set(groups.filter((group) => typeof group === 'string'));
122
+ for (const [role, roleGroups] of Object.entries(table)) {
123
+ if (roleGroups.some((group) => names.has(group)))
124
+ return role;
125
+ }
126
+ return undefined;
127
+ };
128
+ /** Every group named in the role table, once. */
129
+ export const roleTableGroups = (table) => [
130
+ ...new Set(Object.values(table).flat()),
131
+ ];
132
+ /** Whether an email is the break-glass account's. */
133
+ export const isBreakGlassEmail = (options, email) => Boolean(options.breakGlassEmail &&
134
+ typeof email === 'string' &&
135
+ email.trim().toLowerCase() === options.breakGlassEmail);
136
+ /** Whether linking by email is still allowed at `now`. */
137
+ export const canLinkByEmail = (options, now) => Boolean(options.linkByEmailUntil && now < options.linkByEmailUntil);
@@ -0,0 +1,25 @@
1
+ import { type FieldAccess, type Plugin } from 'payload';
2
+ import { type OidcPluginOptions } from './options.js';
3
+ import { type ProviderClient } from './provider.js';
4
+ /** Key under `config.custom` with the plugin's public (non-secret) state, for the login view. */
5
+ export declare const PUBLIC_STATE_KEY = "payloadOidc";
6
+ export type OidcPublicState = {
7
+ authorizePath: string;
8
+ buttonLabel: string;
9
+ enabled: boolean;
10
+ passwordLogin: boolean;
11
+ };
12
+ /** Field access that lets only the plugin's sync write a field. */
13
+ export declare const syncWriteOnly: FieldAccess;
14
+ /**
15
+ * Signs staff in to the Payload admin through an OpenID Connect provider, which becomes the source
16
+ * of truth for the auth collection's users and roles.
17
+ *
18
+ * With the issuer, client ID or client secret unset, the plugin adds its fields and inert
19
+ * endpoints so the schema stays the same, and changes nothing else: password sign-in works as
20
+ * before.
21
+ */
22
+ export declare const oidcPlugin: (pluginOptions: OidcPluginOptions, testing?: {
23
+ client?: ProviderClient;
24
+ clock?: () => Date;
25
+ }) => Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,248 @@
1
+ import { validations, } from 'payload';
2
+ import { createBeforeOperationHook, createRefreshHook, OIDC_REFUSAL_NAME } from './before-operation.js';
3
+ import { createEndpoints } from './endpoints.js';
4
+ import { createReconcileTask } from './reconcile-task.js';
5
+ import { resolveOptions } from './options.js';
6
+ import { createProviderClient } from './provider.js';
7
+ import { OIDC_FIELDS, OIDC_SYNC_CONTEXT } from './store.js';
8
+ /** Key under `config.custom` with the plugin's public (non-secret) state, for the login view. */
9
+ export const PUBLIC_STATE_KEY = 'payloadOidc';
10
+ /** Field access that lets only the plugin's sync write a field. */
11
+ export const syncWriteOnly = ({ req }) => req.context?.[OIDC_SYNC_CONTEXT] === true;
12
+ const denyField = () => false;
13
+ const pluginFields = (options) => [
14
+ {
15
+ name: OIDC_FIELDS.subject,
16
+ type: 'text',
17
+ label: 'Identity provider subject',
18
+ // Sparse unique index: the field is unset, never null, when there is no link.
19
+ unique: true,
20
+ index: true,
21
+ access: { create: denyField, update: denyField },
22
+ admin: {
23
+ description: 'The identity provider subject this account is linked to.',
24
+ position: 'sidebar',
25
+ readOnly: true,
26
+ },
27
+ },
28
+ {
29
+ name: OIDC_FIELDS.disabledAt,
30
+ type: 'date',
31
+ label: 'Disabled by identity provider',
32
+ access: { create: denyField, update: denyField },
33
+ admin: {
34
+ date: { pickerAppearance: 'dayAndTime' },
35
+ description: 'Set when the identity provider removed this account’s role.',
36
+ position: 'sidebar',
37
+ readOnly: true,
38
+ },
39
+ },
40
+ {
41
+ name: OIDC_FIELDS.linkable,
42
+ type: 'checkbox',
43
+ access: { create: denyField, update: denyField },
44
+ admin: { hidden: true },
45
+ },
46
+ {
47
+ // "Remove user" on a disabled user; also hides "Change Password" while passwords are off.
48
+ // Stores nothing.
49
+ name: 'oidcRemove',
50
+ type: 'ui',
51
+ admin: {
52
+ components: {
53
+ Field: {
54
+ clientProps: { hidePasswordChange: options.enabled && !options.passwordLogin },
55
+ path: '@signal58/payload-oidc/client#RemoveUserButton',
56
+ },
57
+ },
58
+ position: 'sidebar',
59
+ },
60
+ },
61
+ ];
62
+ const hasField = (fields, name) => fields.some((field) => ('name' in field && field.name === name) ||
63
+ ((field.type === 'row' || field.type === 'collapsible') && hasField(field.fields, name)));
64
+ /**
65
+ * Copies the fields, replacing each named top-level, row or collapsible data field with
66
+ * `replace(field)`. Copies, never edits: a field config the host shares with another collection
67
+ * must not change there too.
68
+ */
69
+ const mapNamedFields = (fields, names, found, replace) => fields.map((field) => {
70
+ if ('name' in field && names.has(field.name)) {
71
+ found.add(field.name);
72
+ return replace(field);
73
+ }
74
+ if (field.type === 'row' || field.type === 'collapsible') {
75
+ return { ...field, fields: mapNamedFields(field.fields, names, found, replace) };
76
+ }
77
+ return field;
78
+ });
79
+ /** Gives a field `update` access that only sync passes. */
80
+ const syncOnlyUpdate = (field) => {
81
+ const access = field.access;
82
+ return { ...field, access: { ...access, update: syncWriteOnly } };
83
+ };
84
+ /** No role: an empty list, or no single value. */
85
+ const isNoRole = (value) => Array.isArray(value) ? value.length === 0 : value === null || value === undefined || value === '';
86
+ /**
87
+ * Lets a disabled user have no role. Disabling clears the role, so with a `required` role field
88
+ * every later save of that user, such as an admin editing a profile field, would fail. No role
89
+ * passes only while `oidcDisabledAt` is set; anything else goes to the field's own `validate`, or
90
+ * to Payload's default for its type (known options, `required` and so on).
91
+ */
92
+ const allowNoRoleWhenDisabled = (field) => {
93
+ const own = field.validate;
94
+ const fallback = validations[field.type];
95
+ const validate = (value, args) => {
96
+ const siblingData = args?.siblingData;
97
+ if (isNoRole(value) && Boolean(siblingData?.[OIDC_FIELDS.disabledAt]))
98
+ return true;
99
+ if (own)
100
+ return own(value, args);
101
+ // As Payload applies its default (it does so only to fields without a `validate`).
102
+ return fallback ? fallback(value, { ...field, ...args }) : true;
103
+ };
104
+ return { ...field, validate };
105
+ };
106
+ /** The fields with the role field's validation allowing no role on a disabled user. */
107
+ const withRoleValidation = (fields, options) => mapNamedFields(fields, new Set([options.roleField]), new Set(), allowNoRoleWhenDisabled);
108
+ /** Makes the provider-owned fields updatable only by sync, adding `email` so it can be locked too. */
109
+ const lockManagedFields = (collection, options) => {
110
+ const fields = [...(collection.fields ?? [])];
111
+ if (!hasField(fields, 'email'))
112
+ fields.push({ name: 'email', type: 'email' });
113
+ const names = new Set(['email', options.firstNameField, options.lastNameField, options.roleField]);
114
+ const found = new Set();
115
+ // Only update: creation is already limited to sync (and the break-glass first-register) by
116
+ // the beforeOperation hook, and first-register needs to write these fields.
117
+ const locked = mapNamedFields(fields, names, found, syncOnlyUpdate);
118
+ for (const name of names) {
119
+ if (!found.has(name))
120
+ throw new Error(`payload-oidc: ${collection.slug}.${name} not found.`);
121
+ }
122
+ return locked;
123
+ };
124
+ /** Never store `oidcSubject: null`: the sparse unique index would make a second one collide. */
125
+ const dropNullSubject = ({ data }) => {
126
+ if (data && (data[OIDC_FIELDS.subject] === null || data[OIDC_FIELDS.subject] === '')) {
127
+ delete data[OIDC_FIELDS.subject];
128
+ }
129
+ return data;
130
+ };
131
+ /**
132
+ * Signs staff in to the Payload admin through an OpenID Connect provider, which becomes the source
133
+ * of truth for the auth collection's users and roles.
134
+ *
135
+ * With the issuer, client ID or client secret unset, the plugin adds its fields and inert
136
+ * endpoints so the schema stays the same, and changes nothing else: password sign-in works as
137
+ * before.
138
+ */
139
+ export const oidcPlugin = (pluginOptions, testing) => (incoming) => {
140
+ const options = resolveOptions(pluginOptions);
141
+ const client = testing?.client ?? createProviderClient(options);
142
+ const collections = incoming.collections ?? [];
143
+ const target = collections.find(({ slug }) => slug === options.collection);
144
+ if (!target)
145
+ throw new Error(`payload-oidc: no "${options.collection}" collection.`);
146
+ if (!target.auth)
147
+ throw new Error(`payload-oidc: "${options.collection}" is not an auth collection.`);
148
+ // `endpoints: false` would be replaced by our list, and Payload would then add back every
149
+ // auth route, password ones included.
150
+ if (target.endpoints === false) {
151
+ throw new Error(`payload-oidc: "${options.collection}" must keep its REST endpoints.`);
152
+ }
153
+ options.verify = typeof target.auth === 'object' && Boolean(target.auth.verify);
154
+ const loginWithUsername = typeof target.auth === 'object' ? target.auth.loginWithUsername : undefined;
155
+ options.loginWithUsername = loginWithUsername
156
+ ? {
157
+ allowEmailLogin: typeof loginWithUsername === 'object' && Boolean(loginWithUsername.allowEmailLogin),
158
+ }
159
+ : undefined;
160
+ const auth = typeof target.auth === 'object' ? target.auth : {};
161
+ const collection = {
162
+ ...target,
163
+ // The admin then hides "Force Unlock"; the unlock operation is refused regardless.
164
+ access: options.enabled ? { ...target.access, unlock: () => false } : target.access,
165
+ auth: options.enabled
166
+ ? { ...auth, tokenExpiration: options.sessionSeconds, useSessions: true }
167
+ : auth,
168
+ endpoints: [...(target.endpoints ?? []), ...createEndpoints(options, client, testing?.clock)],
169
+ fields: [
170
+ // Role validation whether or not sign-in is on: users disabled before it was turned off
171
+ // keep no role, and must still be editable.
172
+ ...withRoleValidation(options.enabled ? lockManagedFields(target, options) : (target.fields ?? []), options),
173
+ ...pluginFields(options),
174
+ ],
175
+ hooks: {
176
+ ...target.hooks,
177
+ beforeChange: [...(target.hooks?.beforeChange ?? []), dropNullSubject],
178
+ beforeOperation: [
179
+ ...(target.hooks?.beforeOperation ?? []),
180
+ createBeforeOperationHook(options, testing?.clock),
181
+ ],
182
+ // First: Payload stops at the first refresh hook that returns a result, so a host hook
183
+ // placed before this one could sign an uncapped token.
184
+ refresh: [createRefreshHook(options, testing?.clock), ...(target.hooks?.refresh ?? [])],
185
+ },
186
+ };
187
+ const apiRoute = incoming.routes?.api ?? '/api';
188
+ const publicState = {
189
+ authorizePath: `${apiRoute}/${options.collection}/oidc/authorize`,
190
+ buttonLabel: options.buttonLabel,
191
+ enabled: options.enabled,
192
+ passwordLogin: options.passwordLogin,
193
+ };
194
+ const onInit = incoming.onInit;
195
+ return {
196
+ ...incoming,
197
+ // In an app that already has jobs, registered whether or not reconcile is configured, so the
198
+ // job schema doesn't depend on the environment (it is only scheduled when configured). In an
199
+ // app without jobs, only with a source, so the plugin never turns jobs on by itself.
200
+ ...(incoming.jobs?.tasks?.length || options.reconcile
201
+ ? {
202
+ jobs: {
203
+ ...incoming.jobs,
204
+ tasks: [
205
+ ...(incoming.jobs?.tasks ?? []),
206
+ createReconcileTask(options, {
207
+ clock: testing?.clock,
208
+ concurrencyControl: Boolean(incoming.jobs?.enableConcurrencyControl),
209
+ }),
210
+ ],
211
+ },
212
+ }
213
+ : {}),
214
+ admin: {
215
+ ...incoming.admin,
216
+ components: {
217
+ ...incoming.admin?.components,
218
+ beforeLogin: [
219
+ ...(incoming.admin?.components?.beforeLogin ?? []),
220
+ '@signal58/payload-oidc/rsc#OidcLoginButton',
221
+ ],
222
+ },
223
+ },
224
+ collections: collections.map((candidate) => (candidate === target ? collection : candidate)),
225
+ // Refused password sign-ins and ended sessions are expected: not errors.
226
+ loggingLevels: {
227
+ ...incoming.loggingLevels,
228
+ [OIDC_REFUSAL_NAME]: 'info',
229
+ },
230
+ custom: { ...incoming.custom, [PUBLIC_STATE_KEY]: publicState },
231
+ onInit: async (payload) => {
232
+ if (onInit)
233
+ await onInit(payload);
234
+ payload.logger.info(options.enabled
235
+ ? `payload-oidc: single sign-on is on for "${options.collection}"; password sign-in is ${options.passwordLogin ? 'open to the break-glass account' : 'off'}.`
236
+ : `payload-oidc: off (issuer, client ID or client secret unset); password sign-in is unchanged.`);
237
+ if (options.enabled) {
238
+ const table = Object.entries(options.roleTable)
239
+ .map(([role, groups]) => `${role} ← ${groups.join(', ')}`)
240
+ .join('; ');
241
+ payload.logger.info(`payload-oidc: role table (highest first): ${table}.`);
242
+ payload.logger.info(options.reconcile
243
+ ? `payload-oidc: reconcile runs on queue "${options.reconcile.queue}" (${options.reconcile.cron}); the app must auto-run that queue.`
244
+ : 'payload-oidc: reconcile is off (no source configured); removals apply at the next sign-in only.');
245
+ }
246
+ },
247
+ };
248
+ };
@@ -0,0 +1,43 @@
1
+ import * as oidc from 'openid-client';
2
+ import type { ResolvedOidcOptions } from './options.js';
3
+ /** The verified claims the plugin uses from a sign-in. */
4
+ export type VerifiedIdentity = {
5
+ email: string;
6
+ emailVerified: boolean;
7
+ familyName?: string;
8
+ givenName?: string;
9
+ groups: unknown;
10
+ name?: string;
11
+ subject: string;
12
+ };
13
+ export type ProviderClient = {
14
+ /** Builds the provider's authorization URL, discovering the provider on first use. */
15
+ authorizationUrl: (input: {
16
+ codeChallenge: string;
17
+ nonce: string;
18
+ redirectUri: string;
19
+ state: string;
20
+ }) => Promise<URL>;
21
+ /**
22
+ * Exchanges the callback's code and validates the ID token: signature against the provider's
23
+ * JWKS, issuer, audience, expiry, `nonce`, plus `state` and the PKCE verifier.
24
+ */
25
+ exchange: (input: {
26
+ callbackUrl: URL;
27
+ nonce: string;
28
+ state: string;
29
+ verifier: string;
30
+ }) => Promise<VerifiedIdentity>;
31
+ };
32
+ /**
33
+ * Creates the provider client. Discovery runs the first time sign-in needs it, never at startup
34
+ * or during a build, and a failed discovery isn't cached, so the next sign-in tries again.
35
+ */
36
+ export declare const createProviderClient: (options: ResolvedOidcOptions, discover?: typeof oidc.discovery) => ProviderClient;
37
+ /** Fresh `state`, `nonce` and PKCE verifier for one sign-in. */
38
+ export declare const randomSignInSecrets: () => Promise<{
39
+ codeChallenge: string;
40
+ nonce: string;
41
+ state: string;
42
+ verifier: string;
43
+ }>;
@@ -0,0 +1,67 @@
1
+ import * as oidc from 'openid-client';
2
+ const text = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
3
+ /**
4
+ * Creates the provider client. Discovery runs the first time sign-in needs it, never at startup
5
+ * or during a build, and a failed discovery isn't cached, so the next sign-in tries again.
6
+ */
7
+ export const createProviderClient = (options, discover = oidc.discovery) => {
8
+ let configuration;
9
+ // openid-client skips the ID token's signature when the token comes straight from the token
10
+ // endpoint (OIDC Core 3.1.3.7 lets a client rely on TLS); non-repudiation checks verify it
11
+ // against the provider's JWKS as well.
12
+ const execute = [
13
+ oidc.enableNonRepudiationChecks,
14
+ ...(options.allowInsecureIssuer ? [oidc.allowInsecureRequests] : []),
15
+ ];
16
+ const load = () => {
17
+ configuration ??= discover(new URL(options.issuer), options.clientId, options.clientSecret, undefined, { execute }).catch((error) => {
18
+ configuration = undefined;
19
+ throw error;
20
+ });
21
+ return configuration;
22
+ };
23
+ return {
24
+ authorizationUrl: async ({ codeChallenge, nonce, redirectUri, state }) => oidc.buildAuthorizationUrl(await load(), {
25
+ code_challenge: codeChallenge,
26
+ code_challenge_method: 'S256',
27
+ nonce,
28
+ redirect_uri: redirectUri,
29
+ response_type: 'code',
30
+ scope: options.scopes.join(' '),
31
+ state,
32
+ }),
33
+ exchange: async ({ callbackUrl, nonce, state, verifier }) => {
34
+ const tokens = await oidc.authorizationCodeGrant(await load(), callbackUrl, {
35
+ expectedNonce: nonce,
36
+ expectedState: state,
37
+ idTokenExpected: true,
38
+ pkceCodeVerifier: verifier,
39
+ });
40
+ const claims = tokens.claims();
41
+ if (!claims)
42
+ throw new Error('payload-oidc: the provider returned no ID token.');
43
+ const email = text(claims.email)?.toLowerCase();
44
+ if (!email)
45
+ throw new Error('payload-oidc: the ID token has no email.');
46
+ return {
47
+ email,
48
+ emailVerified: claims.email_verified === true,
49
+ familyName: text(claims.family_name),
50
+ givenName: text(claims.given_name),
51
+ groups: claims.groups,
52
+ name: text(claims.name),
53
+ subject: claims.sub,
54
+ };
55
+ },
56
+ };
57
+ };
58
+ /** Fresh `state`, `nonce` and PKCE verifier for one sign-in. */
59
+ export const randomSignInSecrets = async () => {
60
+ const verifier = oidc.randomPKCECodeVerifier();
61
+ return {
62
+ codeChallenge: await oidc.calculatePKCECodeChallenge(verifier),
63
+ nonce: oidc.randomNonce(),
64
+ state: oidc.randomState(),
65
+ verifier,
66
+ };
67
+ };
@@ -0,0 +1,28 @@
1
+ import type { Payload, TaskConfig } from 'payload';
2
+ import type { ResolvedOidcOptions } from './options.js';
3
+ export declare const RECONCILE_TASK_SLUG = "payload-oidc-reconcile";
4
+ /**
5
+ * Marks reconcile runs on `queue` that are still processing after `olderThanSeconds` as failed,
6
+ * and returns the IDs it marked.
7
+ *
8
+ * Payload's scheduler queues a run only while no scheduled run of the task is waiting or
9
+ * processing, and its runner only picks up jobs that aren't processing. A run left processing by a
10
+ * process that stopped mid-run (a container a deploy replaced) would therefore block the queue for
11
+ * good: Payload never expires it. Payload's own cancel writes its final-failure shape
12
+ * (`hasError`, `processing: false`, `error.cancelled`), so the dead run stays visible as a failed
13
+ * job and the scheduler stops counting it.
14
+ */
15
+ export declare const releaseStaleRuns: (payload: Payload, { now, olderThanSeconds, queue }: {
16
+ now: Date;
17
+ olderThanSeconds: number;
18
+ queue: string;
19
+ }) => Promise<Array<number | string>>;
20
+ /**
21
+ * The reconcile job. Payload schedules it only on its own queue, and runs schedules only for
22
+ * queues listed in `jobs.autoRun`, so the app must add that queue there. A failed run (a group
23
+ * that can't be read, a safety stop) fails the job and records no heartbeat.
24
+ */
25
+ export declare const createReconcileTask: (options: ResolvedOidcOptions, { clock, concurrencyControl }?: {
26
+ clock?: (() => Date) | undefined;
27
+ concurrencyControl?: boolean | undefined;
28
+ }) => TaskConfig;