@signal58/payload-oidc 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +299 -2
- 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 +8 -0
- package/dist/index.js +7 -0
- package/dist/options.d.ts +159 -0
- package/dist/options.js +126 -0
- package/dist/plugin.d.ts +25 -0
- package/dist/plugin.js +217 -0
- package/dist/provider.d.ts +43 -0
- package/dist/provider.js +67 -0
- package/dist/reconcile-task.d.ts +12 -0
- package/dist/reconcile-task.js +48 -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 +90 -4
package/dist/remove.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isBreakGlassEmail } from './options.js';
|
|
2
|
+
import { authCollectionModel, OIDC_FIELDS, OIDC_SYNC_CONTEXT, unusablePassword } from './store.js';
|
|
3
|
+
const pointsAt = (relationTo, collection) => Array.isArray(relationTo) ? relationTo.includes(collection) : relationTo === collection;
|
|
4
|
+
/** Every relationship or upload field under `fields` that can point at `collection`. */
|
|
5
|
+
const referencePaths = (fields, collection, prefix = '') => fields.flatMap((field) => {
|
|
6
|
+
const named = 'name' in field && field.name ? `${prefix}${field.name}` : undefined;
|
|
7
|
+
if ((field.type === 'relationship' || field.type === 'upload') && named) {
|
|
8
|
+
if (!pointsAt(field.relationTo, collection))
|
|
9
|
+
return [];
|
|
10
|
+
// A polymorphic relationship stores `{ relationTo, value }`.
|
|
11
|
+
return [Array.isArray(field.relationTo) ? `${named}.value` : named];
|
|
12
|
+
}
|
|
13
|
+
if (field.type === 'array' || field.type === 'group') {
|
|
14
|
+
return referencePaths(field.fields, collection, named ? `${named}.` : prefix);
|
|
15
|
+
}
|
|
16
|
+
if (field.type === 'blocks' && named) {
|
|
17
|
+
return field.blocks.flatMap((block) => referencePaths(typeof block === 'string' ? [] : block.fields, collection, `${named}.`));
|
|
18
|
+
}
|
|
19
|
+
if (field.type === 'tabs') {
|
|
20
|
+
return field.tabs.flatMap((tab) => referencePaths(tab.fields, collection, 'name' in tab && tab.name ? `${prefix}${tab.name}.` : prefix));
|
|
21
|
+
}
|
|
22
|
+
if ((field.type === 'row' || field.type === 'collapsible') && 'fields' in field) {
|
|
23
|
+
return referencePaths(field.fields, collection, prefix);
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Every field in the config that can refer to a user of `collection`, except Payload's own
|
|
29
|
+
* bookkeeping (preferences, locks, jobs, migrations), which belongs to the user and goes with it.
|
|
30
|
+
* Rich-text relationships aren't seen; anything referenced only there is left as a dangling link.
|
|
31
|
+
*/
|
|
32
|
+
export const findUserReferences = (collections, globals, collection) => [
|
|
33
|
+
...collections
|
|
34
|
+
.filter(({ slug }) => !slug.startsWith('payload-'))
|
|
35
|
+
.flatMap((config) => referencePaths(config.fields, collection).map((path) => ({
|
|
36
|
+
entity: 'collection',
|
|
37
|
+
path,
|
|
38
|
+
slug: config.slug,
|
|
39
|
+
versions: Boolean(config.versions),
|
|
40
|
+
}))),
|
|
41
|
+
...globals.flatMap((config) => referencePaths(config.fields, collection).map((path) => ({
|
|
42
|
+
entity: 'global',
|
|
43
|
+
path,
|
|
44
|
+
slug: config.slug,
|
|
45
|
+
versions: Boolean(config.versions),
|
|
46
|
+
}))),
|
|
47
|
+
];
|
|
48
|
+
const isReferenced = async (payload, references, id) => {
|
|
49
|
+
for (const reference of references) {
|
|
50
|
+
if (reference.entity === 'global') {
|
|
51
|
+
const global = (await payload.findGlobal({
|
|
52
|
+
depth: 0,
|
|
53
|
+
overrideAccess: true,
|
|
54
|
+
slug: reference.slug,
|
|
55
|
+
}));
|
|
56
|
+
if (JSON.stringify(global).includes(JSON.stringify(String(id))))
|
|
57
|
+
return true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const where = { [reference.path]: { equals: id } };
|
|
61
|
+
const { totalDocs } = await payload.count({
|
|
62
|
+
collection: reference.slug,
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
where,
|
|
65
|
+
});
|
|
66
|
+
if (totalDocs > 0)
|
|
67
|
+
return true;
|
|
68
|
+
if (reference.versions) {
|
|
69
|
+
const versions = await payload.countVersions({
|
|
70
|
+
collection: reference.slug,
|
|
71
|
+
overrideAccess: true,
|
|
72
|
+
where: { [`version.${reference.path}`]: { equals: id } },
|
|
73
|
+
});
|
|
74
|
+
if (versions.totalDocs > 0)
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
/** Payload's auth bookkeeping that names or can act for the person. */
|
|
81
|
+
const AUTH_FIELDS_TO_CLEAR = [
|
|
82
|
+
'_verificationToken',
|
|
83
|
+
'lockUntil',
|
|
84
|
+
'loginAttempts',
|
|
85
|
+
'resetPasswordExpiration',
|
|
86
|
+
'resetPasswordRequestedAt',
|
|
87
|
+
'resetPasswordToken',
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Everything an anonymised user loses: every top-level data field of the collection (photo,
|
|
91
|
+
* social links, anything an app added) except the ones anonymising sets or keeps, plus Payload's
|
|
92
|
+
* auth bookkeeping. `keepOnRemove` names fields an app wants kept.
|
|
93
|
+
*/
|
|
94
|
+
const fieldsToClear = (payload, options) => {
|
|
95
|
+
const keep = new Set([
|
|
96
|
+
'createdAt',
|
|
97
|
+
'email',
|
|
98
|
+
'hash',
|
|
99
|
+
'id',
|
|
100
|
+
'salt',
|
|
101
|
+
'sessions',
|
|
102
|
+
'updatedAt',
|
|
103
|
+
OIDC_FIELDS.disabledAt,
|
|
104
|
+
options.firstNameField,
|
|
105
|
+
options.lastNameField,
|
|
106
|
+
options.roleField,
|
|
107
|
+
...options.keepOnRemove,
|
|
108
|
+
]);
|
|
109
|
+
const config = payload.collections[options.collection];
|
|
110
|
+
const dataFields = config.config.flattenedFields
|
|
111
|
+
.filter((field) => 'name' in field && field.type !== 'join')
|
|
112
|
+
.map((field) => field.name);
|
|
113
|
+
return [...new Set([...dataFields, ...AUTH_FIELDS_TO_CLEAR])].filter((name) => !keep.has(name));
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Removes a disabled user. Only disabled users: an active one would be created again at their
|
|
117
|
+
* next sign-in. If nothing refers to the user they are deleted; otherwise they are anonymised
|
|
118
|
+
* (names "Removed user", email `removed-<id>@removed.invalid`, every other profile field, the
|
|
119
|
+
* subject, password and sessions gone) so bylines and audit rows still resolve. Someone added back at the provider later signs
|
|
120
|
+
* in as a new user.
|
|
121
|
+
*/
|
|
122
|
+
export const removeUser = async (payload, options, id) => {
|
|
123
|
+
const model = authCollectionModel(payload, options.collection);
|
|
124
|
+
const user = await model.findOne({ _id: id }, { email: 1, [OIDC_FIELDS.disabledAt]: 1 }).lean();
|
|
125
|
+
if (!user)
|
|
126
|
+
return { kind: 'refused', reason: 'not_found' };
|
|
127
|
+
if (isBreakGlassEmail(options, user.email))
|
|
128
|
+
return { kind: 'refused', reason: 'break_glass' };
|
|
129
|
+
if (!user.oidcDisabledAt)
|
|
130
|
+
return { kind: 'refused', reason: 'not_disabled' };
|
|
131
|
+
const references = findUserReferences(payload.config.collections, payload.config.globals, options.collection);
|
|
132
|
+
if (!(await isReferenced(payload, references, id))) {
|
|
133
|
+
// Re-checked in the delete itself: a user who signed in again since the read above is no
|
|
134
|
+
// longer disabled and is kept.
|
|
135
|
+
const { docs } = await payload.delete({
|
|
136
|
+
collection: options.collection,
|
|
137
|
+
context: { [OIDC_SYNC_CONTEXT]: true },
|
|
138
|
+
overrideAccess: true,
|
|
139
|
+
where: {
|
|
140
|
+
and: [{ id: { equals: id } }, { [OIDC_FIELDS.disabledAt]: { exists: true } }],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return docs.length > 0 ? { kind: 'deleted' } : { kind: 'refused', reason: 'not_disabled' };
|
|
144
|
+
}
|
|
145
|
+
const clear = fieldsToClear(payload, options);
|
|
146
|
+
const result = await model.updateOne({ _id: id, [OIDC_FIELDS.disabledAt]: { $ne: null } }, {
|
|
147
|
+
$set: {
|
|
148
|
+
// A valid address (Payload's email check needs a dotted domain), unique per user, and
|
|
149
|
+
// under a reserved TLD so it can never receive mail.
|
|
150
|
+
email: `removed-${String(id)}@removed.invalid`,
|
|
151
|
+
[options.firstNameField]: 'Removed',
|
|
152
|
+
[options.lastNameField]: 'user',
|
|
153
|
+
sessions: [],
|
|
154
|
+
...unusablePassword(),
|
|
155
|
+
},
|
|
156
|
+
$unset: Object.fromEntries([OIDC_FIELDS.linkable, OIDC_FIELDS.subject, ...clear].map((field) => [field, ''])),
|
|
157
|
+
});
|
|
158
|
+
return result.matchedCount > 0
|
|
159
|
+
? { kind: 'anonymised' }
|
|
160
|
+
: { kind: 'refused', reason: 'not_disabled' };
|
|
161
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Payload } from 'payload';
|
|
2
|
+
import { type ResolvedOidcOptions } from './options.js';
|
|
3
|
+
import type { VerifiedIdentity } from './provider.js';
|
|
4
|
+
/**
|
|
5
|
+
* Why a sign-in was refused. The login page shows a short message per code; provider detail is
|
|
6
|
+
* only logged.
|
|
7
|
+
*/
|
|
8
|
+
export type SignInRefusal = 'account_conflict' | 'failed' | 'disabled' | 'email_not_verified' | 'no_role' | 'provider' | 'expired' | 'unavailable';
|
|
9
|
+
export type SignInOutcome = {
|
|
10
|
+
kind: 'refused';
|
|
11
|
+
reason: SignInRefusal;
|
|
12
|
+
} | {
|
|
13
|
+
expiresAt: Date;
|
|
14
|
+
kind: 'success';
|
|
15
|
+
token: string;
|
|
16
|
+
userId: string | number;
|
|
17
|
+
};
|
|
18
|
+
/** First and last name from the claims; `lastName` is required, so it falls back to a placeholder. */
|
|
19
|
+
export declare const namesFromIdentity: (options: ResolvedOidcOptions, identity: VerifiedIdentity) => {
|
|
20
|
+
firstName: string;
|
|
21
|
+
lastName: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Finishes a sign-in after the ID token has been validated: decides the role, finds, links or
|
|
25
|
+
* creates the user, writes the provider-owned fields, and issues a Payload session.
|
|
26
|
+
*/
|
|
27
|
+
export declare const completeSignIn: (payload: Payload, options: ResolvedOidcOptions, identity: VerifiedIdentity, now: Date) => Promise<SignInOutcome>;
|
package/dist/sign-in.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { getFieldsToSign, jwtSign } from 'payload';
|
|
3
|
+
import { canLinkByEmail, isBreakGlassEmail, roleForGroups, } from './options.js';
|
|
4
|
+
import { authCollectionConfig, disableUser, isDuplicateKeyError, linkByEmail, markVerified, OIDC_FIELDS, OIDC_SYNC_CONTEXT, pushSession, roleValue, writeManagedFields, } from './store.js';
|
|
5
|
+
const syncContext = { [OIDC_SYNC_CONTEXT]: true };
|
|
6
|
+
const findOne = async (payload, collection, where) => {
|
|
7
|
+
const { docs } = await payload.find({
|
|
8
|
+
collection: collection,
|
|
9
|
+
depth: 0,
|
|
10
|
+
limit: 1,
|
|
11
|
+
overrideAccess: true,
|
|
12
|
+
pagination: false,
|
|
13
|
+
where: where,
|
|
14
|
+
});
|
|
15
|
+
return docs[0];
|
|
16
|
+
};
|
|
17
|
+
/** First and last name from the claims; `lastName` is required, so it falls back to a placeholder. */
|
|
18
|
+
export const namesFromIdentity = (options, identity) => {
|
|
19
|
+
const firstName = identity.givenName ?? identity.name?.split(/\s+/)[0] ?? identity.email.split('@')[0];
|
|
20
|
+
return { firstName, lastName: identity.familyName ?? options.lastNamePlaceholder };
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Finishes a sign-in after the ID token has been validated: decides the role, finds, links or
|
|
24
|
+
* creates the user, writes the provider-owned fields, and issues a Payload session.
|
|
25
|
+
*/
|
|
26
|
+
export const completeSignIn = async (payload, options, identity, now) => {
|
|
27
|
+
const refuse = (reason) => ({ kind: 'refused', reason });
|
|
28
|
+
if (!identity.emailVerified)
|
|
29
|
+
return refuse('email_not_verified');
|
|
30
|
+
// The break-glass account signs in with its password only. A provider account with its email
|
|
31
|
+
// must never link to it (which would rotate its password away) or sync it.
|
|
32
|
+
if (isBreakGlassEmail(options, identity.email))
|
|
33
|
+
return refuse('account_conflict');
|
|
34
|
+
const collection = options.collection;
|
|
35
|
+
const bySubject = { [OIDC_FIELDS.subject]: { equals: identity.subject } };
|
|
36
|
+
const role = roleForGroups(options.roleTable, identity.groups);
|
|
37
|
+
let user = await findOne(payload, collection, bySubject);
|
|
38
|
+
if (!role) {
|
|
39
|
+
if (user && !isBreakGlassEmail(options, user.email)) {
|
|
40
|
+
await disableUser(payload, options, user.id, now);
|
|
41
|
+
}
|
|
42
|
+
return refuse('no_role');
|
|
43
|
+
}
|
|
44
|
+
if (!user &&
|
|
45
|
+
canLinkByEmail(options, now) &&
|
|
46
|
+
(await linkByEmail(payload, options, identity.email, identity.subject))) {
|
|
47
|
+
user = await findOne(payload, collection, bySubject);
|
|
48
|
+
}
|
|
49
|
+
const names = namesFromIdentity(options, identity);
|
|
50
|
+
const managed = {
|
|
51
|
+
email: identity.email,
|
|
52
|
+
[options.firstNameField]: names.firstName,
|
|
53
|
+
[options.lastNameField]: names.lastName,
|
|
54
|
+
[options.roleField]: roleValue(options, role),
|
|
55
|
+
};
|
|
56
|
+
if (!user) {
|
|
57
|
+
// Someone else's email (a user who isn't linkable, or is linked to another subject): never
|
|
58
|
+
// take the account over and never create a duplicate.
|
|
59
|
+
if (await findOne(payload, collection, { email: { equals: identity.email } })) {
|
|
60
|
+
return refuse('account_conflict');
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const created = (await payload.create({
|
|
64
|
+
collection: collection,
|
|
65
|
+
context: syncContext,
|
|
66
|
+
data: {
|
|
67
|
+
...managed,
|
|
68
|
+
[OIDC_FIELDS.subject]: identity.subject,
|
|
69
|
+
// Required by local auth; nobody knows it, and password sign-in is closed anyway.
|
|
70
|
+
password: crypto.randomBytes(48).toString('base64url'),
|
|
71
|
+
},
|
|
72
|
+
depth: 0,
|
|
73
|
+
disableVerificationEmail: true,
|
|
74
|
+
overrideAccess: true,
|
|
75
|
+
}));
|
|
76
|
+
await markVerified(payload, options, created.id);
|
|
77
|
+
user = created;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!isDuplicateKeyError(error, [OIDC_FIELDS.subject, 'email']))
|
|
81
|
+
throw error;
|
|
82
|
+
// A second first sign-in for the same subject won the race (it may not have committed
|
|
83
|
+
// yet), or the email was just taken.
|
|
84
|
+
for (let attempt = 0; !user && attempt < 5; attempt += 1) {
|
|
85
|
+
if (attempt > 0)
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
87
|
+
user = await findOne(payload, collection, bySubject);
|
|
88
|
+
}
|
|
89
|
+
if (!user)
|
|
90
|
+
return refuse('account_conflict');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
try {
|
|
95
|
+
if (!(await writeManagedFields(payload, options, user.id, identity.subject, managed, now))) {
|
|
96
|
+
return refuse('disabled');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// The provider's email now belongs to another CMS user.
|
|
101
|
+
if (isDuplicateKeyError(error))
|
|
102
|
+
return refuse('account_conflict');
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
user = (await findOne(payload, collection, bySubject)) ?? user;
|
|
106
|
+
}
|
|
107
|
+
const sid = await pushSession(payload, options, user.id, identity.subject, now);
|
|
108
|
+
if (!sid)
|
|
109
|
+
return refuse('disabled');
|
|
110
|
+
const collectionConfig = authCollectionConfig(payload, collection);
|
|
111
|
+
const { exp, token } = await jwtSign({
|
|
112
|
+
fieldsToSign: getFieldsToSign({
|
|
113
|
+
collectionConfig,
|
|
114
|
+
email: identity.email,
|
|
115
|
+
sid,
|
|
116
|
+
user: { ...user, collection },
|
|
117
|
+
}),
|
|
118
|
+
secret: payload.secret,
|
|
119
|
+
tokenExpiration: options.sessionSeconds,
|
|
120
|
+
});
|
|
121
|
+
return { expiresAt: new Date(exp * 1000), kind: 'success', token, userId: user.id };
|
|
122
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReconcileSource } from '../options.js';
|
|
2
|
+
/**
|
|
3
|
+
* A reconcile source that reads the role-table groups' members from Authentik's API.
|
|
4
|
+
*
|
|
5
|
+
* The token needs only object-level `view_group` on those groups: reading a group returns its
|
|
6
|
+
* direct members with their `uid`, which equals the OIDC `sub` when the provider's subject mode
|
|
7
|
+
* is "based on the user's hashed ID". Every group must exist and be readable, or the fetch throws
|
|
8
|
+
* and reconcile changes nothing.
|
|
9
|
+
*
|
|
10
|
+
* @param options.url - Authentik's base URL, such as `https://auth.example.com`.
|
|
11
|
+
* @param options.token - An API token for a service account with `view_group` on the groups.
|
|
12
|
+
* @param options.queue - The Payload jobs queue the reconcile task runs on.
|
|
13
|
+
* @param options.cron - How often it runs (default every 15 minutes).
|
|
14
|
+
* @param options.fetch - For tests.
|
|
15
|
+
*/
|
|
16
|
+
export declare const authentikSource: (options: {
|
|
17
|
+
/** Allows an `http:` URL, for a local Authentik; refused when `NODE_ENV` is `production`. */
|
|
18
|
+
allowInsecure?: boolean;
|
|
19
|
+
cron?: string;
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
queue?: string;
|
|
22
|
+
/** Per request, in milliseconds (default 30 seconds). */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
token?: string;
|
|
25
|
+
url?: string;
|
|
26
|
+
}) => ReconcileSource | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reconcile source that reads the role-table groups' members from Authentik's API.
|
|
3
|
+
*
|
|
4
|
+
* The token needs only object-level `view_group` on those groups: reading a group returns its
|
|
5
|
+
* direct members with their `uid`, which equals the OIDC `sub` when the provider's subject mode
|
|
6
|
+
* is "based on the user's hashed ID". Every group must exist and be readable, or the fetch throws
|
|
7
|
+
* and reconcile changes nothing.
|
|
8
|
+
*
|
|
9
|
+
* @param options.url - Authentik's base URL, such as `https://auth.example.com`.
|
|
10
|
+
* @param options.token - An API token for a service account with `view_group` on the groups.
|
|
11
|
+
* @param options.queue - The Payload jobs queue the reconcile task runs on.
|
|
12
|
+
* @param options.cron - How often it runs (default every 15 minutes).
|
|
13
|
+
* @param options.fetch - For tests.
|
|
14
|
+
*/
|
|
15
|
+
export const authentikSource = (options) => {
|
|
16
|
+
const url = options.url?.trim().replace(/\/+$/, '');
|
|
17
|
+
const token = options.token?.trim();
|
|
18
|
+
if (!url || !token)
|
|
19
|
+
return undefined;
|
|
20
|
+
// The token travels in the Authorization header: never in clear text outside local development.
|
|
21
|
+
if (new URL(url).protocol !== 'https:') {
|
|
22
|
+
if (!options.allowInsecure || process.env.NODE_ENV === 'production') {
|
|
23
|
+
throw new Error('payload-oidc: the Authentik reconcile URL must use https.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const request = options.fetch ?? fetch;
|
|
27
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
28
|
+
const fetchGroup = async (name) => {
|
|
29
|
+
const query = new URLSearchParams({ include_users: 'true', name, page_size: '2' });
|
|
30
|
+
const response = await request(`${url}/api/v3/core/groups/?${query}`, {
|
|
31
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
|
32
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
throw new Error(`Authentik answered ${response.status} for group ${name}.`);
|
|
36
|
+
const body = (await response.json());
|
|
37
|
+
const matches = (body.results ?? []).filter((group) => group.name === name);
|
|
38
|
+
// A missing group (renamed, or the token lost its permission) is a failure, not an empty one:
|
|
39
|
+
// otherwise a whole tier would be disabled.
|
|
40
|
+
if (matches.length !== 1)
|
|
41
|
+
throw new Error(`Authentik group ${name} not found or not readable.`);
|
|
42
|
+
const members = matches[0].users_obj;
|
|
43
|
+
if (!Array.isArray(members))
|
|
44
|
+
throw new Error(`Authentik returned no members for ${name}.`);
|
|
45
|
+
return members.map((member) => {
|
|
46
|
+
if (typeof member.uid !== 'string' || !member.uid) {
|
|
47
|
+
throw new Error(`Authentik returned a member of ${name} without a uid.`);
|
|
48
|
+
}
|
|
49
|
+
return { active: member.is_active === true, subject: member.uid };
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
cron: options.cron ?? '*/15 * * * *',
|
|
54
|
+
fetchGroupMembers: async (groups) => {
|
|
55
|
+
const entries = await Promise.all(groups.map(async (group) => [group, await fetchGroup(group)]));
|
|
56
|
+
return new Map(entries);
|
|
57
|
+
},
|
|
58
|
+
queue: options.queue ?? 'oidc-reconcile',
|
|
59
|
+
};
|
|
60
|
+
};
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type Payload, type SanitizedCollectionConfig } from 'payload';
|
|
2
|
+
import type { ResolvedOidcOptions } from './options.js';
|
|
3
|
+
/** `req.context` key that marks a write made by the plugin's sync. */
|
|
4
|
+
export declare const OIDC_SYNC_CONTEXT = "oidcSync";
|
|
5
|
+
/** The fields the plugin adds to the auth collection. */
|
|
6
|
+
export declare const OIDC_FIELDS: {
|
|
7
|
+
readonly disabledAt: 'oidcDisabledAt';
|
|
8
|
+
readonly linkable: 'oidcLinkable';
|
|
9
|
+
readonly subject: 'oidcSubject';
|
|
10
|
+
};
|
|
11
|
+
type StoredSession = {
|
|
12
|
+
createdAt: Date | string;
|
|
13
|
+
expiresAt: Date | string;
|
|
14
|
+
id: string;
|
|
15
|
+
};
|
|
16
|
+
/** The stored shape of an auth-collection document, as far as the plugin reads it. */
|
|
17
|
+
export type StoredUser = {
|
|
18
|
+
_id: unknown;
|
|
19
|
+
email?: string;
|
|
20
|
+
oidcDisabledAt?: Date | null;
|
|
21
|
+
oidcLinkable?: boolean;
|
|
22
|
+
oidcSubject?: string;
|
|
23
|
+
sessions?: StoredSession[];
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
type UpdateResult = {
|
|
27
|
+
matchedCount: number;
|
|
28
|
+
modifiedCount?: number;
|
|
29
|
+
};
|
|
30
|
+
/** The subset of a Mongoose model the plugin needs for conditional, atomic writes. */
|
|
31
|
+
export type AuthCollectionModel = {
|
|
32
|
+
find: (filter: Record<string, unknown>) => {
|
|
33
|
+
lean: () => Promise<StoredUser[]>;
|
|
34
|
+
};
|
|
35
|
+
findOne: (filter: Record<string, unknown>, projection?: Record<string, 0 | 1>) => {
|
|
36
|
+
lean: () => Promise<StoredUser | null>;
|
|
37
|
+
};
|
|
38
|
+
updateOne: (filter: Record<string, unknown>, update: Record<string, unknown> | Record<string, unknown>[], options?: Record<string, unknown>) => Promise<UpdateResult>;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The auth collection's Mongoose model. Session pushes and disables must be conditional, atomic
|
|
42
|
+
* updates, which Payload's adapter API can't express, so the plugin needs MongoDB.
|
|
43
|
+
*/
|
|
44
|
+
export declare const authCollectionModel: (payload: Payload, slug: string) => AuthCollectionModel;
|
|
45
|
+
/**
|
|
46
|
+
* The auth collection's sanitized config. Typed loosely on purpose: an app's generated Payload
|
|
47
|
+
* types narrow collection slugs to its own, which a plugin can't name.
|
|
48
|
+
*/
|
|
49
|
+
export declare const authCollectionConfig: (payload: Payload, slug: string) => SanitizedCollectionConfig;
|
|
50
|
+
/** A password hash and salt that no password matches. */
|
|
51
|
+
export declare const unusablePassword: () => {
|
|
52
|
+
hash: string;
|
|
53
|
+
salt: string;
|
|
54
|
+
};
|
|
55
|
+
/** The value the role field stores for `role` (or for no role). */
|
|
56
|
+
export declare const roleValue: (options: ResolvedOidcOptions, role: string | undefined) => string | string[] | null;
|
|
57
|
+
/**
|
|
58
|
+
* Disables a user: clears the role and every session and sets `oidcDisabledAt`, in one atomic
|
|
59
|
+
* update. Clearing sessions ends access on the next request, because Payload checks the session
|
|
60
|
+
* ID on every request. The break-glass account is never matched.
|
|
61
|
+
*
|
|
62
|
+
* @returns Whether a user was disabled.
|
|
63
|
+
*/
|
|
64
|
+
export declare const disableUser: (payload: Payload, options: ResolvedOidcOptions, id: unknown, now: Date,
|
|
65
|
+
/** Further conditions, such as "unchanged since it was read". */
|
|
66
|
+
condition?: Record<string, unknown>) => Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Links an existing, never-linked snapshot user to a provider subject, by email. One atomic,
|
|
69
|
+
* conditional update sets the subject, clears `oidcLinkable` and every session, and replaces the
|
|
70
|
+
* password with one nobody knows, so the old password and old sessions stop working.
|
|
71
|
+
*
|
|
72
|
+
* @returns Whether a user was linked.
|
|
73
|
+
*/
|
|
74
|
+
export declare const linkByEmail: (payload: Payload, options: ResolvedOidcOptions, email: string, subject: string) => Promise<boolean>;
|
|
75
|
+
/**
|
|
76
|
+
* Writes the provider-owned fields of a linked user and clears `oidcDisabledAt`, as one update of
|
|
77
|
+
* just those paths. A Payload update would write back the whole document as it read it, sessions
|
|
78
|
+
* included, so a disable landing in between would be undone.
|
|
79
|
+
*
|
|
80
|
+
* @returns Whether the user (still linked to `subject`) was updated.
|
|
81
|
+
*/
|
|
82
|
+
export declare const writeManagedFields: (payload: Payload, options: ResolvedOidcOptions, id: unknown, subject: string, fields: Record<string, unknown>, now: Date) => Promise<boolean>;
|
|
83
|
+
/** Marks a user Payload created as verified, for collections with `auth.verify`. */
|
|
84
|
+
export declare const markVerified: (payload: Payload, options: ResolvedOidcOptions, id: unknown) => Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Whether an error is a unique index refusing the write: MongoDB's duplicate-key error from a raw
|
|
87
|
+
* model write, the `ValidationError` db-mongodb turns it into for a Payload write (with the
|
|
88
|
+
* unique field as its path, and no cause), or a transaction's write conflict on the same key.
|
|
89
|
+
*/
|
|
90
|
+
export declare const isDuplicateKeyError: (error: unknown, uniqueFields?: readonly string[]) => boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Adds a session, conditional on the user still being linked to `subject` and not disabled, as one
|
|
93
|
+
* atomic update. A whole-document write (Payload's `addSessionToUser`) could undo a disable that
|
|
94
|
+
* landed between the read and the write; this can't. Expired sessions are dropped in the same
|
|
95
|
+
* update.
|
|
96
|
+
*
|
|
97
|
+
* @returns The new session ID, or `undefined` if the user was disabled or relinked meanwhile.
|
|
98
|
+
*/
|
|
99
|
+
export declare const pushSession: (payload: Payload, options: ResolvedOidcOptions, id: unknown, subject: string, now: Date) => Promise<string | undefined>;
|
|
100
|
+
/** One stored session, with the fields the refresh cap needs. */
|
|
101
|
+
export declare const findSession: (payload: Payload, options: ResolvedOidcOptions, id: unknown, sid: string) => Promise<{
|
|
102
|
+
createdAt: Date;
|
|
103
|
+
disabled: boolean;
|
|
104
|
+
} | undefined>;
|
|
105
|
+
export {};
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { ValidationError } from 'payload';
|
|
3
|
+
/** `req.context` key that marks a write made by the plugin's sync. */
|
|
4
|
+
export const OIDC_SYNC_CONTEXT = 'oidcSync';
|
|
5
|
+
/** The fields the plugin adds to the auth collection. */
|
|
6
|
+
export const OIDC_FIELDS = {
|
|
7
|
+
disabledAt: 'oidcDisabledAt',
|
|
8
|
+
linkable: 'oidcLinkable',
|
|
9
|
+
subject: 'oidcSubject',
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The auth collection's Mongoose model. Session pushes and disables must be conditional, atomic
|
|
13
|
+
* updates, which Payload's adapter API can't express, so the plugin needs MongoDB.
|
|
14
|
+
*/
|
|
15
|
+
export const authCollectionModel = (payload, slug) => {
|
|
16
|
+
const model = payload.db
|
|
17
|
+
.collections?.[slug];
|
|
18
|
+
if (!model) {
|
|
19
|
+
throw new Error(`payload-oidc needs the MongoDB adapter (no model for "${slug}").`);
|
|
20
|
+
}
|
|
21
|
+
return model;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The auth collection's sanitized config. Typed loosely on purpose: an app's generated Payload
|
|
25
|
+
* types narrow collection slugs to its own, which a plugin can't name.
|
|
26
|
+
*/
|
|
27
|
+
export const authCollectionConfig = (payload, slug) => payload.collections[slug]
|
|
28
|
+
.config;
|
|
29
|
+
/** A password hash and salt that no password matches. */
|
|
30
|
+
export const unusablePassword = () => ({
|
|
31
|
+
hash: crypto.randomBytes(64).toString('hex'),
|
|
32
|
+
salt: crypto.randomBytes(32).toString('hex'),
|
|
33
|
+
});
|
|
34
|
+
/** The value the role field stores for `role` (or for no role). */
|
|
35
|
+
export const roleValue = (options, role) => options.roleHasMany ? (role ? [role] : []) : (role ?? null);
|
|
36
|
+
/**
|
|
37
|
+
* Disables a user: clears the role and every session and sets `oidcDisabledAt`, in one atomic
|
|
38
|
+
* update. Clearing sessions ends access on the next request, because Payload checks the session
|
|
39
|
+
* ID on every request. The break-glass account is never matched.
|
|
40
|
+
*
|
|
41
|
+
* @returns Whether a user was disabled.
|
|
42
|
+
*/
|
|
43
|
+
export const disableUser = async (payload, options, id, now,
|
|
44
|
+
/** Further conditions, such as "unchanged since it was read". */
|
|
45
|
+
condition = {}) => {
|
|
46
|
+
const filter = { ...condition, _id: id };
|
|
47
|
+
if (options.breakGlassEmail)
|
|
48
|
+
filter.email = { $ne: options.breakGlassEmail };
|
|
49
|
+
const result = await authCollectionModel(payload, options.collection).updateOne(filter, {
|
|
50
|
+
$set: {
|
|
51
|
+
[OIDC_FIELDS.disabledAt]: now,
|
|
52
|
+
[options.roleField]: roleValue(options, undefined),
|
|
53
|
+
sessions: [],
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return result.matchedCount > 0;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Links an existing, never-linked snapshot user to a provider subject, by email. One atomic,
|
|
60
|
+
* conditional update sets the subject, clears `oidcLinkable` and every session, and replaces the
|
|
61
|
+
* password with one nobody knows, so the old password and old sessions stop working.
|
|
62
|
+
*
|
|
63
|
+
* @returns Whether a user was linked.
|
|
64
|
+
*/
|
|
65
|
+
export const linkByEmail = async (payload, options, email, subject) => {
|
|
66
|
+
// The break-glass account keeps its password: it is never linked, even if the migration marked it.
|
|
67
|
+
if (options.breakGlassEmail && email === options.breakGlassEmail)
|
|
68
|
+
return false;
|
|
69
|
+
const result = await authCollectionModel(payload, options.collection).updateOne({
|
|
70
|
+
email,
|
|
71
|
+
[OIDC_FIELDS.linkable]: true,
|
|
72
|
+
[OIDC_FIELDS.subject]: { $exists: false },
|
|
73
|
+
}, {
|
|
74
|
+
$set: {
|
|
75
|
+
[OIDC_FIELDS.subject]: subject,
|
|
76
|
+
sessions: [],
|
|
77
|
+
...unusablePassword(),
|
|
78
|
+
...(options.verify ? { _verified: true } : {}),
|
|
79
|
+
},
|
|
80
|
+
$unset: { [OIDC_FIELDS.linkable]: '' },
|
|
81
|
+
});
|
|
82
|
+
return result.matchedCount > 0;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Writes the provider-owned fields of a linked user and clears `oidcDisabledAt`, as one update of
|
|
86
|
+
* just those paths. A Payload update would write back the whole document as it read it, sessions
|
|
87
|
+
* included, so a disable landing in between would be undone.
|
|
88
|
+
*
|
|
89
|
+
* @returns Whether the user (still linked to `subject`) was updated.
|
|
90
|
+
*/
|
|
91
|
+
export const writeManagedFields = async (payload, options, id, subject, fields, now) => {
|
|
92
|
+
const result = await authCollectionModel(payload, options.collection).updateOne({ _id: id, [OIDC_FIELDS.subject]: subject }, { $set: { ...fields, [OIDC_FIELDS.disabledAt]: null, updatedAt: now } });
|
|
93
|
+
return result.matchedCount > 0;
|
|
94
|
+
};
|
|
95
|
+
/** Marks a user Payload created as verified, for collections with `auth.verify`. */
|
|
96
|
+
export const markVerified = async (payload, options, id) => {
|
|
97
|
+
if (!options.verify)
|
|
98
|
+
return;
|
|
99
|
+
await authCollectionModel(payload, options.collection).updateOne({ _id: id }, { $set: { _verified: true }, $unset: { _verificationToken: '' } });
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Whether an error is a unique index refusing the write: MongoDB's duplicate-key error from a raw
|
|
103
|
+
* model write, the `ValidationError` db-mongodb turns it into for a Payload write (with the
|
|
104
|
+
* unique field as its path, and no cause), or a transaction's write conflict on the same key.
|
|
105
|
+
*/
|
|
106
|
+
export const isDuplicateKeyError = (error, uniqueFields = []) => {
|
|
107
|
+
if (typeof error !== 'object' || error === null)
|
|
108
|
+
return false;
|
|
109
|
+
const candidate = error;
|
|
110
|
+
if (candidate.code === 11000 || candidate.cause?.code === 11000)
|
|
111
|
+
return true;
|
|
112
|
+
// Inside a replica-set transaction a concurrent insert of the same key is a write conflict.
|
|
113
|
+
if (candidate.code === 112 || candidate.cause?.code === 112)
|
|
114
|
+
return true;
|
|
115
|
+
return Boolean(error instanceof ValidationError &&
|
|
116
|
+
candidate.data?.errors?.some((entry) => uniqueFields.includes(String(entry.path))));
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Adds a session, conditional on the user still being linked to `subject` and not disabled, as one
|
|
120
|
+
* atomic update. A whole-document write (Payload's `addSessionToUser`) could undo a disable that
|
|
121
|
+
* landed between the read and the write; this can't. Expired sessions are dropped in the same
|
|
122
|
+
* update.
|
|
123
|
+
*
|
|
124
|
+
* @returns The new session ID, or `undefined` if the user was disabled or relinked meanwhile.
|
|
125
|
+
*/
|
|
126
|
+
export const pushSession = async (payload, options, id, subject, now) => {
|
|
127
|
+
const sid = crypto.randomUUID();
|
|
128
|
+
const session = {
|
|
129
|
+
createdAt: now,
|
|
130
|
+
expiresAt: new Date(now.getTime() + options.sessionSeconds * 1000),
|
|
131
|
+
id: sid,
|
|
132
|
+
};
|
|
133
|
+
const result = await authCollectionModel(payload, options.collection).updateOne({ _id: id, [OIDC_FIELDS.disabledAt]: null, [OIDC_FIELDS.subject]: subject }, [
|
|
134
|
+
{
|
|
135
|
+
$set: {
|
|
136
|
+
sessions: {
|
|
137
|
+
$concatArrays: [
|
|
138
|
+
{
|
|
139
|
+
$filter: {
|
|
140
|
+
cond: { $gt: ['$$this.expiresAt', now] },
|
|
141
|
+
input: { $ifNull: ['$sessions', []] },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
[session],
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
return result.matchedCount > 0 ? sid : undefined;
|
|
151
|
+
};
|
|
152
|
+
/** One stored session, with the fields the refresh cap needs. */
|
|
153
|
+
export const findSession = async (payload, options, id, sid) => {
|
|
154
|
+
const user = await authCollectionModel(payload, options.collection)
|
|
155
|
+
.findOne({ _id: id }, { [OIDC_FIELDS.disabledAt]: 1, sessions: 1 })
|
|
156
|
+
.lean();
|
|
157
|
+
const session = user?.sessions?.find((candidate) => candidate.id === sid);
|
|
158
|
+
if (!user || !session)
|
|
159
|
+
return undefined;
|
|
160
|
+
return { createdAt: new Date(session.createdAt), disabled: Boolean(user.oidcDisabledAt) };
|
|
161
|
+
};
|