@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,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
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The claims the fake provider puts in the next ID token. */
|
|
2
|
+
export type FakeUser = {
|
|
3
|
+
email: string;
|
|
4
|
+
email_verified?: boolean;
|
|
5
|
+
family_name?: string;
|
|
6
|
+
given_name?: string;
|
|
7
|
+
groups?: string[];
|
|
8
|
+
name?: string;
|
|
9
|
+
sub: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A minimal OpenID Connect provider on 127.0.0.1 for tests: discovery, JWKS, an authorization
|
|
13
|
+
* endpoint that signs in `nextUser` at once, and a token endpoint that checks the client secret,
|
|
14
|
+
* the code, the redirect URI and the PKCE verifier, then issues an RS256 ID token.
|
|
15
|
+
*/
|
|
16
|
+
export declare const startFakeProvider: (client: {
|
|
17
|
+
id: string;
|
|
18
|
+
secret: string;
|
|
19
|
+
}) => Promise<{
|
|
20
|
+
close: () => Promise<void>;
|
|
21
|
+
state: {
|
|
22
|
+
/** Signs the ID token with a key the JWKS doesn't publish. */
|
|
23
|
+
forgeSignature: boolean;
|
|
24
|
+
issuer: string;
|
|
25
|
+
nextUser: FakeUser | undefined;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
|
|
4
|
+
/**
|
|
5
|
+
* A minimal OpenID Connect provider on 127.0.0.1 for tests: discovery, JWKS, an authorization
|
|
6
|
+
* endpoint that signs in `nextUser` at once, and a token endpoint that checks the client secret,
|
|
7
|
+
* the code, the redirect URI and the PKCE verifier, then issues an RS256 ID token.
|
|
8
|
+
*/
|
|
9
|
+
export const startFakeProvider = async (client) => {
|
|
10
|
+
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
|
11
|
+
const jwk = { ...(await exportJWK(publicKey)), alg: 'RS256', kid: 'test-key', use: 'sig' };
|
|
12
|
+
const codes = new Map();
|
|
13
|
+
const state = {
|
|
14
|
+
/** Signs the ID token with a key the JWKS doesn't publish. */
|
|
15
|
+
forgeSignature: false,
|
|
16
|
+
issuer: '',
|
|
17
|
+
nextUser: undefined,
|
|
18
|
+
};
|
|
19
|
+
const server = http.createServer(async (req, res) => {
|
|
20
|
+
const url = new URL(req.url ?? '/', state.issuer);
|
|
21
|
+
const json = (status, body) => {
|
|
22
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
23
|
+
};
|
|
24
|
+
if (url.pathname === '/.well-known/openid-configuration') {
|
|
25
|
+
return json(200, {
|
|
26
|
+
authorization_endpoint: `${state.issuer}/authorize`,
|
|
27
|
+
code_challenge_methods_supported: ['S256'],
|
|
28
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
29
|
+
issuer: state.issuer,
|
|
30
|
+
jwks_uri: `${state.issuer}/jwks`,
|
|
31
|
+
response_types_supported: ['code'],
|
|
32
|
+
subject_types_supported: ['public'],
|
|
33
|
+
token_endpoint: `${state.issuer}/token`,
|
|
34
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (url.pathname === '/jwks')
|
|
38
|
+
return json(200, { keys: [jwk] });
|
|
39
|
+
if (url.pathname === '/authorize') {
|
|
40
|
+
const params = url.searchParams;
|
|
41
|
+
if (!state.nextUser || params.get('client_id') !== client.id)
|
|
42
|
+
return json(400, {});
|
|
43
|
+
const code = crypto.randomBytes(16).toString('hex');
|
|
44
|
+
codes.set(code, {
|
|
45
|
+
challenge: params.get('code_challenge') ?? '',
|
|
46
|
+
clientId: client.id,
|
|
47
|
+
nonce: params.get('nonce') ?? '',
|
|
48
|
+
redirectUri: params.get('redirect_uri') ?? '',
|
|
49
|
+
user: state.nextUser,
|
|
50
|
+
});
|
|
51
|
+
const back = new URL(params.get('redirect_uri') ?? '');
|
|
52
|
+
back.searchParams.set('code', code);
|
|
53
|
+
back.searchParams.set('state', params.get('state') ?? '');
|
|
54
|
+
back.searchParams.set('iss', state.issuer);
|
|
55
|
+
res.writeHead(302, { location: back.href }).end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname === '/token' && req.method === 'POST') {
|
|
59
|
+
const body = new URLSearchParams(await new Promise((resolve) => {
|
|
60
|
+
let data = '';
|
|
61
|
+
req.on('data', (chunk) => (data += chunk));
|
|
62
|
+
req.on('end', () => resolve(data));
|
|
63
|
+
}));
|
|
64
|
+
const basic = Buffer.from(`${encodeURIComponent(client.id)}:${encodeURIComponent(client.secret)}`).toString('base64');
|
|
65
|
+
const posted = body.get('client_id') === client.id && body.get('client_secret') === client.secret;
|
|
66
|
+
if (req.headers.authorization !== `Basic ${basic}` && !posted) {
|
|
67
|
+
return json(401, { error: 'invalid_client' });
|
|
68
|
+
}
|
|
69
|
+
const pending = codes.get(body.get('code') ?? '');
|
|
70
|
+
codes.delete(body.get('code') ?? '');
|
|
71
|
+
const verifier = body.get('code_verifier') ?? '';
|
|
72
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
73
|
+
if (!pending ||
|
|
74
|
+
pending.redirectUri !== body.get('redirect_uri') ||
|
|
75
|
+
pending.challenge !== challenge) {
|
|
76
|
+
return json(400, { error: 'invalid_grant' });
|
|
77
|
+
}
|
|
78
|
+
const signingKey = state.forgeSignature
|
|
79
|
+
? (await generateKeyPair('RS256')).privateKey
|
|
80
|
+
: privateKey;
|
|
81
|
+
const idToken = await new SignJWT({ ...pending.user, nonce: pending.nonce })
|
|
82
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
|
83
|
+
.setIssuer(state.issuer)
|
|
84
|
+
.setAudience(client.id)
|
|
85
|
+
.setIssuedAt()
|
|
86
|
+
.setExpirationTime('5m')
|
|
87
|
+
.sign(signingKey);
|
|
88
|
+
return json(200, {
|
|
89
|
+
access_token: 'unused',
|
|
90
|
+
expires_in: 300,
|
|
91
|
+
id_token: idToken,
|
|
92
|
+
token_type: 'Bearer',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
json(404, {});
|
|
96
|
+
});
|
|
97
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
98
|
+
state.issuer = `http://127.0.0.1:${server.address().port}`;
|
|
99
|
+
return {
|
|
100
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
101
|
+
state,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sign-in transaction: what `authorize` hands to `callback` through the browser. It is sealed
|
|
3
|
+
* with AES-256-GCM under a key derived from the Payload secret, so the browser can neither read
|
|
4
|
+
* the PKCE verifier and nonce nor change the return path.
|
|
5
|
+
*/
|
|
6
|
+
export type SignInTransaction = {
|
|
7
|
+
/** When the transaction was created, in milliseconds since the epoch. */
|
|
8
|
+
createdAt: number;
|
|
9
|
+
nonce: string;
|
|
10
|
+
returnTo: string;
|
|
11
|
+
state: string;
|
|
12
|
+
verifier: string;
|
|
13
|
+
};
|
|
14
|
+
/** How long the provider round trip may take. */
|
|
15
|
+
export declare const TRANSACTION_MAX_AGE_SECONDS = 600;
|
|
16
|
+
/** Seals a transaction for the cookie. */
|
|
17
|
+
export declare const sealTransaction: (secret: string, transaction: SignInTransaction) => string;
|
|
18
|
+
/**
|
|
19
|
+
* Opens a sealed transaction.
|
|
20
|
+
*
|
|
21
|
+
* @returns The transaction, or `undefined` if it was tampered with, sealed under another secret,
|
|
22
|
+
* malformed, or older than {@link TRANSACTION_MAX_AGE_SECONDS}.
|
|
23
|
+
*/
|
|
24
|
+
export declare const openTransaction: (secret: string, sealed: string | undefined, now: Date) => SignInTransaction | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* The path to return to after sign-in: only paths under the admin route.
|
|
27
|
+
*
|
|
28
|
+
* Rejects absolute and scheme-relative URLs (`//host`, `/\host`), backslashes, control
|
|
29
|
+
* characters, and anything that doesn't stay under `adminRoute` once normalised.
|
|
30
|
+
*
|
|
31
|
+
* @param requested - The `redirect` the admin passed, if any.
|
|
32
|
+
* @param adminRoute - The admin route, such as `/admin`.
|
|
33
|
+
* @returns A safe same-origin path.
|
|
34
|
+
*/
|
|
35
|
+
export declare const safeReturnPath: (requested: unknown, adminRoute: string) => string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
/** How long the provider round trip may take. */
|
|
3
|
+
export const TRANSACTION_MAX_AGE_SECONDS = 600;
|
|
4
|
+
const KEY_INFO = 'payload-oidc sign-in transaction v1';
|
|
5
|
+
const transactionKey = (secret) => Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), KEY_INFO, 32));
|
|
6
|
+
/** Seals a transaction for the cookie. */
|
|
7
|
+
export const sealTransaction = (secret, transaction) => {
|
|
8
|
+
const iv = crypto.randomBytes(12);
|
|
9
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', transactionKey(secret), iv);
|
|
10
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(transaction), 'utf8'), cipher.final()]);
|
|
11
|
+
return [iv, body, cipher.getAuthTag()].map((part) => part.toString('base64url')).join('.');
|
|
12
|
+
};
|
|
13
|
+
const isTransaction = (value) => {
|
|
14
|
+
if (!value || typeof value !== 'object')
|
|
15
|
+
return false;
|
|
16
|
+
const record = value;
|
|
17
|
+
return (typeof record.createdAt === 'number' &&
|
|
18
|
+
['nonce', 'returnTo', 'state', 'verifier'].every((key) => typeof record[key] === 'string'));
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Opens a sealed transaction.
|
|
22
|
+
*
|
|
23
|
+
* @returns The transaction, or `undefined` if it was tampered with, sealed under another secret,
|
|
24
|
+
* malformed, or older than {@link TRANSACTION_MAX_AGE_SECONDS}.
|
|
25
|
+
*/
|
|
26
|
+
export const openTransaction = (secret, sealed, now) => {
|
|
27
|
+
if (!sealed)
|
|
28
|
+
return undefined;
|
|
29
|
+
const parts = sealed.split('.');
|
|
30
|
+
if (parts.length !== 3)
|
|
31
|
+
return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const [iv, body, tag] = parts.map((part) => Buffer.from(part, 'base64url'));
|
|
34
|
+
// A full 16-byte tag only: GCM would otherwise accept a truncated one.
|
|
35
|
+
if (iv.length !== 12 || tag.length !== 16)
|
|
36
|
+
return undefined;
|
|
37
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', transactionKey(secret), iv, {
|
|
38
|
+
authTagLength: 16,
|
|
39
|
+
});
|
|
40
|
+
decipher.setAuthTag(tag);
|
|
41
|
+
const json = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
|
|
42
|
+
const transaction = JSON.parse(json);
|
|
43
|
+
if (!isTransaction(transaction))
|
|
44
|
+
return undefined;
|
|
45
|
+
const age = now.getTime() - transaction.createdAt;
|
|
46
|
+
if (age < 0 || age > TRANSACTION_MAX_AGE_SECONDS * 1000)
|
|
47
|
+
return undefined;
|
|
48
|
+
return transaction;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The path to return to after sign-in: only paths under the admin route.
|
|
56
|
+
*
|
|
57
|
+
* Rejects absolute and scheme-relative URLs (`//host`, `/\host`), backslashes, control
|
|
58
|
+
* characters, and anything that doesn't stay under `adminRoute` once normalised.
|
|
59
|
+
*
|
|
60
|
+
* @param requested - The `redirect` the admin passed, if any.
|
|
61
|
+
* @param adminRoute - The admin route, such as `/admin`.
|
|
62
|
+
* @returns A safe same-origin path.
|
|
63
|
+
*/
|
|
64
|
+
export const safeReturnPath = (requested, adminRoute) => {
|
|
65
|
+
const fallback = adminRoute || '/';
|
|
66
|
+
if (typeof requested !== 'string' || requested.length === 0 || requested.length > 2048) {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
// Raw input checks first: a backslash or control character is never needed in an admin path.
|
|
70
|
+
// oxlint-disable-next-line no-control-regex
|
|
71
|
+
if (!requested.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(requested))
|
|
72
|
+
return fallback;
|
|
73
|
+
if (requested.startsWith('//'))
|
|
74
|
+
return fallback;
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(requested, 'http://payload-oidc.invalid');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
if (parsed.origin !== 'http://payload-oidc.invalid')
|
|
83
|
+
return fallback;
|
|
84
|
+
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
85
|
+
const root = adminRoute.replace(/\/+$/, '');
|
|
86
|
+
if (root && parsed.pathname !== root && !parsed.pathname.startsWith(`${root}/`))
|
|
87
|
+
return fallback;
|
|
88
|
+
return path;
|
|
89
|
+
};
|
package/package.json
CHANGED
|
@@ -1 +1,92 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@signal58/payload-oidc",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Sign in to the Payload admin with an OpenID Connect provider, with the provider as the source of truth for users and roles.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"authentik",
|
|
7
|
+
"oidc",
|
|
8
|
+
"openid-connect",
|
|
9
|
+
"payload",
|
|
10
|
+
"payload-plugin",
|
|
11
|
+
"payloadcms",
|
|
12
|
+
"sso"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/Signal-58/payload-oidc#readme",
|
|
15
|
+
"bugs": "https://github.com/Signal-58/payload-oidc/issues",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Signal-58/payload-oidc.git"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./rsc": {
|
|
33
|
+
"types": "./dist/exports/rsc.d.ts",
|
|
34
|
+
"default": "./dist/exports/rsc.js"
|
|
35
|
+
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"types": "./dist/test/fake-provider.d.ts",
|
|
38
|
+
"default": "./dist/test/fake-provider.js"
|
|
39
|
+
},
|
|
40
|
+
"./client": {
|
|
41
|
+
"types": "./dist/exports/client.d.ts",
|
|
42
|
+
"default": "./dist/exports/client.js"
|
|
43
|
+
},
|
|
44
|
+
"./package.json": "./package.json"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node scripts/build.mjs",
|
|
52
|
+
"check": "tsc --noEmit && oxlint",
|
|
53
|
+
"check:package": "publint --strict && attw --pack . --profile esm-only",
|
|
54
|
+
"format": "oxfmt",
|
|
55
|
+
"format:check": "oxfmt --check",
|
|
56
|
+
"lint": "oxlint",
|
|
57
|
+
"prepack": "node scripts/build.mjs",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:integration": "node scripts/test-integration.mjs",
|
|
60
|
+
"typecheck": "tsc --noEmit"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"jose": "^6.2.12",
|
|
64
|
+
"openid-client": "6.8.8"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@payloadcms/ui": "^3.91.0",
|
|
68
|
+
"payload": "^3.91.0",
|
|
69
|
+
"react": "^19.0.0"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
73
|
+
"@payloadcms/db-mongodb": "3.92.1",
|
|
74
|
+
"@payloadcms/ui": "3.92.1",
|
|
75
|
+
"@types/node": "22.18.0",
|
|
76
|
+
"@types/react": "19.2.14",
|
|
77
|
+
"mongodb": "6.20.0",
|
|
78
|
+
"next": "16.4.3",
|
|
79
|
+
"oxfmt": "0.53.0",
|
|
80
|
+
"oxlint": "1.77.0",
|
|
81
|
+
"payload": "3.92.1",
|
|
82
|
+
"publint": "0.3.25",
|
|
83
|
+
"react": "19.2.4",
|
|
84
|
+
"react-dom": "19.2.4",
|
|
85
|
+
"typescript": "7.0.2",
|
|
86
|
+
"vitest": "4.1.10"
|
|
87
|
+
},
|
|
88
|
+
"engines": {
|
|
89
|
+
"node": ">=24"
|
|
90
|
+
},
|
|
91
|
+
"packageManager": "pnpm@11.20.0"
|
|
92
|
+
}
|