@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
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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';
|
|
9
|
+
export { clearLinkingSnapshot, takeLinkingSnapshot, type OidcMigrationArgs, type OidcMigrationOptions, } from './migration.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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';
|
|
8
|
+
export { clearLinkingSnapshot, takeLinkingSnapshot, } from './migration.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Payload } from 'payload';
|
|
2
|
+
/**
|
|
3
|
+
* What the helpers need from a migration's arguments (`MigrateUpArgs` or `MigrateDownArgs` from
|
|
4
|
+
* `@payloadcms/db-mongodb`): pass those straight through.
|
|
5
|
+
*/
|
|
6
|
+
export type OidcMigrationArgs = {
|
|
7
|
+
payload: Payload;
|
|
8
|
+
/** The migration's MongoDB session, when `payload migrate` runs it in a transaction. */
|
|
9
|
+
session?: unknown;
|
|
10
|
+
};
|
|
11
|
+
export type OidcMigrationOptions = {
|
|
12
|
+
/** The auth collection the plugin is on. */
|
|
13
|
+
collection?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* For an app's migration, before sign-in is turned on: marks every existing user that isn't
|
|
17
|
+
* linked yet `oidcLinkable`, so each can be linked once, by verified email, until
|
|
18
|
+
* `linkByEmailUntil`. Users created later (at their first sign-in) are never linkable, so a new
|
|
19
|
+
* provider account can never claim one of them by email.
|
|
20
|
+
*
|
|
21
|
+
* It builds the collection's indexes first, among them the sparse unique `oidcSubject` index:
|
|
22
|
+
* Mongoose starts that build in the background when `payload migrate` boots, and writing in the
|
|
23
|
+
* migration's transaction while it runs fails with a transient catalog-change error. The build is
|
|
24
|
+
* idempotent and runs outside the transaction. **Call this first in the migration**, before any
|
|
25
|
+
* other operation in its transaction, on any collection: a transaction reads from the moment of
|
|
26
|
+
* its first operation, so after a build that had work to do its write to the collection fails
|
|
27
|
+
* (a write conflict, and a rerun of the migration then passes). It refuses, before any change, a
|
|
28
|
+
* user that stores `oidcSubject: null`, which the sparse index would still index (and collide
|
|
29
|
+
* on). A user with an `oidcSubject` is already linked and left alone, so a rerun is a no-op.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* export async function up(args: MigrateUpArgs): Promise<void> {
|
|
33
|
+
* await takeLinkingSnapshot(args)
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @returns How many users were marked.
|
|
38
|
+
*/
|
|
39
|
+
export declare const takeLinkingSnapshot: ({ payload, session }: OidcMigrationArgs, { collection: slug }?: OidcMigrationOptions) => Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* The `down` of {@link takeLinkingSnapshot}: clears every `oidcLinkable` mark. Links already made
|
|
42
|
+
* (`oidcSubject`) and disables (`oidcDisabledAt`) stay: they are the provider's record of who
|
|
43
|
+
* these users are, and with the plugin off they do nothing.
|
|
44
|
+
*
|
|
45
|
+
* @returns How many marks were cleared.
|
|
46
|
+
*/
|
|
47
|
+
export declare const clearLinkingSnapshot: ({ payload, session }: OidcMigrationArgs, { collection: slug }?: OidcMigrationOptions) => Promise<number>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { OIDC_FIELDS } from './store.js';
|
|
2
|
+
/** The collection's MongoDB collection, written directly, and its compiled model. */
|
|
3
|
+
const usersCollection = (payload, slug) => {
|
|
4
|
+
const db = payload.db;
|
|
5
|
+
const model = db.collections?.[slug];
|
|
6
|
+
const name = model?.collection?.name;
|
|
7
|
+
const database = db.connection?.db;
|
|
8
|
+
if (!model || !name || !database) {
|
|
9
|
+
throw new Error(`payload-oidc: the migration needs the MongoDB adapter (no model for "${slug}").`);
|
|
10
|
+
}
|
|
11
|
+
const config = payload.collections[slug]?.config;
|
|
12
|
+
const hasPluginFields = config?.fields.some((field) => field.name === OIDC_FIELDS.subject);
|
|
13
|
+
if (!hasPluginFields) {
|
|
14
|
+
throw new Error(`payload-oidc: "${slug}" has no ${OIDC_FIELDS.subject} field; add the plugin to the config before this migration runs.`);
|
|
15
|
+
}
|
|
16
|
+
// Direct writes: through the model, Mongoose would also stamp `updatedAt` on every user.
|
|
17
|
+
return { collection: database.collection(name), model };
|
|
18
|
+
};
|
|
19
|
+
/** MongoDB's write conflict, which a transaction gets when the collection changed under it. */
|
|
20
|
+
const isWriteConflict = (error) => {
|
|
21
|
+
const candidate = error;
|
|
22
|
+
return candidate?.code === 112 || candidate?.hasErrorLabel?.('TransientTransactionError') === true;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* For an app's migration, before sign-in is turned on: marks every existing user that isn't
|
|
26
|
+
* linked yet `oidcLinkable`, so each can be linked once, by verified email, until
|
|
27
|
+
* `linkByEmailUntil`. Users created later (at their first sign-in) are never linkable, so a new
|
|
28
|
+
* provider account can never claim one of them by email.
|
|
29
|
+
*
|
|
30
|
+
* It builds the collection's indexes first, among them the sparse unique `oidcSubject` index:
|
|
31
|
+
* Mongoose starts that build in the background when `payload migrate` boots, and writing in the
|
|
32
|
+
* migration's transaction while it runs fails with a transient catalog-change error. The build is
|
|
33
|
+
* idempotent and runs outside the transaction. **Call this first in the migration**, before any
|
|
34
|
+
* other operation in its transaction, on any collection: a transaction reads from the moment of
|
|
35
|
+
* its first operation, so after a build that had work to do its write to the collection fails
|
|
36
|
+
* (a write conflict, and a rerun of the migration then passes). It refuses, before any change, a
|
|
37
|
+
* user that stores `oidcSubject: null`, which the sparse index would still index (and collide
|
|
38
|
+
* on). A user with an `oidcSubject` is already linked and left alone, so a rerun is a no-op.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* export async function up(args: MigrateUpArgs): Promise<void> {
|
|
42
|
+
* await takeLinkingSnapshot(args)
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @returns How many users were marked.
|
|
47
|
+
*/
|
|
48
|
+
export const takeLinkingSnapshot = async ({ payload, session }, { collection: slug = 'users' } = {}) => {
|
|
49
|
+
const { collection, model } = usersCollection(payload, slug);
|
|
50
|
+
// Outside the transaction, like the build: a read in it would hold the collection until commit.
|
|
51
|
+
const nullSubjects = await collection.countDocuments({ [OIDC_FIELDS.subject]: { $type: 'null' } }, {});
|
|
52
|
+
if (nullSubjects > 0) {
|
|
53
|
+
throw new Error(`payload-oidc: a user of "${slug}" stores ${OIDC_FIELDS.subject} as null.`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await model.createIndexes();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// Every index on the collection is built here, not only the plugin's.
|
|
60
|
+
throw new Error(`payload-oidc: building the indexes of "${slug}" failed: ${String(error)}`, {
|
|
61
|
+
cause: error,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
let modifiedCount;
|
|
65
|
+
try {
|
|
66
|
+
;
|
|
67
|
+
({ modifiedCount } = await collection.updateMany({ [OIDC_FIELDS.linkable]: { $exists: false }, [OIDC_FIELDS.subject]: { $exists: false } }, { $set: { [OIDC_FIELDS.linkable]: true } }, { session }));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!isWriteConflict(error))
|
|
71
|
+
throw error;
|
|
72
|
+
throw new Error(`payload-oidc: the linking snapshot conflicted with the index build on "${slug}". Call takeLinkingSnapshot before any other operation in the migration, or run the migration again.`, { cause: error });
|
|
73
|
+
}
|
|
74
|
+
payload.logger.info(`payload-oidc: marked ${modifiedCount} users of "${slug}" linkable.`);
|
|
75
|
+
return modifiedCount;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The `down` of {@link takeLinkingSnapshot}: clears every `oidcLinkable` mark. Links already made
|
|
79
|
+
* (`oidcSubject`) and disables (`oidcDisabledAt`) stay: they are the provider's record of who
|
|
80
|
+
* these users are, and with the plugin off they do nothing.
|
|
81
|
+
*
|
|
82
|
+
* @returns How many marks were cleared.
|
|
83
|
+
*/
|
|
84
|
+
export const clearLinkingSnapshot = async ({ payload, session }, { collection: slug = 'users' } = {}) => {
|
|
85
|
+
const { collection } = usersCollection(payload, slug);
|
|
86
|
+
const { modifiedCount } = await collection.updateMany({ [OIDC_FIELDS.linkable]: { $exists: true } }, { $unset: { [OIDC_FIELDS.linkable]: '' } }, { session });
|
|
87
|
+
payload.logger.info(`payload-oidc: cleared ${modifiedCount} linkable marks on "${slug}".`);
|
|
88
|
+
return modifiedCount;
|
|
89
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
/**
|
|
71
|
+
* Hooks for the reconcile schedule. A `beforeSchedule` hook runs after stale runs are released
|
|
72
|
+
* (`reconcileStaleAfterMinutes`), in place of Payload's default rule.
|
|
73
|
+
*/
|
|
74
|
+
reconcileScheduleHooks?: ReconcileScheduleHooks;
|
|
75
|
+
/**
|
|
76
|
+
* A reconcile run still processing after this many minutes was cut off, for example by a deploy
|
|
77
|
+
* that replaced the process mid-run, and would block the queue for good. Before each schedule,
|
|
78
|
+
* such runs are marked failed with Payload's own cancel, so a new one can be queued. A live run
|
|
79
|
+
* therefore stops, before changing anything, if the source hasn't answered within 80% of this.
|
|
80
|
+
* `false` turns both off. At least 1.
|
|
81
|
+
*/
|
|
82
|
+
reconcileStaleAfterMinutes?: number | false;
|
|
83
|
+
/** Who may remove a disabled user. Defaults to nobody, so an app must decide. */
|
|
84
|
+
removeAccess?: (req: PayloadRequest) => boolean | Promise<boolean>;
|
|
85
|
+
roles: {
|
|
86
|
+
/** The role field on the auth collection. */
|
|
87
|
+
field: string;
|
|
88
|
+
/** Whether the role field stores a list (a `hasMany` select) or a single value. */
|
|
89
|
+
hasMany?: boolean;
|
|
90
|
+
table: RoleTable;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* The public origin the callback is registered under (`<origin>/api/<collection>/oidc/callback`).
|
|
94
|
+
* Defaults to Payload's `serverURL`; set it when that is unset, as some apps do in development.
|
|
95
|
+
*/
|
|
96
|
+
serverURL?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Further public origins the app is also served from (each registered with the provider as a
|
|
99
|
+
* callback too). Sign-in uses the request's own origin when it is one of these, so its cookie and
|
|
100
|
+
* callback stay on one host; any other origin falls back to `serverURL`.
|
|
101
|
+
*/
|
|
102
|
+
additionalServerURLs?: string[];
|
|
103
|
+
/** Scopes requested at sign-in. */
|
|
104
|
+
scopes?: string[];
|
|
105
|
+
/** Idle timeout: the collection's `tokenExpiration`, in hours. */
|
|
106
|
+
sessionHours?: number;
|
|
107
|
+
/** Absolute cap on a session from sign-in, in hours. Refresh is refused after it. */
|
|
108
|
+
sessionMaxHours?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Allows an `http:` issuer. Only for a provider on the local machine in development or tests;
|
|
111
|
+
* refused when `NODE_ENV` is `production`.
|
|
112
|
+
*/
|
|
113
|
+
allowInsecureIssuer?: boolean;
|
|
114
|
+
};
|
|
115
|
+
export type ResolvedOidcOptions = {
|
|
116
|
+
allowInsecureIssuer: boolean;
|
|
117
|
+
breakGlassEmail?: string;
|
|
118
|
+
buttonLabel: string;
|
|
119
|
+
clientId: string;
|
|
120
|
+
clientSecret: string;
|
|
121
|
+
collection: string;
|
|
122
|
+
enabled: boolean;
|
|
123
|
+
firstNameField: string;
|
|
124
|
+
issuer: string;
|
|
125
|
+
lastNameField: string;
|
|
126
|
+
lastNamePlaceholder: string;
|
|
127
|
+
linkByEmailUntil?: Date;
|
|
128
|
+
passwordLogin: boolean;
|
|
129
|
+
reconcile?: ReconcileSource;
|
|
130
|
+
keepOnRemove: string[];
|
|
131
|
+
reconcileScheduleHooks?: ReconcileScheduleHooks;
|
|
132
|
+
/** Unset when releasing stale reconcile runs is off. */
|
|
133
|
+
reconcileStaleAfterSeconds?: number;
|
|
134
|
+
reconcileHeartbeatMaxSeconds: number;
|
|
135
|
+
reconcileMaxDisableShare: number;
|
|
136
|
+
removeAccess: (req: PayloadRequest) => boolean | Promise<boolean>;
|
|
137
|
+
roleField: string;
|
|
138
|
+
roleHasMany: boolean;
|
|
139
|
+
roleTable: RoleTable;
|
|
140
|
+
scopes: string[];
|
|
141
|
+
sessionMaxSeconds: number;
|
|
142
|
+
sessionSeconds: number;
|
|
143
|
+
/** The public origin the callback is registered under, if not Payload's `serverURL`. */
|
|
144
|
+
serverURL?: string;
|
|
145
|
+
/** Further allowed public origins, normalised. */
|
|
146
|
+
additionalServerURLs: string[];
|
|
147
|
+
/** Whether the collection uses `auth.verify`; set by the plugin from the collection config. */
|
|
148
|
+
verify: boolean;
|
|
149
|
+
/** The collection's `loginWithUsername` setting; set by the plugin from the collection config. */
|
|
150
|
+
loginWithUsername?: {
|
|
151
|
+
allowEmailLogin: boolean;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Validates the options and fills in defaults. Throws for a configuration that can't be safe,
|
|
156
|
+
* so a mistake fails at startup rather than at someone's first sign-in.
|
|
157
|
+
*/
|
|
158
|
+
export declare const resolveOptions: (options: OidcPluginOptions) => ResolvedOidcOptions;
|
|
159
|
+
/**
|
|
160
|
+
* The role a user's groups grant: the first role in the table with a matching group.
|
|
161
|
+
*
|
|
162
|
+
* @param table - The role table, highest role first.
|
|
163
|
+
* @param groups - The `groups` claim (or a reconcile member's groups); anything else is no role.
|
|
164
|
+
* @returns The role, or `undefined` for none.
|
|
165
|
+
*/
|
|
166
|
+
export declare const roleForGroups: (table: RoleTable, groups: unknown) => string | undefined;
|
|
167
|
+
/** Every group named in the role table, once. */
|
|
168
|
+
export declare const roleTableGroups: (table: RoleTable) => string[];
|
|
169
|
+
/** Whether an email is the break-glass account's. */
|
|
170
|
+
export declare const isBreakGlassEmail: (options: ResolvedOidcOptions, email: unknown) => boolean;
|
|
171
|
+
/** Whether linking by email is still allowed at `now`. */
|
|
172
|
+
export declare const canLinkByEmail: (options: ResolvedOidcOptions, now: Date) => boolean;
|