@signal58/payload-oidc 0.0.0 → 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 +300 -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 +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 +92 -1
|
@@ -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.2.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
|
+
}
|