payload-better-auth 1.0.10 → 1.1.6
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/README.md +111 -174
- package/dist/better-auth/databaseHooks.js +1 -1
- package/dist/better-auth/databaseHooks.js.map +1 -1
- package/dist/better-auth/plugin.d.ts +1 -1
- package/dist/better-auth/plugin.js +3 -3
- package/dist/better-auth/plugin.js.map +1 -1
- package/dist/better-auth/reconcile-queue.d.ts +1 -1
- package/dist/better-auth/reconcile-queue.js.map +1 -1
- package/dist/better-auth/sources.js +1 -1
- package/dist/better-auth/sources.js.map +1 -1
- package/dist/collections/Users/index.js +1 -1
- package/dist/collections/Users/index.js.map +1 -1
- package/dist/components/BetterAuthLoginServer.d.ts +19 -6
- package/dist/components/BetterAuthLoginServer.js +24 -8
- package/dist/components/BetterAuthLoginServer.js.map +1 -1
- package/dist/components/EmailPasswordFormClient.d.ts +1 -1
- package/dist/components/EmailPasswordFormClient.js.map +1 -1
- package/dist/exports/client.d.ts +1 -1
- package/dist/exports/client.js +1 -1
- package/dist/exports/client.js.map +1 -1
- package/dist/exports/rsc.d.ts +1 -1
- package/dist/exports/rsc.js +1 -1
- package/dist/exports/rsc.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/payload/plugin.d.ts +21 -2
- package/dist/payload/plugin.js +29 -7
- package/dist/payload/plugin.js.map +1 -1
- package/dist/utils/payload-reconcile.js +2 -6
- package/dist/utils/payload-reconcile.js.map +1 -1
- package/package.json +137 -63
- package/src/better-auth/crypto-shared.ts +169 -0
- package/src/better-auth/databaseHooks.ts +30 -0
- package/src/better-auth/helpers.ts +3 -0
- package/src/better-auth/plugin.ts +214 -0
- package/src/better-auth/reconcile-queue.ts +401 -0
- package/src/better-auth/sources.ts +123 -0
- package/src/collections/Users/index.ts +148 -0
- package/src/components/BetterAuthLoginServer.tsx +154 -0
- package/src/components/EmailPasswordFormClient.tsx +204 -0
- package/src/components/VerifyEmailInfoViewClient.tsx +62 -0
- package/src/exports/client.ts +1 -0
- package/src/exports/rsc.ts +1 -0
- package/src/index.ts +9 -0
- package/src/payload/plugin.ts +163 -0
- package/src/utils/payload-reconcile.ts +50 -0
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payload-better-auth",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "1.1.6",
|
|
4
|
+
"description": "A Payload CMS plugin that integrates Better Auth for seamless user authentication and management",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/benjaminpreiss/payload-better-auth"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
@@ -24,78 +28,148 @@
|
|
|
24
28
|
"main": "./dist/index.js",
|
|
25
29
|
"types": "./dist/index.d.ts",
|
|
26
30
|
"files": [
|
|
27
|
-
"dist"
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
28
33
|
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "nx run payload-better-auth:_build",
|
|
36
|
+
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
|
37
|
+
"build:types": "tsc --outDir dist --rootDir ./src",
|
|
38
|
+
"_build": "npm run copyfiles && npm run build:types && npm run build:swc",
|
|
39
|
+
"clean": "rimraf {dist,*.tsbuildinfo}",
|
|
40
|
+
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
|
41
|
+
"dev": "concurrently -k -n \"WEB,MAIL\" -c \"auto\" \"npm run dev:original\" \"npm run maildev\"",
|
|
42
|
+
"dev:build": "nx run payload-better-auth:_dev:build",
|
|
43
|
+
"_dev:build": "dotenv -e ./dev/.env.test -- next build dev",
|
|
44
|
+
"dev:original": "dotenv -e ./dev/.env.development -- next dev dev --turbo",
|
|
45
|
+
"dev:start": "dotenv -e ./dev/.env.test -- next start dev",
|
|
46
|
+
"dev:generate-importmap": "npm run dev:payload generate:importmap",
|
|
47
|
+
"dev:generate-types": "npm run dev:payload generate:types",
|
|
48
|
+
"dev:payload": "dotenv -e ./dev/.env.development -- cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
|
49
|
+
"generate:importmap": "npm run dev:generate-importmap",
|
|
50
|
+
"generate:types": "npm run dev:generate-types",
|
|
51
|
+
"db:reset": "rimraf ./payload.db ./better-auth.db",
|
|
52
|
+
"db:reset:test": "rimraf ./dev/tests/test-payload.db ./dev/tests/test-better-auth.db",
|
|
53
|
+
"payload:migrate": "dotenv -e ./dev/.env.development -- cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload migrate:create --skip-empty && dotenv -e ./dev/.env.development -- cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload migrate",
|
|
54
|
+
"payload:migrate:test": "dotenv -e ./dev/.env.test -- cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload migrate:create --skip-empty && dotenv -e ./dev/.env.test -- cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload migrate",
|
|
55
|
+
"better-auth:migrate": "dotenv -e ./dev/.env.development -- better-auth migrate --yes --config ./dev/lib/auth.ts",
|
|
56
|
+
"better-auth:migrate:test": "dotenv -e ./dev/.env.test -- better-auth migrate --yes --config ./dev/lib/auth.ts",
|
|
57
|
+
"reset": "npm run db:reset && npm run payload:migrate && npm run better-auth:migrate",
|
|
58
|
+
"reset:test": "npm run db:reset:test && npm run payload:migrate:test && npm run better-auth:migrate:test",
|
|
59
|
+
"lint": "nx run payload-better-auth:_lint",
|
|
60
|
+
"_lint": "eslint --stats ./src ./dev",
|
|
61
|
+
"lint:fix:all": "eslint --fix --stats ./src ./dev ./dev/tests",
|
|
62
|
+
"lint:fix": "eslint ./src --fix",
|
|
63
|
+
"maildev": "maildev --smtp=1025 --web=1080",
|
|
64
|
+
"prepare": "husky",
|
|
65
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
66
|
+
"test": "nx run payload-better-auth:_test",
|
|
67
|
+
"_test": "npm run test:int && npm run test:e2e",
|
|
68
|
+
"test:e2e": "nx run payload-better-auth:_test:e2e",
|
|
69
|
+
"_test:e2e": "npm run dev:build && dotenv -e ./dev/.env.test -- playwright test",
|
|
70
|
+
"test:int": "nx run payload-better-auth:_test:int",
|
|
71
|
+
"_test:int": "npm run test:setup && dotenv -e ./dev/.env.test -- vitest run",
|
|
72
|
+
"test:setup": "npm run reset:test",
|
|
73
|
+
"typecheck": "nx run payload-better-auth:_typecheck",
|
|
74
|
+
"_typecheck": "tsc --noEmit -p ./dev/tsconfig.json",
|
|
75
|
+
"semantic-release": "semantic-release",
|
|
76
|
+
"test:int-with-wait": "dotenv -e ./dev/.env.test -- concurrently --success first -k -n \"dev,test\" \"npm run dev\" \"sh -lc 'until curl -fsS http://127.0.0.1:3000/admin >/dev/null; do sleep 0.25; done; npm run test:int'\""
|
|
77
|
+
},
|
|
29
78
|
"devDependencies": {
|
|
30
|
-
"@
|
|
31
|
-
"@
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
79
|
+
"@better-auth/cli": "^1.4.10",
|
|
80
|
+
"@commitlint/cli": "^20.3.1",
|
|
81
|
+
"@commitlint/config-conventional": "^20.3.1",
|
|
82
|
+
"@eslint/eslintrc": "^3.3.3",
|
|
83
|
+
"@payloadcms/db-mongodb": "^3.70.0",
|
|
84
|
+
"@payloadcms/db-postgres": "^3.70.0",
|
|
85
|
+
"@payloadcms/db-sqlite": "^3.70.0",
|
|
34
86
|
"@payloadcms/eslint-config": "^3.28.0",
|
|
35
|
-
"@payloadcms/next": "^3.
|
|
36
|
-
"@payloadcms/richtext-lexical": "^3.
|
|
37
|
-
"@payloadcms/ui": "^3.
|
|
38
|
-
"@playwright/test": "^1.
|
|
39
|
-
"@
|
|
40
|
-
"@
|
|
41
|
-
"@
|
|
42
|
-
"@
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
-
"better-sqlite3": "^
|
|
87
|
+
"@payloadcms/next": "^3.70.0",
|
|
88
|
+
"@payloadcms/richtext-lexical": "^3.70.0",
|
|
89
|
+
"@payloadcms/ui": "^3.70.0",
|
|
90
|
+
"@playwright/test": "^1.57.0",
|
|
91
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
92
|
+
"@semantic-release/git": "^10.0.1",
|
|
93
|
+
"@semantic-release/github": "^12.0.2",
|
|
94
|
+
"@semantic-release/npm": "^13.1.3",
|
|
95
|
+
"@swc-node/register": "1.11.1",
|
|
96
|
+
"@swc/cli": "0.7.9",
|
|
97
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
98
|
+
"@types/node": "^25.0.6",
|
|
99
|
+
"@types/nodemailer": "^7.0.5",
|
|
100
|
+
"@types/react": "19.2.8",
|
|
101
|
+
"@types/react-dom": "19.2.3",
|
|
102
|
+
"better-auth": "^1.4.10",
|
|
103
|
+
"better-sqlite3": "^12.6.0",
|
|
46
104
|
"concurrently": "^9.2.1",
|
|
47
105
|
"copyfiles": "2.4.1",
|
|
48
|
-
"cross-env": "^
|
|
49
|
-
"dotenv-cli": "^
|
|
50
|
-
"
|
|
51
|
-
"eslint
|
|
52
|
-
"
|
|
53
|
-
"
|
|
106
|
+
"cross-env": "^10.1.0",
|
|
107
|
+
"dotenv-cli": "^11.0.0",
|
|
108
|
+
"drizzle-orm": "^0.44.0",
|
|
109
|
+
"eslint": "^9.39.2",
|
|
110
|
+
"eslint-config-next": "16.1.1",
|
|
111
|
+
"execa": "^9.6.1",
|
|
112
|
+
"graphql": "^16.12.0",
|
|
113
|
+
"husky": "^9.1.7",
|
|
54
114
|
"maildev": "^2.2.1",
|
|
55
|
-
"mongodb-memory-server": "
|
|
56
|
-
"next": "
|
|
57
|
-
"nodemailer": "^7.0.
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"react
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
115
|
+
"mongodb-memory-server": "11.0.1",
|
|
116
|
+
"next": "16.1.1",
|
|
117
|
+
"nodemailer": "^7.0.12",
|
|
118
|
+
"nx": "^22.3.3",
|
|
119
|
+
"open": "^11.0.0",
|
|
120
|
+
"payload": "^3.70.0",
|
|
121
|
+
"prettier": "^3.7.4",
|
|
122
|
+
"qs-esm": "7.0.3",
|
|
123
|
+
"react": "19.2.3",
|
|
124
|
+
"react-dom": "19.2.3",
|
|
125
|
+
"rimraf": "6.1.2",
|
|
126
|
+
"semantic-release": "^25.0.2",
|
|
127
|
+
"sharp": "0.34.5",
|
|
128
|
+
"sort-package-json": "^3.6.0",
|
|
129
|
+
"typescript": "5.9.3",
|
|
130
|
+
"vite-tsconfig-paths": "^6.0.4",
|
|
131
|
+
"vitest": "^4.0.16"
|
|
70
132
|
},
|
|
71
133
|
"peerDependencies": {
|
|
72
|
-
"better-auth": "^1.4.
|
|
134
|
+
"better-auth": "^1.4.10",
|
|
73
135
|
"payload": "^3.37.0"
|
|
74
136
|
},
|
|
137
|
+
"packageManager": "pnpm@10.16.1",
|
|
75
138
|
"engines": {
|
|
76
139
|
"node": "^18.20.2 || >=20.9.0",
|
|
77
140
|
"pnpm": "^9 || ^10"
|
|
78
141
|
},
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
142
|
+
"publishConfig": {
|
|
143
|
+
"exports": {
|
|
144
|
+
".": {
|
|
145
|
+
"import": "./dist/index.js",
|
|
146
|
+
"types": "./dist/index.d.ts",
|
|
147
|
+
"default": "./dist/index.js"
|
|
148
|
+
},
|
|
149
|
+
"./client": {
|
|
150
|
+
"import": "./dist/exports/client.js",
|
|
151
|
+
"types": "./dist/exports/client.d.ts",
|
|
152
|
+
"default": "./dist/exports/client.js"
|
|
153
|
+
},
|
|
154
|
+
"./rsc": {
|
|
155
|
+
"import": "./dist/exports/rsc.js",
|
|
156
|
+
"types": "./dist/exports/rsc.d.ts",
|
|
157
|
+
"default": "./dist/exports/rsc.js"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"main": "./dist/index.js",
|
|
161
|
+
"types": "./dist/index.d.ts"
|
|
162
|
+
},
|
|
163
|
+
"pnpm": {
|
|
164
|
+
"overrides": {
|
|
165
|
+
"drizzle-orm": "^0.44.0"
|
|
166
|
+
},
|
|
167
|
+
"onlyBuiltDependencies": [
|
|
168
|
+
"sharp",
|
|
169
|
+
"esbuild",
|
|
170
|
+
"unrs-resolver",
|
|
171
|
+
"better-sqlite3"
|
|
172
|
+
]
|
|
173
|
+
},
|
|
174
|
+
"nx": {}
|
|
175
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// crypto-shared.ts
|
|
2
|
+
import crypto from 'crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type for serializable values that can be canonically stringified
|
|
6
|
+
*/
|
|
7
|
+
type SerializableValue =
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| number
|
|
11
|
+
| SerializableArray
|
|
12
|
+
| SerializableObject
|
|
13
|
+
| string
|
|
14
|
+
| undefined
|
|
15
|
+
|
|
16
|
+
interface SerializableObject {
|
|
17
|
+
[key: string]: SerializableValue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SerializableArray extends Array<SerializableValue> {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Signature object containing timestamp, nonce, and MAC
|
|
24
|
+
*/
|
|
25
|
+
export interface CryptoSignature {
|
|
26
|
+
/** HMAC-SHA256 signature */
|
|
27
|
+
mac: string
|
|
28
|
+
/** Unique nonce for this signature */
|
|
29
|
+
nonce: string
|
|
30
|
+
/** Unix timestamp as string */
|
|
31
|
+
ts: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Input parameters for signature verification
|
|
36
|
+
*/
|
|
37
|
+
export interface VerifySignatureInput {
|
|
38
|
+
/** The data that was signed */
|
|
39
|
+
body: unknown
|
|
40
|
+
/** Maximum allowed time skew in seconds (default: 300) */
|
|
41
|
+
maxSkewSec?: number
|
|
42
|
+
/** Secret key for verification */
|
|
43
|
+
secret: string
|
|
44
|
+
/** The signature to verify */
|
|
45
|
+
signature: CryptoSignature
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Input parameters for signature creation
|
|
50
|
+
*/
|
|
51
|
+
export interface SignCanonicalInput {
|
|
52
|
+
/** The data to sign */
|
|
53
|
+
body: unknown
|
|
54
|
+
/** Secret key for signing */
|
|
55
|
+
secret: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Converts an object to a canonical string representation
|
|
60
|
+
* Handles circular references and ensures consistent ordering
|
|
61
|
+
*/
|
|
62
|
+
function canonicalStringify(obj: unknown): string {
|
|
63
|
+
const seen = new WeakSet<object>()
|
|
64
|
+
|
|
65
|
+
const walk = (v: unknown): string => {
|
|
66
|
+
if (v && typeof v === 'object') {
|
|
67
|
+
if (seen.has(v)) {
|
|
68
|
+
throw new Error('Circular reference detected in object')
|
|
69
|
+
}
|
|
70
|
+
seen.add(v)
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(v)) {
|
|
73
|
+
const result = `[${v.map(walk).join(',')}]`
|
|
74
|
+
seen.delete(v)
|
|
75
|
+
return result
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const keys = Object.keys(v).sort()
|
|
79
|
+
const result = `{${keys.map((k) => `"${k}":${walk((v as Record<string, unknown>)[k])}`).join(',')}}`
|
|
80
|
+
seen.delete(v)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
return JSON.stringify(v)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return walk(obj)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Creates a cryptographic signature for the given data
|
|
91
|
+
* @param body - The data to sign
|
|
92
|
+
* @param secret - Secret key for signing
|
|
93
|
+
* @returns Signature object with timestamp, nonce, and MAC
|
|
94
|
+
*/
|
|
95
|
+
export function signCanonical(body: unknown, secret: string): CryptoSignature {
|
|
96
|
+
if (!secret || typeof secret !== 'string') {
|
|
97
|
+
throw new Error('Secret must be a non-empty string')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ts = Math.floor(Date.now() / 1000).toString()
|
|
101
|
+
const nonce = crypto.randomUUID()
|
|
102
|
+
const payload = canonicalStringify(body)
|
|
103
|
+
const mac = crypto.createHmac('sha256', secret).update(`${ts}.${nonce}.${payload}`).digest('hex')
|
|
104
|
+
|
|
105
|
+
return { mac, nonce, ts }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Verifies a cryptographic signature
|
|
110
|
+
* @param body - The original data that was signed
|
|
111
|
+
* @param sig - The signature to verify
|
|
112
|
+
* @param secret - Secret key for verification
|
|
113
|
+
* @param maxSkewSec - Maximum allowed time skew in seconds (default: 300)
|
|
114
|
+
* @returns true if signature is valid, false otherwise
|
|
115
|
+
*/
|
|
116
|
+
export function verifyCanonical(
|
|
117
|
+
body: unknown,
|
|
118
|
+
sig: CryptoSignature,
|
|
119
|
+
secret: string,
|
|
120
|
+
maxSkewSec: number = 300,
|
|
121
|
+
) {
|
|
122
|
+
if (!secret || typeof secret !== 'string') {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!sig || typeof sig !== 'object' || !sig.ts || !sig.nonce || !sig.mac) {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validate timestamp
|
|
131
|
+
const now = Math.floor(Date.now() / 1000)
|
|
132
|
+
const t = Number(sig.ts)
|
|
133
|
+
if (!Number.isFinite(t) || Math.abs(now - t) > maxSkewSec) {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const payload = canonicalStringify(body)
|
|
139
|
+
const expected = crypto
|
|
140
|
+
.createHmac('sha256', secret)
|
|
141
|
+
.update(`${sig.ts}.${sig.nonce}.${payload}`)
|
|
142
|
+
.digest('hex')
|
|
143
|
+
|
|
144
|
+
return crypto.timingSafeEqual(
|
|
145
|
+
new Uint8Array(Buffer.from(sig.mac, 'hex')),
|
|
146
|
+
new Uint8Array(Buffer.from(expected, 'hex')),
|
|
147
|
+
)
|
|
148
|
+
} catch {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Convenience function for verifying signatures with input object
|
|
155
|
+
* @param input - Verification parameters
|
|
156
|
+
* @returns true if signature is valid, false otherwise
|
|
157
|
+
*/
|
|
158
|
+
export function verifySignature(input: VerifySignatureInput) {
|
|
159
|
+
return verifyCanonical(input.body, input.signature, input.secret, input.maxSkewSec)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Convenience function for creating signatures with input object
|
|
164
|
+
* @param input - Signing parameters
|
|
165
|
+
* @returns Signature object
|
|
166
|
+
*/
|
|
167
|
+
export function createSignature(input: SignCanonicalInput) {
|
|
168
|
+
return signCanonical(input.body, input.secret)
|
|
169
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { betterAuth } from 'better-auth'
|
|
2
|
+
import type { SanitizedConfig } from 'payload'
|
|
3
|
+
|
|
4
|
+
import { createDeleteUserFromPayload, createSyncUserToPayload } from './sources'
|
|
5
|
+
|
|
6
|
+
export function createDatabaseHooks({
|
|
7
|
+
config,
|
|
8
|
+
}: {
|
|
9
|
+
config: Promise<SanitizedConfig>
|
|
10
|
+
}): Parameters<typeof betterAuth>['0']['databaseHooks'] {
|
|
11
|
+
const syncUserToPayload = createSyncUserToPayload(config)
|
|
12
|
+
const deleteUserFromPayload = createDeleteUserFromPayload(config)
|
|
13
|
+
return {
|
|
14
|
+
user: {
|
|
15
|
+
create: {
|
|
16
|
+
// After the BA user exists, sync to Payload. On failure, enqueue in memory.
|
|
17
|
+
after: async (user) => {
|
|
18
|
+
// push BA-induced ensure to the **front** of the queue
|
|
19
|
+
await syncUserToPayload(user)
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// TODO: possibly offer "update"
|
|
23
|
+
delete: {
|
|
24
|
+
after: async (user) => {
|
|
25
|
+
await deleteUserFromPayload(user.id)
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/plugins/reconcile-queue-plugin.ts
|
|
2
|
+
import type { AuthContext, BetterAuthPlugin, DeepPartial } from 'better-auth'
|
|
3
|
+
import type { SanitizedConfig } from 'payload'
|
|
4
|
+
|
|
5
|
+
import { APIError } from 'better-auth/api'
|
|
6
|
+
import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins'
|
|
7
|
+
|
|
8
|
+
import type { AuthMethod } from './helpers'
|
|
9
|
+
|
|
10
|
+
import { createDatabaseHooks } from './databaseHooks'
|
|
11
|
+
import { type InitOptions, Queue } from './reconcile-queue'
|
|
12
|
+
import {
|
|
13
|
+
type BAUser,
|
|
14
|
+
createDeleteUserFromPayload,
|
|
15
|
+
createListPayloadUsersPage,
|
|
16
|
+
createSyncUserToPayload,
|
|
17
|
+
} from './sources'
|
|
18
|
+
|
|
19
|
+
type PayloadSyncPluginContext = { payloadSyncPlugin: { queue: Queue } } & AuthContext
|
|
20
|
+
|
|
21
|
+
type CreateAdminsUser = Parameters<AuthContext['internalAdapter']['createUser']>['0']
|
|
22
|
+
|
|
23
|
+
const defaultLog = (msg: string, extra?: unknown) => {
|
|
24
|
+
console.log(`[reconcile] ${msg}`, extra ? JSON.stringify(extra, null, 2) : '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const payloadBetterAuthPlugin = (
|
|
28
|
+
opts: {
|
|
29
|
+
createAdmins?: { overwrite?: boolean; user: CreateAdminsUser }[]
|
|
30
|
+
enableLogging?: boolean
|
|
31
|
+
payloadConfig: Promise<SanitizedConfig>
|
|
32
|
+
token: string // simple header token for admin endpoints,
|
|
33
|
+
} & InitOptions,
|
|
34
|
+
): BetterAuthPlugin => {
|
|
35
|
+
return {
|
|
36
|
+
id: 'reconcile-queue-plugin',
|
|
37
|
+
endpoints: {
|
|
38
|
+
run: createAuthEndpoint(
|
|
39
|
+
'/reconcile/run',
|
|
40
|
+
{ method: 'POST' },
|
|
41
|
+
async ({ context, json, request }) => {
|
|
42
|
+
if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
|
|
43
|
+
throw new APIError('UNAUTHORIZED', { message: 'invalid token' })
|
|
44
|
+
}
|
|
45
|
+
await (context as PayloadSyncPluginContext).payloadSyncPlugin.queue.seedFullReconcile()
|
|
46
|
+
return json({ ok: true })
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
status: createAuthEndpoint(
|
|
50
|
+
'/reconcile/status',
|
|
51
|
+
{ method: 'GET' },
|
|
52
|
+
async ({ context, json, request }) => {
|
|
53
|
+
if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
|
|
54
|
+
return Promise.reject(
|
|
55
|
+
new APIError('UNAUTHORIZED', { message: 'invalid token' }) as Error,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return json((context as PayloadSyncPluginContext).payloadSyncPlugin.queue.status())
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
// convenience for tests/admin tools (optional)
|
|
62
|
+
authMethods: createAuthEndpoint(
|
|
63
|
+
'/auth/methods',
|
|
64
|
+
{ method: 'GET' },
|
|
65
|
+
async ({ context, json }) => {
|
|
66
|
+
const authMethods: AuthMethod[] = []
|
|
67
|
+
// Check if emailAndPassword is enabled, or if present at all (not present defaults to false)
|
|
68
|
+
if (context.options.emailAndPassword?.enabled) {
|
|
69
|
+
authMethods.push({
|
|
70
|
+
method: 'emailAndPassword',
|
|
71
|
+
options: {
|
|
72
|
+
minPasswordLength: context.options.emailAndPassword.minPasswordLength ?? 0,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
if (context.options.plugins?.some((p) => p.id === 'magic-link')) {
|
|
77
|
+
authMethods.push({ method: 'magicLink' })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return await json(authMethods)
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
deleteNow: createAuthEndpoint(
|
|
84
|
+
'/reconcile/delete',
|
|
85
|
+
{ method: 'POST' },
|
|
86
|
+
async ({ context, json, request }) => {
|
|
87
|
+
if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
|
|
88
|
+
throw new APIError('UNAUTHORIZED', { message: 'invalid token' })
|
|
89
|
+
}
|
|
90
|
+
const body = (await request?.json().catch(() => ({}))) as { baId?: string } | undefined
|
|
91
|
+
const baId = body?.baId
|
|
92
|
+
if (!baId) {
|
|
93
|
+
throw new APIError('BAD_REQUEST', { message: 'missing baId' })
|
|
94
|
+
}
|
|
95
|
+
;(context as PayloadSyncPluginContext).payloadSyncPlugin.queue.enqueueDelete(
|
|
96
|
+
baId,
|
|
97
|
+
true,
|
|
98
|
+
'user-operation',
|
|
99
|
+
)
|
|
100
|
+
return json({ ok: true })
|
|
101
|
+
},
|
|
102
|
+
),
|
|
103
|
+
ensureNow: createAuthEndpoint(
|
|
104
|
+
'/reconcile/ensure',
|
|
105
|
+
{ method: 'POST' },
|
|
106
|
+
async ({ context, json, request }) => {
|
|
107
|
+
if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
|
|
108
|
+
throw new APIError('UNAUTHORIZED', { message: 'invalid token' })
|
|
109
|
+
}
|
|
110
|
+
const body = (await request?.json().catch(() => ({}))) as { user?: BAUser } | undefined
|
|
111
|
+
const user = body?.user
|
|
112
|
+
if (!user?.id) {
|
|
113
|
+
throw new APIError('BAD_REQUEST', { message: 'missing user' })
|
|
114
|
+
}
|
|
115
|
+
;(context as PayloadSyncPluginContext).payloadSyncPlugin.queue.enqueueEnsure(
|
|
116
|
+
user,
|
|
117
|
+
true,
|
|
118
|
+
'user-operation',
|
|
119
|
+
)
|
|
120
|
+
return json({ ok: true })
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
hooks: {
|
|
125
|
+
before: [
|
|
126
|
+
{
|
|
127
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
128
|
+
const locale = ctx.getHeader('User-Locale')
|
|
129
|
+
return Promise.resolve({
|
|
130
|
+
context: { ...ctx, body: { ...ctx.body, locale: locale ?? undefined } },
|
|
131
|
+
})
|
|
132
|
+
}),
|
|
133
|
+
matcher: (context) => {
|
|
134
|
+
return context.path === '/sign-up/email'
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
schema: {
|
|
140
|
+
user: {
|
|
141
|
+
fields: {
|
|
142
|
+
locale: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
required: false,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
// TODO: the queue must be destroyed on better auth instance destruction, as it utilizes timers.
|
|
150
|
+
async init({ internalAdapter, password }) {
|
|
151
|
+
if (opts.createAdmins) {
|
|
152
|
+
try {
|
|
153
|
+
await Promise.all(
|
|
154
|
+
opts.createAdmins.map(async ({ overwrite, user }) => {
|
|
155
|
+
const alreadyExistingUser = await internalAdapter.findUserByEmail(user.email)
|
|
156
|
+
if (alreadyExistingUser) {
|
|
157
|
+
if (overwrite) {
|
|
158
|
+
// clear accounts
|
|
159
|
+
await internalAdapter.deleteAccounts(alreadyExistingUser.user.id)
|
|
160
|
+
const createdUser = await internalAdapter.updateUser(
|
|
161
|
+
alreadyExistingUser.user.id,
|
|
162
|
+
{
|
|
163
|
+
...user,
|
|
164
|
+
role: 'admin',
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
// assuming this creates an account?
|
|
168
|
+
await internalAdapter.linkAccount({
|
|
169
|
+
accountId: createdUser.id,
|
|
170
|
+
password: await password.hash(user.password),
|
|
171
|
+
providerId: 'credential',
|
|
172
|
+
userId: createdUser.id,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// if the user doesnt exist there can't be an account
|
|
177
|
+
else {
|
|
178
|
+
const createdUser = await internalAdapter.createUser({ ...user, role: 'admin' })
|
|
179
|
+
await internalAdapter.linkAccount({
|
|
180
|
+
accountId: createdUser.id,
|
|
181
|
+
password: await password.hash(user.password),
|
|
182
|
+
providerId: 'credential',
|
|
183
|
+
userId: createdUser.id,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (opts.enableLogging) {
|
|
190
|
+
defaultLog('Failed to create Admin user', error)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const queue = new Queue(
|
|
196
|
+
{
|
|
197
|
+
deleteUserFromPayload: createDeleteUserFromPayload(opts.payloadConfig),
|
|
198
|
+
internalAdapter,
|
|
199
|
+
listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig),
|
|
200
|
+
log: opts.enableLogging ? defaultLog : undefined,
|
|
201
|
+
syncUserToPayload: createSyncUserToPayload(opts.payloadConfig),
|
|
202
|
+
},
|
|
203
|
+
opts,
|
|
204
|
+
)
|
|
205
|
+
return {
|
|
206
|
+
context: { payloadSyncPlugin: { queue } } as DeepPartial<Omit<AuthContext, 'options'>>,
|
|
207
|
+
options: {
|
|
208
|
+
databaseHooks: createDatabaseHooks({ config: opts.payloadConfig }),
|
|
209
|
+
user: { deleteUser: { enabled: true } },
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|