@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.
@@ -0,0 +1,193 @@
1
+ import { generatePayloadCookie, parseCookies } from 'payload/shared';
2
+ import { reconcileHealth } from './reconcile.js';
3
+ import { removeUser } from './remove.js';
4
+ import { authCollectionConfig } from './store.js';
5
+ import { randomSignInSecrets } from './provider.js';
6
+ import { completeSignIn } from './sign-in.js';
7
+ import { openTransaction, safeReturnPath, sealTransaction, TRANSACTION_MAX_AGE_SECONDS, } from './transaction.js';
8
+ /** Query parameter on the login page that carries a refusal code. */
9
+ export const SIGN_IN_ERROR_PARAM = 'oidc_error';
10
+ const noStore = { 'Cache-Control': 'private, no-store' };
11
+ const notConfigured = () => Response.json({ error: { code: 'not_found', message: 'Single sign-on is not configured.' } }, { headers: noStore, status: 404 });
12
+ const firstHeader = (req, name) => req.headers.get(name)?.split(',')[0]?.trim() || undefined;
13
+ /**
14
+ * The public origin this sign-in runs on: the request's own origin when it is one of the allowed
15
+ * ones, so the transaction cookie and the callback stay on one host, otherwise the configured
16
+ * `serverURL`. Headers can only choose among allowed origins, never add one.
17
+ */
18
+ const serverUrl = (req, options) => {
19
+ const configured = options.serverURL ?? req.payload.config.serverURL;
20
+ if (!configured) {
21
+ throw new Error('payload-oidc needs its serverURL option or serverURL in the Payload config.');
22
+ }
23
+ const fallback = new URL(configured).origin;
24
+ if (options.additionalServerURLs.length === 0)
25
+ return fallback;
26
+ const url = new URL(req.url ?? fallback, fallback);
27
+ const host = firstHeader(req, 'x-forwarded-host') ?? firstHeader(req, 'host') ?? url.host;
28
+ const protocol = firstHeader(req, 'x-forwarded-proto') ?? url.protocol.replace(/:$/, '');
29
+ const candidate = `${protocol}://${host}`.toLowerCase();
30
+ return ([fallback, ...options.additionalServerURLs].find((origin) => origin === candidate) ?? fallback);
31
+ };
32
+ const callbackPath = (req, options) => `${req.payload.config.routes.api}/${options.collection}/oidc/callback`;
33
+ /** The redirect URI registered with the provider. */
34
+ export const redirectUri = (req, options) => `${serverUrl(req, options)}${callbackPath(req, options)}`;
35
+ const transactionCookieName = (req) => `${req.payload.config.cookiePrefix}-oidc`;
36
+ const transactionCookie = (req, options, value, maxAge) => [
37
+ `${transactionCookieName(req)}=${value}`,
38
+ `Path=${callbackPath(req, options)}`,
39
+ `Max-Age=${maxAge}`,
40
+ 'HttpOnly',
41
+ // Lax, not Strict: the provider's redirect back is a cross-site navigation, and Strict would
42
+ // drop the cookie on exactly that request.
43
+ 'SameSite=Lax',
44
+ ...(serverUrl(req, options).startsWith('https:') ? ['Secure'] : []),
45
+ ].join('; ');
46
+ const loginPath = (req) => {
47
+ const { admin, routes } = req.payload.config;
48
+ return `${routes.admin.replace(/\/+$/, '')}${admin.routes.login}`;
49
+ };
50
+ const redirect = (location, cookies = []) => {
51
+ const headers = new Headers({ ...noStore, Location: location });
52
+ for (const cookie of cookies)
53
+ headers.append('Set-Cookie', cookie);
54
+ return new Response(null, { headers, status: 302 });
55
+ };
56
+ const toLogin = (req, reason, cookies = []) => redirect(`${loginPath(req)}?${SIGN_IN_ERROR_PARAM}=${reason}`, cookies);
57
+ export const createEndpoints = (options, client, clock = () => new Date()) => [
58
+ {
59
+ handler: async (req) => {
60
+ if (!options.enabled)
61
+ return notConfigured();
62
+ const requested = new URL(req.url ?? '', 'http://payload-oidc.invalid').searchParams.get('redirect');
63
+ const returnTo = safeReturnPath(requested, req.payload.config.routes.admin);
64
+ const secrets = await randomSignInSecrets();
65
+ let callback;
66
+ try {
67
+ callback = redirectUri(req, options);
68
+ }
69
+ catch (error) {
70
+ req.payload.logger.error({ err: error, msg: 'payload-oidc: no public origin configured' });
71
+ return toLogin(req, 'failed');
72
+ }
73
+ let location;
74
+ try {
75
+ location = await client.authorizationUrl({
76
+ codeChallenge: secrets.codeChallenge,
77
+ nonce: secrets.nonce,
78
+ redirectUri: callback,
79
+ state: secrets.state,
80
+ });
81
+ }
82
+ catch (error) {
83
+ req.payload.logger.error({ err: error, msg: 'payload-oidc: provider discovery failed' });
84
+ return toLogin(req, 'unavailable');
85
+ }
86
+ const sealed = sealTransaction(req.payload.secret, {
87
+ createdAt: clock().getTime(),
88
+ nonce: secrets.nonce,
89
+ returnTo,
90
+ state: secrets.state,
91
+ verifier: secrets.verifier,
92
+ });
93
+ return redirect(location.href, [
94
+ transactionCookie(req, options, sealed, TRANSACTION_MAX_AGE_SECONDS),
95
+ ]);
96
+ },
97
+ method: 'get',
98
+ path: '/oidc/authorize',
99
+ },
100
+ {
101
+ handler: async (req) => {
102
+ if (!options.enabled)
103
+ return notConfigured();
104
+ const clear = transactionCookie(req, options, '', 0);
105
+ const now = clock();
106
+ const transaction = openTransaction(req.payload.secret, parseCookies(req.headers).get(transactionCookieName(req)), now);
107
+ if (!transaction)
108
+ return toLogin(req, 'expired', [clear]);
109
+ // The registered redirect URI with the provider's parameters: behind a proxy, req.url
110
+ // carries the internal origin, which the token endpoint would reject.
111
+ const callbackUrl = new URL(redirectUri(req, options));
112
+ callbackUrl.search = new URL(req.url ?? '', 'http://payload-oidc.invalid').search;
113
+ let identity;
114
+ try {
115
+ identity = await client.exchange({
116
+ callbackUrl,
117
+ nonce: transaction.nonce,
118
+ state: transaction.state,
119
+ verifier: transaction.verifier,
120
+ });
121
+ }
122
+ catch (error) {
123
+ req.payload.logger.warn({ err: error, msg: 'payload-oidc: sign-in failed at the provider' });
124
+ return toLogin(req, 'provider', [clear]);
125
+ }
126
+ let outcome;
127
+ try {
128
+ outcome = await completeSignIn(req.payload, options, identity, now);
129
+ }
130
+ catch (error) {
131
+ req.payload.logger.error({ err: error, msg: 'payload-oidc: sign-in failed in the CMS' });
132
+ return toLogin(req, 'failed', [clear]);
133
+ }
134
+ if (outcome.kind === 'refused') {
135
+ req.payload.logger.info({ msg: 'payload-oidc: sign-in refused', reason: outcome.reason });
136
+ return toLogin(req, outcome.reason, [clear]);
137
+ }
138
+ const authConfig = authCollectionConfig(req.payload, options.collection).auth;
139
+ const sessionCookie = generatePayloadCookie({
140
+ collectionAuthConfig: authConfig,
141
+ cookiePrefix: req.payload.config.cookiePrefix,
142
+ token: outcome.token,
143
+ });
144
+ return redirect(transaction.returnTo, [clear, sessionCookie]);
145
+ },
146
+ method: 'get',
147
+ path: '/oidc/callback',
148
+ },
149
+ {
150
+ // Removes a disabled user: deleted if nothing refers to them, otherwise anonymised. POST
151
+ // only, and the app decides who may (`removeAccess`, default nobody).
152
+ handler: async (req) => {
153
+ if (!options.enabled)
154
+ return notConfigured();
155
+ if (!req.user || !(await options.removeAccess(req))) {
156
+ return Response.json({ error: { code: 'forbidden', message: 'You may not remove users.' } }, { headers: noStore, status: req.user ? 403 : 401 });
157
+ }
158
+ const id = req.routeParams?.id;
159
+ if (typeof id !== 'string' && typeof id !== 'number')
160
+ return notConfigured();
161
+ const outcome = await removeUser(req.payload, options, id);
162
+ if (outcome.kind === 'refused') {
163
+ const messages = {
164
+ break_glass: 'The break-glass account is never removed.',
165
+ not_disabled: 'Only a disabled user can be removed: remove them at the identity provider first.',
166
+ not_found: 'No such user.',
167
+ };
168
+ return Response.json({ error: { code: outcome.reason, message: messages[outcome.reason] } }, { headers: noStore, status: outcome.reason === 'not_found' ? 404 : 409 });
169
+ }
170
+ req.payload.logger.info({
171
+ by: req.user.id,
172
+ id,
173
+ msg: `payload-oidc: user ${outcome.kind}`,
174
+ });
175
+ return Response.json({ result: outcome.kind }, { headers: noStore });
176
+ },
177
+ method: 'post',
178
+ path: '/oidc/remove/:id',
179
+ },
180
+ {
181
+ // For an uptime check (Gatus): 200 while the last successful reconcile is recent, 503 once it
182
+ // isn't, so a failing reconcile (an expired token, a renamed group) alerts instead of silently
183
+ // stopping removals. It says nothing about users.
184
+ handler: async (req) => {
185
+ if (!options.enabled || !options.reconcile)
186
+ return notConfigured();
187
+ const health = await reconcileHealth(req.payload, options, clock());
188
+ return Response.json(health, { headers: noStore, status: health.healthy ? 200 : 503 });
189
+ },
190
+ method: 'get',
191
+ path: '/oidc/reconcile/health',
192
+ },
193
+ ];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * "Remove user" in the sidebar of a disabled user. The endpoint decides who may (the app's
3
+ * `removeAccess`) and whether the user is deleted or anonymised. With `hidePasswordChange`
4
+ * (single sign-on on, password sign-in off) it also hides "Change Password" on every user.
5
+ */
6
+ export declare const RemoveUserButton: ({ hidePasswordChange, }: {
7
+ hidePasswordChange?: boolean;
8
+ }) => import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, toast, useConfig, useDocumentInfo, useFormFields } from '@payloadcms/ui';
4
+ import { useState } from 'react';
5
+ /**
6
+ * Payload shows "Change Password" to anyone who may update the user, and has no option to hide
7
+ * it; the server refuses the change regardless (`beforeOperation`).
8
+ */
9
+ const HIDE_PASSWORD_CHANGE = '#change-password { display: none; }';
10
+ /**
11
+ * "Remove user" in the sidebar of a disabled user. The endpoint decides who may (the app's
12
+ * `removeAccess`) and whether the user is deleted or anonymised. With `hidePasswordChange`
13
+ * (single sign-on on, password sign-in off) it also hides "Change Password" on every user.
14
+ */
15
+ export const RemoveUserButton = ({ hidePasswordChange = false, }) => {
16
+ const { collectionSlug, id } = useDocumentInfo();
17
+ const disabledAt = useFormFields(([fields]) => fields.oidcDisabledAt?.value);
18
+ const { config: { routes }, } = useConfig();
19
+ const [busy, setBusy] = useState(false);
20
+ const hide = hidePasswordChange ? _jsx("style", { children: HIDE_PASSWORD_CHANGE }) : null;
21
+ if (!id || !collectionSlug || !disabledAt)
22
+ return hide;
23
+ const remove = async () => {
24
+ const confirmed = window.confirm('Remove this disabled user? If nothing refers to them they are deleted; otherwise their name and email are replaced so bylines still resolve. This can’t be undone.');
25
+ if (!confirmed)
26
+ return;
27
+ setBusy(true);
28
+ try {
29
+ const response = await fetch(
30
+ // Relative, like Payload's own admin calls: the session cookie belongs to this host.
31
+ `${routes.api}/${collectionSlug}/oidc/remove/${encodeURIComponent(String(id))}`, { credentials: 'include', method: 'POST' });
32
+ const body = (await response.json().catch(() => ({})));
33
+ if (!response.ok) {
34
+ toast.error(body.error?.message ?? 'The user could not be removed.');
35
+ return;
36
+ }
37
+ toast.success(body.result === 'deleted' ? 'User deleted.' : 'User anonymised.');
38
+ window.location.assign(`${routes.admin}/collections/${collectionSlug}`);
39
+ }
40
+ finally {
41
+ setBusy(false);
42
+ }
43
+ };
44
+ return (_jsxs("div", { className: "field-type payload-oidc-remove", style: { marginBottom: 'var(--base)' }, children: [hide, _jsx(Button, { buttonStyle: "secondary", disabled: busy, onClick: remove, size: "medium", children: "Remove user" })] }));
45
+ };
@@ -0,0 +1,9 @@
1
+ import type { Payload } from 'payload';
2
+ /**
3
+ * The single sign-on button above the login form. While password sign-in is off it also hides
4
+ * the password form; the server refuses password operations regardless (`beforeOperation`).
5
+ */
6
+ export declare const OidcLoginButton: ({ payload, searchParams, }: {
7
+ payload: Payload;
8
+ searchParams?: Record<string, string | string[] | undefined>;
9
+ }) => import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { SIGN_IN_ERROR_PARAM } from '../endpoints.js';
3
+ import { PUBLIC_STATE_KEY } from '../plugin.js';
4
+ const MESSAGES = {
5
+ account_conflict: 'An account with this email already exists and can’t be linked. Ask an administrator.',
6
+ disabled: 'This account is disabled. Ask an administrator.',
7
+ email_not_verified: 'Your email address isn’t verified with the identity provider.',
8
+ expired: 'The sign-in took too long or was started in another window. Try again.',
9
+ failed: 'The CMS couldn’t complete the sign-in. Try again, or ask an administrator.',
10
+ no_role: 'Your account has no access to this CMS. Ask an administrator.',
11
+ provider: 'The identity provider didn’t complete the sign-in. Try again.',
12
+ unavailable: 'The identity provider can’t be reached right now. Try again shortly.',
13
+ };
14
+ const firstValue = (value) => typeof value === 'string' ? value : Array.isArray(value) ? firstValue(value[0]) : undefined;
15
+ /**
16
+ * The single sign-on button above the login form. While password sign-in is off it also hides
17
+ * the password form; the server refuses password operations regardless (`beforeOperation`).
18
+ */
19
+ export const OidcLoginButton = ({ payload, searchParams, }) => {
20
+ const state = payload.config.custom?.[PUBLIC_STATE_KEY];
21
+ if (!state?.enabled)
22
+ return null;
23
+ const error = firstValue(searchParams?.[SIGN_IN_ERROR_PARAM]);
24
+ const message = error && Object.hasOwn(MESSAGES, error) ? MESSAGES[error] : undefined;
25
+ const redirect = firstValue(searchParams?.redirect);
26
+ const href = redirect
27
+ ? `${state.authorizePath}?redirect=${encodeURIComponent(redirect)}`
28
+ : state.authorizePath;
29
+ return (_jsxs("div", { className: "payload-oidc", style: { marginBottom: 'var(--base)' }, children: [message ? (_jsx("p", { className: "payload-oidc__error", role: "alert", style: { color: 'var(--theme-error-500)' }, children: message })) : null, _jsx("a", { className: "btn btn--style-primary btn--size-large", href: href, style: { display: 'block', textAlign: 'center', width: '100%' }, children: state.buttonLabel }), state.passwordLogin ? null : _jsx("style", { children: '.login__form { display: none; }' })] }));
30
+ };
@@ -0,0 +1,8 @@
1
+ export { oidcPlugin, PUBLIC_STATE_KEY, syncWriteOnly, type OidcPublicState } from './plugin.js';
2
+ export { roleForGroups, roleTableGroups, type OidcPluginOptions, type ReconcileMember, type ReconcileSource, type RoleTable, } from './options.js';
3
+ export { OIDC_FIELDS, OIDC_SYNC_CONTEXT } from './store.js';
4
+ export type { SignInRefusal } from './sign-in.js';
5
+ export { authentikSource } from './sources/authentik.js';
6
+ export { reconcileUsers, RECONCILE_HEARTBEAT_KEY } from './reconcile.js';
7
+ export { RECONCILE_TASK_SLUG } from './reconcile-task.js';
8
+ export { findUserReferences, removeUser } from './remove.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { oidcPlugin, PUBLIC_STATE_KEY, syncWriteOnly } from './plugin.js';
2
+ export { roleForGroups, roleTableGroups, } from './options.js';
3
+ export { OIDC_FIELDS, OIDC_SYNC_CONTEXT } from './store.js';
4
+ export { authentikSource } from './sources/authentik.js';
5
+ export { reconcileUsers, RECONCILE_HEARTBEAT_KEY } from './reconcile.js';
6
+ export { RECONCILE_TASK_SLUG } from './reconcile-task.js';
7
+ export { findUserReferences, removeUser } from './remove.js';
@@ -0,0 +1,159 @@
1
+ import type { PayloadRequest, TaskConfig } from 'payload';
2
+ /**
3
+ * Role name → the provider groups that grant it, **highest role first**. A user in groups for
4
+ * several roles gets the first one listed, and a user in none of them gets no role.
5
+ */
6
+ export type RoleTable = Readonly<Record<string, readonly string[]>>;
7
+ /** Hooks for the reconcile task's schedule (Payload's `ScheduleConfig['hooks']`). */
8
+ export type ReconcileScheduleHooks = NonNullable<NonNullable<NonNullable<TaskConfig['schedule']>[number]['hooks']>>;
9
+ /** One member of a role-table group, as the provider's directory reports it. */
10
+ export type ReconcileMember = {
11
+ /** Whether the provider still lets this user sign in. Inactive members count as removed. */
12
+ active: boolean;
13
+ /** The same value as the ID token's `sub` for this user. */
14
+ subject: string;
15
+ };
16
+ /** Reads the current members of every role-table group from the provider. */
17
+ export type ReconcileSource = {
18
+ /**
19
+ * Returns the members of each group. Must throw if any group can't be read: reconcile only acts
20
+ * on a complete answer.
21
+ */
22
+ fetchGroupMembers: (groups: readonly string[]) => Promise<Map<string, ReconcileMember[]>>;
23
+ /** How often reconcile runs, as a cron expression. */
24
+ cron: string;
25
+ /** The Payload jobs queue the reconcile task runs on. The app must auto-run this queue. */
26
+ queue: string;
27
+ };
28
+ export type OidcPluginOptions = {
29
+ /**
30
+ * Reopens password sign-in, for the `breakGlassEmail` account and, until `linkByEmailUntil`,
31
+ * for users the migration marked `oidcLinkable` who haven't linked yet.
32
+ */
33
+ passwordLogin?: boolean;
34
+ /** Text of the admin sign-in button. */
35
+ buttonLabel?: string;
36
+ /**
37
+ * The one account that may use a password while `passwordLogin` is on, and that sync and
38
+ * reconcile never disable. Compared case-insensitively.
39
+ */
40
+ breakGlassEmail?: string;
41
+ clientId?: string;
42
+ clientSecret?: string;
43
+ /** The auth collection staff sign in to. */
44
+ collection?: string;
45
+ /** Names of the profile fields the provider owns. */
46
+ fields?: {
47
+ firstName?: string;
48
+ lastName?: string;
49
+ };
50
+ /**
51
+ * The provider's issuer URL. With it, `clientId` or `clientSecret` unset, the plugin is off:
52
+ * nothing changes and password sign-in works as before.
53
+ */
54
+ issuer?: string;
55
+ /** Written to `lastName` when the provider sends no `family_name`. */
56
+ lastNamePlaceholder?: string;
57
+ /**
58
+ * Until this date (ISO 8601), a first sign-in may link an existing user that the app's
59
+ * migration marked `oidcLinkable`, by verified email. After it, linking stops.
60
+ */
61
+ linkByEmailUntil?: string;
62
+ /** Reconcile source (optional). Without one, removal applies at the next sign-in only. */
63
+ reconcile?: ReconcileSource;
64
+ /** The largest share of linked users one reconcile run may disable (at least one always may). */
65
+ reconcileMaxDisableShare?: number;
66
+ /** How old the last successful reconcile may be before the health endpoint reports it, in minutes. */
67
+ reconcileHeartbeatMaxMinutes?: number;
68
+ /** Fields an anonymised user keeps (by default every profile field is cleared). */
69
+ keepOnRemove?: string[];
70
+ /** Hooks for the reconcile schedule, such as one that releases jobs a deploy cut off. */
71
+ reconcileScheduleHooks?: ReconcileScheduleHooks;
72
+ /** Who may remove a disabled user. Defaults to nobody, so an app must decide. */
73
+ removeAccess?: (req: PayloadRequest) => boolean | Promise<boolean>;
74
+ roles: {
75
+ /** The role field on the auth collection. */
76
+ field: string;
77
+ /** Whether the role field stores a list (a `hasMany` select) or a single value. */
78
+ hasMany?: boolean;
79
+ table: RoleTable;
80
+ };
81
+ /**
82
+ * The public origin the callback is registered under (`<origin>/api/<collection>/oidc/callback`).
83
+ * Defaults to Payload's `serverURL`; set it when that is unset, as some apps do in development.
84
+ */
85
+ serverURL?: string;
86
+ /**
87
+ * Further public origins the app is also served from (each registered with the provider as a
88
+ * callback too). Sign-in uses the request's own origin when it is one of these, so its cookie and
89
+ * callback stay on one host; any other origin falls back to `serverURL`.
90
+ */
91
+ additionalServerURLs?: string[];
92
+ /** Scopes requested at sign-in. */
93
+ scopes?: string[];
94
+ /** Idle timeout: the collection's `tokenExpiration`, in hours. */
95
+ sessionHours?: number;
96
+ /** Absolute cap on a session from sign-in, in hours. Refresh is refused after it. */
97
+ sessionMaxHours?: number;
98
+ /**
99
+ * Allows an `http:` issuer. Only for a provider on the local machine in development or tests;
100
+ * refused when `NODE_ENV` is `production`.
101
+ */
102
+ allowInsecureIssuer?: boolean;
103
+ };
104
+ export type ResolvedOidcOptions = {
105
+ allowInsecureIssuer: boolean;
106
+ breakGlassEmail?: string;
107
+ buttonLabel: string;
108
+ clientId: string;
109
+ clientSecret: string;
110
+ collection: string;
111
+ enabled: boolean;
112
+ firstNameField: string;
113
+ issuer: string;
114
+ lastNameField: string;
115
+ lastNamePlaceholder: string;
116
+ linkByEmailUntil?: Date;
117
+ passwordLogin: boolean;
118
+ reconcile?: ReconcileSource;
119
+ keepOnRemove: string[];
120
+ reconcileScheduleHooks?: ReconcileScheduleHooks;
121
+ reconcileHeartbeatMaxSeconds: number;
122
+ reconcileMaxDisableShare: number;
123
+ removeAccess: (req: PayloadRequest) => boolean | Promise<boolean>;
124
+ roleField: string;
125
+ roleHasMany: boolean;
126
+ roleTable: RoleTable;
127
+ scopes: string[];
128
+ sessionMaxSeconds: number;
129
+ sessionSeconds: number;
130
+ /** The public origin the callback is registered under, if not Payload's `serverURL`. */
131
+ serverURL?: string;
132
+ /** Further allowed public origins, normalised. */
133
+ additionalServerURLs: string[];
134
+ /** Whether the collection uses `auth.verify`; set by the plugin from the collection config. */
135
+ verify: boolean;
136
+ /** The collection's `loginWithUsername` setting; set by the plugin from the collection config. */
137
+ loginWithUsername?: {
138
+ allowEmailLogin: boolean;
139
+ };
140
+ };
141
+ /**
142
+ * Validates the options and fills in defaults. Throws for a configuration that can't be safe,
143
+ * so a mistake fails at startup rather than at someone's first sign-in.
144
+ */
145
+ export declare const resolveOptions: (options: OidcPluginOptions) => ResolvedOidcOptions;
146
+ /**
147
+ * The role a user's groups grant: the first role in the table with a matching group.
148
+ *
149
+ * @param table - The role table, highest role first.
150
+ * @param groups - The `groups` claim (or a reconcile member's groups); anything else is no role.
151
+ * @returns The role, or `undefined` for none.
152
+ */
153
+ export declare const roleForGroups: (table: RoleTable, groups: unknown) => string | undefined;
154
+ /** Every group named in the role table, once. */
155
+ export declare const roleTableGroups: (table: RoleTable) => string[];
156
+ /** Whether an email is the break-glass account's. */
157
+ export declare const isBreakGlassEmail: (options: ResolvedOidcOptions, email: unknown) => boolean;
158
+ /** Whether linking by email is still allowed at `now`. */
159
+ export declare const canLinkByEmail: (options: ResolvedOidcOptions, now: Date) => boolean;
@@ -0,0 +1,126 @@
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 toOrigin = (url, name) => {
9
+ try {
10
+ return new URL(url.trim()).origin;
11
+ }
12
+ catch {
13
+ throw new Error(`payload-oidc: ${name} must be absolute URLs.`);
14
+ }
15
+ };
16
+ const shareBetweenZeroAndOne = (share) => {
17
+ if (!Number.isFinite(share) || share < 0 || share > 1) {
18
+ throw new Error('payload-oidc: reconcileMaxDisableShare must be between 0 and 1.');
19
+ }
20
+ return share;
21
+ };
22
+ /**
23
+ * Validates the options and fills in defaults. Throws for a configuration that can't be safe,
24
+ * so a mistake fails at startup rather than at someone's first sign-in.
25
+ */
26
+ export const resolveOptions = (options) => {
27
+ const issuer = options.issuer?.trim() ?? '';
28
+ const clientId = options.clientId?.trim() ?? '';
29
+ const clientSecret = options.clientSecret ?? '';
30
+ const enabled = Boolean(issuer && clientId && clientSecret);
31
+ const roleTable = options.roles?.table ?? {};
32
+ if (!options.roles?.field)
33
+ throw new Error('payload-oidc: roles.field is required.');
34
+ if (Object.keys(roleTable).length === 0) {
35
+ throw new Error('payload-oidc: roles.table needs at least one role.');
36
+ }
37
+ for (const [role, groups] of Object.entries(roleTable)) {
38
+ if (!Array.isArray(groups) || groups.length === 0 || groups.some((group) => !group)) {
39
+ throw new Error(`payload-oidc: role "${role}" needs at least one group.`);
40
+ }
41
+ }
42
+ const sessionHours = positiveHours(options.sessionHours, 8, 'sessionHours');
43
+ const sessionMaxHours = positiveHours(options.sessionMaxHours, 12, 'sessionMaxHours');
44
+ if (sessionMaxHours < sessionHours) {
45
+ throw new Error('payload-oidc: sessionMaxHours must be at least sessionHours.');
46
+ }
47
+ let linkByEmailUntil;
48
+ if (options.linkByEmailUntil) {
49
+ linkByEmailUntil = new Date(options.linkByEmailUntil);
50
+ if (Number.isNaN(linkByEmailUntil.getTime())) {
51
+ throw new Error('payload-oidc: linkByEmailUntil must be an ISO 8601 date.');
52
+ }
53
+ }
54
+ if (enabled) {
55
+ const url = new URL(issuer);
56
+ if (url.protocol !== 'https:') {
57
+ if (!options.allowInsecureIssuer || process.env.NODE_ENV === 'production') {
58
+ throw new Error('payload-oidc: the issuer must use https.');
59
+ }
60
+ }
61
+ }
62
+ const breakGlassEmail = options.breakGlassEmail?.trim().toLowerCase() || undefined;
63
+ if (enabled && options.passwordLogin && !breakGlassEmail) {
64
+ throw new Error('payload-oidc: passwordLogin needs breakGlassEmail.');
65
+ }
66
+ return {
67
+ allowInsecureIssuer: Boolean(options.allowInsecureIssuer),
68
+ breakGlassEmail,
69
+ buttonLabel: options.buttonLabel ?? 'Sign in with single sign-on',
70
+ clientId,
71
+ clientSecret,
72
+ collection: options.collection ?? 'users',
73
+ enabled,
74
+ firstNameField: options.fields?.firstName ?? 'firstName',
75
+ issuer,
76
+ lastNameField: options.fields?.lastName ?? 'lastName',
77
+ lastNamePlaceholder: options.lastNamePlaceholder ?? '-',
78
+ linkByEmailUntil,
79
+ passwordLogin: Boolean(options.passwordLogin),
80
+ reconcile: options.reconcile,
81
+ keepOnRemove: options.keepOnRemove ?? [],
82
+ reconcileScheduleHooks: options.reconcileScheduleHooks,
83
+ reconcileHeartbeatMaxSeconds: Math.round(positiveHours((options.reconcileHeartbeatMaxMinutes ?? 45) / 60, 0.75, 'reconcileHeartbeatMaxMinutes') * 3600),
84
+ reconcileMaxDisableShare: shareBetweenZeroAndOne(options.reconcileMaxDisableShare ?? 0.25),
85
+ removeAccess: options.removeAccess ?? (() => false),
86
+ roleField: options.roles.field,
87
+ roleHasMany: options.roles.hasMany ?? true,
88
+ roleTable,
89
+ scopes: options.scopes ?? ['openid', 'email', 'profile'],
90
+ sessionMaxSeconds: Math.round(sessionMaxHours * 3600),
91
+ sessionSeconds: Math.round(sessionHours * 3600),
92
+ serverURL: options.serverURL ? toOrigin(options.serverURL, 'serverURL') : undefined,
93
+ additionalServerURLs: (options.additionalServerURLs ?? [])
94
+ .map((url) => url.trim())
95
+ .filter(Boolean)
96
+ .map((url) => toOrigin(url, 'additionalServerURLs')),
97
+ verify: false,
98
+ };
99
+ };
100
+ /**
101
+ * The role a user's groups grant: the first role in the table with a matching group.
102
+ *
103
+ * @param table - The role table, highest role first.
104
+ * @param groups - The `groups` claim (or a reconcile member's groups); anything else is no role.
105
+ * @returns The role, or `undefined` for none.
106
+ */
107
+ export const roleForGroups = (table, groups) => {
108
+ if (!Array.isArray(groups))
109
+ return undefined;
110
+ const names = new Set(groups.filter((group) => typeof group === 'string'));
111
+ for (const [role, roleGroups] of Object.entries(table)) {
112
+ if (roleGroups.some((group) => names.has(group)))
113
+ return role;
114
+ }
115
+ return undefined;
116
+ };
117
+ /** Every group named in the role table, once. */
118
+ export const roleTableGroups = (table) => [
119
+ ...new Set(Object.values(table).flat()),
120
+ ];
121
+ /** Whether an email is the break-glass account's. */
122
+ export const isBreakGlassEmail = (options, email) => Boolean(options.breakGlassEmail &&
123
+ typeof email === 'string' &&
124
+ email.trim().toLowerCase() === options.breakGlassEmail);
125
+ /** Whether linking by email is still allowed at `now`. */
126
+ export const canLinkByEmail = (options, now) => Boolean(options.linkByEmailUntil && now < options.linkByEmailUntil);
@@ -0,0 +1,25 @@
1
+ import type { FieldAccess, 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;