@delmaredigital/payload-better-auth 0.5.5 → 0.6.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.
@@ -1,392 +1,236 @@
1
1
  /**
2
- * API Key Scope Enforcement Utilities
2
+ * API Key Permission Enforcement Utilities
3
3
  *
4
- * These utilities help enforce API key scopes in Payload access control.
5
- * They extract the API key from requests, validate scopes, and provide
6
- * type-safe access control functions.
4
+ * Thin wrappers around Better Auth's verifyApiKey() for use in
5
+ * Payload access control. Uses BA's native permission format.
7
6
  *
8
7
  * @example
9
8
  * ```ts
10
- * import { requireScope, requireAnyScope } from '@delmaredigital/payload-better-auth'
9
+ * import { requirePermission, allowSessionOrPermission } from '@delmaredigital/payload-better-auth'
11
10
  *
12
11
  * export const Posts: CollectionConfig = {
13
12
  * slug: 'posts',
14
13
  * access: {
15
- * read: requireAnyScope(['posts:read', 'content:read']),
16
- * create: requireScope('posts:write'),
17
- * update: requireScope('posts:write'),
18
- * delete: requireScope('posts:delete'),
14
+ * read: requirePermission('posts', 'read'),
15
+ * create: requirePermission('posts', 'write'),
16
+ * update: requirePermission('posts', 'write'),
17
+ * delete: requirePermission('posts', 'write'),
19
18
  * },
20
19
  * }
21
20
  * ```
22
- */ import { createHash } from 'node:crypto';
23
- // ─────────────────────────────────────────────────────────────────────────────
21
+ */ // ─────────────────────────────────────────────────────────────────────────────
24
22
  // Helpers
25
23
  // ─────────────────────────────────────────────────────────────────────────────
26
- /**
27
- * Hash an API key using the same algorithm as Better Auth's defaultKeyHasher.
28
- * Produces a SHA-256 hash encoded as Base64URL (no padding).
29
- */ function hashApiKey(key) {
30
- return createHash('sha256').update(key).digest('base64url');
31
- }
32
24
  /**
33
25
  * Extract API key from request headers.
34
26
  * Supports Bearer token format: Authorization: Bearer <api-key>
35
27
  */ export function extractApiKeyFromRequest(req) {
36
28
  const authHeader = req.headers?.get('authorization');
37
29
  if (!authHeader) return null;
38
- // Support "Bearer <key>" format
39
30
  if (authHeader.startsWith('Bearer ')) {
40
31
  return authHeader.slice(7).trim();
41
32
  }
42
- // Support raw key in Authorization header
43
33
  return authHeader.trim();
44
34
  }
45
35
  /**
46
- * Look up API key info from the database.
47
- * Returns null if key not found or disabled.
48
- */ export async function getApiKeyInfo(req, apiKey, apiKeysCollection = 'apiKeys') {
36
+ * Verify an API key has the required permission using Better Auth's native verifyApiKey.
37
+ * Returns true if the key is valid and has the permission, false otherwise.
38
+ *
39
+ * Includes backward compatibility: if the key was created with old CRUD actions
40
+ * (create/update/delete), a 'write' check will fall back to checking for those.
41
+ */ async function verifyKeyPermission(req, apiKey, resource, action) {
42
+ const auth = req.payload.betterAuth;
43
+ if (!auth) return false;
49
44
  try {
50
- // Hash the raw API key to match Better Auth's storage format (SHA-256 + Base64URL).
51
- // We query for both the hashed and raw key to support both modes:
52
- // - Hashing enabled (default): only the hashed query matches
53
- // - Hashing disabled (disableKeyHashing: true): only the plaintext query matches
54
- const hashedKey = hashApiKey(apiKey);
55
- // Try the provided collection name first
56
- let results = await req.payload.find({
57
- collection: apiKeysCollection,
58
- overrideAccess: true,
59
- where: {
60
- and: [
61
- {
62
- enabled: {
63
- not_equals: false
64
- }
65
- },
66
- {
67
- or: [
68
- {
69
- key: {
70
- equals: hashedKey
71
- }
72
- },
73
- {
74
- key: {
75
- equals: apiKey
76
- }
77
- }
78
- ]
79
- }
80
- ]
81
- },
82
- limit: 1,
83
- depth: 0
84
- }).catch(()=>null);
85
- // If not found, try alternative slug
86
- if (!results || results.docs.length === 0) {
87
- const altSlug = apiKeysCollection === 'apiKeys' ? 'api-keys' : 'apiKeys';
88
- results = await req.payload.find({
89
- collection: altSlug,
90
- overrideAccess: true,
91
- where: {
92
- and: [
93
- {
94
- enabled: {
95
- not_equals: false
96
- }
97
- },
98
- {
99
- or: [
100
- {
101
- key: {
102
- equals: hashedKey
103
- }
104
- },
105
- {
106
- key: {
107
- equals: apiKey
108
- }
109
- }
110
- ]
111
- }
45
+ // Primary check: use BA's native permission verification
46
+ const result = await auth.api.verifyApiKey({
47
+ body: {
48
+ key: apiKey,
49
+ permissions: {
50
+ [resource]: [
51
+ action
112
52
  ]
113
- },
114
- limit: 1,
115
- depth: 0
116
- }).catch(()=>null);
117
- }
118
- if (!results || results.docs.length === 0) {
119
- return null;
120
- }
121
- const doc = results.docs[0];
122
- // Parse scopes from permissions field (Better Auth format) or scopes array
123
- let scopes = [];
124
- if (doc.permissions) {
125
- try {
126
- const parsed = JSON.parse(doc.permissions);
127
- if (Array.isArray(parsed)) {
128
- scopes = parsed;
129
- } else if (typeof parsed === 'object') {
130
- // Flatten Better Auth permissions format {"resource": ["action1", "action2"]}
131
- // into scope strings like ["resource:action1", "resource:action2"]
132
- scopes = Object.entries(parsed).flatMap(([resource, actions])=>Array.isArray(actions) ? actions.map((action)=>`${resource}:${action}`) : [
133
- resource
134
- ]);
135
53
  }
136
- } catch {
137
- // If not JSON, treat as comma-separated
138
- scopes = doc.permissions.split(',').map((s)=>s.trim()).filter(Boolean);
139
54
  }
140
- } else if (Array.isArray(doc.scopes)) {
141
- scopes = doc.scopes;
142
- }
143
- // Get reference ID and type (BA 1.5 uses referenceId/referenceType instead of userId)
144
- let referenceId;
145
- let referenceType = 'user';
146
- if (doc.referenceId) {
147
- referenceId = String(doc.referenceId);
148
- if (doc.referenceType === 'organization') {
149
- referenceType = 'organization';
55
+ });
56
+ if (result.valid) return true;
57
+ // Backward compat: old keys stored CRUD actions instead of 'read'/'write'
58
+ const fallbackResult = await auth.api.verifyApiKey({
59
+ body: {
60
+ key: apiKey
150
61
  }
151
- } else if (doc.userId) {
152
- // Fallback for pre-1.5 schema
153
- referenceId = String(doc.userId);
154
- } else if (doc.user) {
155
- // Fallback for relationship field
156
- referenceId = typeof doc.user === 'object' ? String(doc.user.id) : String(doc.user);
157
- } else {
158
- return null;
62
+ });
63
+ if (!fallbackResult.valid || !fallbackResult.key?.permissions) return false;
64
+ const perms = fallbackResult.key.permissions;
65
+ const actions = perms[resource];
66
+ if (!Array.isArray(actions)) return false;
67
+ if (action === 'write') {
68
+ // Old 'write' stored as ['read', 'create', 'update'] or ['delete']
69
+ return actions.some((a)=>[
70
+ 'create',
71
+ 'update',
72
+ 'delete'
73
+ ].includes(a));
74
+ }
75
+ if (action === 'read') {
76
+ return actions.includes('read');
159
77
  }
160
- // Parse metadata
161
- let metadata;
162
- if (doc.metadata) {
163
- if (typeof doc.metadata === 'string') {
164
- try {
165
- metadata = JSON.parse(doc.metadata);
166
- } catch {
167
- // Ignore parse errors
168
- }
169
- } else {
170
- metadata = doc.metadata;
171
- }
172
- }
173
- // Prefer scope names from metadata (stored by admin UI as original scope strings
174
- // like ["pages:read", "*"]) over permissions-derived scopes, because the permissions
175
- // field uses Better Auth's internal format (e.g. {"pages": {"$": ["read"]}}) and
176
- // Object.keys() on that only yields collection names, not proper scope strings.
177
- if (metadata?.scopes && Array.isArray(metadata.scopes)) {
178
- scopes = metadata.scopes;
179
- }
180
- return {
181
- id: String(doc.id),
182
- referenceId,
183
- referenceType,
184
- scopes,
185
- keyPrefix: doc.start,
186
- metadata
187
- };
78
+ return false;
188
79
  } catch {
189
- return null;
80
+ return false;
190
81
  }
191
82
  }
192
83
  /**
193
- * Check if an API key has a specific scope.
194
- * Supports wildcard patterns like 'posts:*' matching 'posts:read', 'posts:write', etc.
195
- */ export function hasScope(keyScopes, requiredScope) {
196
- return keyScopes.some((scope)=>{
197
- // Exact match
198
- if (scope === requiredScope) return true;
199
- // Wildcard match: 'posts:*' matches 'posts:read'
200
- if (scope.endsWith(':*')) {
201
- const prefix = scope.slice(0, -1) // Remove '*', keep ':'
202
- ;
203
- return requiredScope.startsWith(prefix);
204
- }
205
- // Global wildcard
206
- if (scope === '*') return true;
84
+ * Verify an API key without checking specific permissions.
85
+ * Returns true if the key is valid, false otherwise.
86
+ */ async function verifyKeyOnly(req, apiKey) {
87
+ const auth = req.payload.betterAuth;
88
+ if (!auth) return false;
89
+ try {
90
+ const result = await auth.api.verifyApiKey({
91
+ body: {
92
+ key: apiKey
93
+ }
94
+ });
95
+ return result.valid === true;
96
+ } catch {
207
97
  return false;
208
- });
209
- }
210
- /**
211
- * Check if an API key has any of the specified scopes.
212
- */ export function hasAnyScope(keyScopes, requiredScopes) {
213
- return requiredScopes.some((scope)=>hasScope(keyScopes, scope));
214
- }
215
- /**
216
- * Check if an API key has all of the specified scopes.
217
- */ export function hasAllScopes(keyScopes, requiredScopes) {
218
- return requiredScopes.every((scope)=>hasScope(keyScopes, scope));
98
+ }
219
99
  }
220
100
  // ─────────────────────────────────────────────────────────────────────────────
221
101
  // Access Control Functions
222
102
  // ─────────────────────────────────────────────────────────────────────────────
223
103
  /**
224
- * Create an access control function that requires a specific scope.
104
+ * Require a specific permission on an API key.
225
105
  *
226
- * @param scope - The required scope string (e.g., 'posts:read')
227
- * @param config - Configuration options
106
+ * @param resource - Collection slug (e.g., 'posts')
107
+ * @param action - Permission action: 'read' or 'write'
108
+ * @param config - Optional configuration
228
109
  * @returns Payload access function
229
110
  *
230
111
  * @example
231
112
  * ```ts
232
113
  * access: {
233
- * read: requireScope('posts:read'),
234
- * create: requireScope('posts:write'),
114
+ * read: requirePermission('posts', 'read'),
115
+ * create: requirePermission('posts', 'write'),
235
116
  * }
236
117
  * ```
237
- */ export function requireScope(scope, config = {}) {
238
- const { apiKeysCollection = 'apiKeys', allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
118
+ */ export function requirePermission(resource, action, config = {}) {
119
+ const { allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
239
120
  return async ({ req })=>{
240
- // If authenticated users are allowed and user is logged in without API key
241
- if (allowAuthenticatedUsers && req.user) {
242
- const apiKey = extractApiKey(req);
243
- if (!apiKey) {
244
- return true // User authenticated via session, no API key = allow
245
- ;
246
- }
247
- }
248
- // Extract API key from request
249
121
  const apiKey = extractApiKey(req);
250
- if (!apiKey) {
251
- return false;
252
- }
253
- // Look up API key
254
- const keyInfo = await getApiKeyInfo(req, apiKey, apiKeysCollection);
255
- if (!keyInfo) {
256
- return false;
122
+ if (allowAuthenticatedUsers && req.user && !apiKey) {
123
+ return true;
257
124
  }
258
- // Check scope
259
- return hasScope(keyInfo.scopes, scope);
125
+ if (!apiKey) return false;
126
+ return verifyKeyPermission(req, apiKey, resource, action);
260
127
  };
261
128
  }
262
129
  /**
263
- * Create an access control function that requires any of the specified scopes.
130
+ * Require any one of the specified permissions.
264
131
  *
265
- * @param scopes - Array of acceptable scopes (at least one must match)
266
- * @param config - Configuration options
132
+ * @param permissions - Array of {resource, action} pairs (at least one must match)
133
+ * @param config - Optional configuration
267
134
  * @returns Payload access function
268
135
  *
269
136
  * @example
270
137
  * ```ts
271
138
  * access: {
272
- * read: requireAnyScope(['posts:read', 'content:read', 'admin:*']),
139
+ * read: requireAnyPermission([
140
+ * { resource: 'posts', action: 'read' },
141
+ * { resource: 'pages', action: 'read' },
142
+ * ]),
273
143
  * }
274
144
  * ```
275
- */ export function requireAnyScope(scopes, config = {}) {
276
- const { apiKeysCollection = 'apiKeys', allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
145
+ */ export function requireAnyPermission(permissions, config = {}) {
146
+ const { allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
277
147
  return async ({ req })=>{
278
- // If authenticated users are allowed and user is logged in without API key
279
- if (allowAuthenticatedUsers && req.user) {
280
- const apiKey = extractApiKey(req);
281
- if (!apiKey) {
282
- return true;
283
- }
284
- }
285
148
  const apiKey = extractApiKey(req);
286
- if (!apiKey) {
287
- return false;
149
+ if (allowAuthenticatedUsers && req.user && !apiKey) {
150
+ return true;
288
151
  }
289
- const keyInfo = await getApiKeyInfo(req, apiKey, apiKeysCollection);
290
- if (!keyInfo) {
291
- return false;
152
+ if (!apiKey) return false;
153
+ for (const perm of permissions){
154
+ if (await verifyKeyPermission(req, apiKey, perm.resource, perm.action)) {
155
+ return true;
156
+ }
292
157
  }
293
- return hasAnyScope(keyInfo.scopes, scopes);
158
+ return false;
294
159
  };
295
160
  }
296
161
  /**
297
- * Create an access control function that requires all specified scopes.
162
+ * Require all of the specified permissions.
298
163
  *
299
- * @param scopes - Array of required scopes (all must be present)
300
- * @param config - Configuration options
164
+ * @param permissions - Array of {resource, action} pairs (all must match)
165
+ * @param config - Optional configuration
301
166
  * @returns Payload access function
302
167
  *
303
168
  * @example
304
169
  * ```ts
305
170
  * access: {
306
- * delete: requireAllScopes(['posts:delete', 'admin:write']),
171
+ * delete: requireAllPermissions([
172
+ * { resource: 'posts', action: 'write' },
173
+ * { resource: 'admin', action: 'write' },
174
+ * ]),
307
175
  * }
308
176
  * ```
309
- */ export function requireAllScopes(scopes, config = {}) {
310
- const { apiKeysCollection = 'apiKeys', allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
177
+ */ export function requireAllPermissions(permissions, config = {}) {
178
+ const { allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
311
179
  return async ({ req })=>{
312
- // If authenticated users are allowed and user is logged in without API key
313
- if (allowAuthenticatedUsers && req.user) {
314
- const apiKey = extractApiKey(req);
315
- if (!apiKey) {
316
- return true;
317
- }
318
- }
319
180
  const apiKey = extractApiKey(req);
320
- if (!apiKey) {
321
- return false;
181
+ if (allowAuthenticatedUsers && req.user && !apiKey) {
182
+ return true;
322
183
  }
323
- const keyInfo = await getApiKeyInfo(req, apiKey, apiKeysCollection);
324
- if (!keyInfo) {
325
- return false;
184
+ if (!apiKey) return false;
185
+ for (const perm of permissions){
186
+ if (!await verifyKeyPermission(req, apiKey, perm.resource, perm.action)) {
187
+ return false;
188
+ }
326
189
  }
327
- return hasAllScopes(keyInfo.scopes, scopes);
190
+ return true;
328
191
  };
329
192
  }
330
193
  /**
331
- * Create an access control function that allows either:
332
- * 1. Authenticated users (via session)
333
- * 2. API key with required scope
334
- *
335
- * This is useful for endpoints that should work with both auth methods.
336
- *
337
- * @param scope - The required scope for API key access
338
- * @param config - Configuration options
339
- * @returns Payload access function
194
+ * Allow either authenticated session OR API key with permission.
340
195
  *
341
196
  * @example
342
197
  * ```ts
343
198
  * access: {
344
- * read: allowSessionOrScope('posts:read'),
199
+ * read: allowSessionOrPermission('posts', 'read'),
345
200
  * }
346
201
  * ```
347
- */ export function allowSessionOrScope(scope, config = {}) {
348
- return requireScope(scope, {
202
+ */ export function allowSessionOrPermission(resource, action, config = {}) {
203
+ return requirePermission(resource, action, {
349
204
  ...config,
350
205
  allowAuthenticatedUsers: true
351
206
  });
352
207
  }
353
208
  /**
354
- * Create an access control function that allows either:
355
- * 1. Authenticated users (via session)
356
- * 2. API key with any of the required scopes
357
- *
358
- * @param scopes - Array of acceptable scopes for API key access
359
- * @param config - Configuration options
360
- * @returns Payload access function
361
- */ export function allowSessionOrAnyScope(scopes, config = {}) {
362
- return requireAnyScope(scopes, {
209
+ * Allow either authenticated session OR API key with any of the permissions.
210
+ */ export function allowSessionOrAnyPermission(permissions, config = {}) {
211
+ return requireAnyPermission(permissions, {
363
212
  ...config,
364
213
  allowAuthenticatedUsers: true
365
214
  });
366
215
  }
367
- // ─────────────────────────────────────────────────────────────────────────────
368
- // Better Auth Integration
369
- // ─────────────────────────────────────────────────────────────────────────────
370
216
  /**
371
- * Validate an API key and get its info.
372
- *
373
- * This performs a database lookup to validate the key and retrieve
374
- * its associated scopes and user.
375
- *
376
- * @param req - Payload request
377
- * @param apiKeysCollection - The API keys collection slug
378
- * @returns API key info if valid, null otherwise
217
+ * Require a valid API key (no specific permissions checked).
218
+ * Useful for apps that use role-based access and just need to verify the key exists.
379
219
  *
380
220
  * @example
381
221
  * ```ts
382
- * const keyInfo = await validateApiKey(req)
383
- * if (keyInfo) {
384
- * console.log('Valid API key for:', keyInfo.referenceId, keyInfo.referenceType)
385
- * console.log('Scopes:', keyInfo.scopes)
222
+ * access: {
223
+ * read: requireApiKey(),
386
224
  * }
387
225
  * ```
388
- */ export async function validateApiKey(req, apiKeysCollection = 'apiKeys') {
389
- const apiKey = extractApiKeyFromRequest(req);
390
- if (!apiKey) return null;
391
- return getApiKeyInfo(req, apiKey, apiKeysCollection);
226
+ */ export function requireApiKey(config = {}) {
227
+ const { allowAuthenticatedUsers = false, extractApiKey = extractApiKeyFromRequest } = config;
228
+ return async ({ req })=>{
229
+ const apiKey = extractApiKey(req);
230
+ if (allowAuthenticatedUsers && req.user && !apiKey) {
231
+ return true;
232
+ }
233
+ if (!apiKey) return false;
234
+ return verifyKeyOnly(req, apiKey);
235
+ };
392
236
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Generate permission definitions from Payload collections for the admin UI.
3
+ */
4
+ import type { CollectionConfig } from 'payload';
5
+ import type { PermissionDefinition } from '../types/apiKey.js';
6
+ /**
7
+ * Generate permission definitions from Payload collections.
8
+ * Returns a list of collections with their available actions (read/write)
9
+ * for display in the API key management UI.
10
+ */
11
+ export declare function generateCollectionPermissions(collections: CollectionConfig[], excludeCollections?: string[]): PermissionDefinition[];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Generate permission definitions from Payload collections for the admin UI.
3
+ */ /** Default collections to exclude from permissions UI */ const DEFAULT_EXCLUDED_COLLECTIONS = [
4
+ 'sessions',
5
+ 'verifications',
6
+ 'accounts',
7
+ 'twoFactors',
8
+ 'apiKeys',
9
+ 'api-keys'
10
+ ];
11
+ /**
12
+ * Convert slug to human-readable label.
13
+ * e.g., 'blog-posts' -> 'Blog Posts'
14
+ */ function slugToLabel(slug) {
15
+ return slug.split('-').map((s)=>s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
16
+ }
17
+ /**
18
+ * Generate permission definitions from Payload collections.
19
+ * Returns a list of collections with their available actions (read/write)
20
+ * for display in the API key management UI.
21
+ */ export function generateCollectionPermissions(collections, excludeCollections = DEFAULT_EXCLUDED_COLLECTIONS) {
22
+ return collections.filter((c)=>!excludeCollections.includes(c.slug)).map((c)=>({
23
+ slug: c.slug,
24
+ label: (typeof c.labels?.plural === 'string' ? c.labels.plural : null) ?? slugToLabel(c.slug) + 's',
25
+ actions: [
26
+ 'read',
27
+ 'write'
28
+ ]
29
+ }));
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-better-auth",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Better Auth adapter and plugins for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,20 +0,0 @@
1
- /**
2
- * Auto-generate API key scopes from Payload collections.
3
- */
4
- import type { CollectionConfig } from 'payload';
5
- import type { ScopeDefinition, ApiKeyScopesConfig, AvailableScope } from '../types/apiKey.js';
6
- /**
7
- * Generate scopes from Payload collections.
8
- * Creates {collection}:read, {collection}:write, {collection}:delete for each collection.
9
- */
10
- export declare function generateScopesFromCollections(collections: CollectionConfig[], excludeCollections?: string[]): Record<string, ScopeDefinition>;
11
- /**
12
- * Build the final scopes configuration from plugin options and collections.
13
- * Handles merging custom scopes with auto-generated collection scopes.
14
- */
15
- export declare function buildAvailableScopes(collections: CollectionConfig[], config?: ApiKeyScopesConfig): AvailableScope[];
16
- /**
17
- * Convert selected scopes to Better Auth permission format.
18
- * Used when creating an API key.
19
- */
20
- export declare function scopesToPermissions(selectedScopeIds: string[], availableScopes: AvailableScope[]): Record<string, string[]>;