json-rest-api 1.0.10 → 1.0.13

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.
Files changed (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +7 -11
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -1,897 +0,0 @@
1
- /**
2
- * JWT Authentication Plugin for REST API
3
- *
4
- * This plugin provides:
5
- * 1. JWT token validation and authentication
6
- * 2. Declarative authorization rules on resources
7
- * 3. Token revocation support
8
- * 4. Built-in and custom auth checkers
9
- * 5. Helper methods for auth operations
10
- *
11
- * The plugin works by:
12
- * - Intercepting requests in the transport:request hook
13
- * - Validating JWT tokens and populating context.auth
14
- * - Checking permissions in the checkPermissions hook
15
- * - Providing helpers for programmatic auth checks
16
- */
17
-
18
- import { requirePackage } from 'hooked-api';
19
- import {
20
- verifyToken,
21
- createRevocationResource,
22
- checkRevocation,
23
- cleanupExpiredTokens
24
- } from './lib/jwt-auth-helpers.js';
25
-
26
- /* =========================================================================
27
- * PLUGIN EXPORTS
28
- * ========================================================================= */
29
-
30
- /* =========================================================================
31
- * PLUGIN DEFINITION AND MAIN INSTALL FUNCTION
32
- * ========================================================================= */
33
-
34
- export const JwtAuthPlugin = {
35
- name: 'jwt-auth',
36
- version: '1.0.0',
37
- dependencies: ['rest-api'], // Requires REST API plugin for resource operations
38
-
39
- async install({ api, addHook, log, runHooks, helpers, vars, on, pluginOptions }) {
40
-
41
- /* -----------------------------------------------------------------------
42
- * INITIALIZATION
43
- * ----------------------------------------------------------------------- */
44
-
45
- // Use plugin options directly
46
- const jwtOptions = pluginOptions || {};
47
-
48
- // Initialize plugin state
49
- // State is scoped to this install method and accessible via closure
50
- const state = {
51
- // Timer ID for periodic cleanup of expired revoked tokens
52
- cleanupJob: null,
53
-
54
- // In-memory storage for revoked tokens (alternative to database storage)
55
- // WARNING: Memory storage is cleared on restart - use database for production
56
- memoryRevocationStore: new Map(),
57
-
58
- // Registry of auth checker functions (public, authenticated, is_owner, etc.)
59
- authCheckers: new Map()
60
- };
61
-
62
- /* -----------------------------------------------------------------------
63
- * CONFIGURATION PARSING
64
- *
65
- * Parses and validates all configuration options with sensible defaults.
66
- * Configuration is divided into logical groups for clarity.
67
- * ----------------------------------------------------------------------- */
68
-
69
- const config = {
70
- // Token validation strategies
71
- // - secret: For symmetric algorithms (HS256)
72
- // - publicKey: For asymmetric algorithms (RS256)
73
- // - jwksUrl: For dynamic key rotation (fetches public key by kid)
74
- secret: jwtOptions.secret,
75
- publicKey: jwtOptions.publicKey,
76
- jwksUrl: jwtOptions.jwksUrl,
77
- algorithms: jwtOptions.algorithms || ['HS256', 'RS256'],
78
- audience: jwtOptions.audience,
79
- issuer: jwtOptions.issuer,
80
-
81
- // Token claim mapping
82
- // These configure which JWT claims map to auth context properties
83
- // Example JWT payload:
84
- // {
85
- // "sub": "user123",
86
- // "email": "user@example.com",
87
- // "roles": ["user", "editor"],
88
- // "permissions": ["posts:write", "comments:*"]
89
- // }
90
- userIdField: jwtOptions.userIdField || 'sub', // Standard JWT subject claim
91
- emailField: jwtOptions.emailField || 'email',
92
- rolesField: jwtOptions.rolesField || 'roles',
93
- permissionsField: jwtOptions.permissionsField || 'permissions',
94
-
95
- // Resource ownership
96
- // Configures which field in resources indicates the owner
97
- ownershipField: jwtOptions.ownershipField || 'user_id',
98
-
99
- // Token revocation configuration
100
- // Supports both database and in-memory storage
101
- revocation: {
102
- enabled: jwtOptions.revocation?.enabled !== false, // Default: enabled
103
- storage: jwtOptions.revocation?.storage || 'database', // 'database' or 'memory'
104
- cleanupInterval: jwtOptions.revocation?.cleanupInterval || 3600000, // 1 hour
105
- tableName: jwtOptions.revocation?.tableName || 'revoked_tokens'
106
- },
107
-
108
- // Optional REST endpoints
109
- // The plugin can automatically add logout and session endpoints
110
- endpoints: {
111
- logout: jwtOptions.endpoints?.logout || false, // e.g., '/auth/logout'
112
- session: jwtOptions.endpoints?.session || false // e.g., '/auth/session'
113
- }
114
- };
115
-
116
- /* -----------------------------------------------------------------------
117
- * CONFIGURATION VALIDATION
118
- * ----------------------------------------------------------------------- */
119
-
120
- // Ensure at least one token validation method is provided
121
- if (!config.secret && !config.publicKey && !config.jwksUrl) {
122
- throw new Error('JwtAuthPlugin requires either secret, publicKey, or jwksUrl');
123
- }
124
-
125
- /* -----------------------------------------------------------------------
126
- * JWKS CLIENT SETUP
127
- *
128
- * For Auth0, Supabase, and other providers that use rotating keys.
129
- * The JWKS client fetches public keys dynamically based on the 'kid' claim.
130
- *
131
- * When to use each verification method:
132
- * - secret: For simple symmetric keys (HS256) - same key signs and verifies
133
- * - publicKey: For static asymmetric keys (RS256) - private key signs, public verifies
134
- * - jwksUrl: For providers with key rotation - fetches current public key by kid
135
- *
136
- * JWKS is preferred for production as it:
137
- * - Supports automatic key rotation
138
- * - Caches keys to reduce network calls
139
- * - Rate limits to prevent abuse
140
- * ----------------------------------------------------------------------- */
141
-
142
- let jwksClientInstance;
143
- if (config.jwksUrl) {
144
- // Dynamic import for jwks-rsa
145
- let jwksClient;
146
- try {
147
- jwksClient = (await import('jwks-rsa')).default;
148
- } catch (e) {
149
- requirePackage('jwks-rsa', 'jwt-auth',
150
- 'JWKS RSA support is required for dynamic key rotation with providers like Auth0, Supabase, etc. This is a peer dependency.');
151
- }
152
-
153
- jwksClientInstance = jwksClient({
154
- jwksUri: config.jwksUrl,
155
- cache: true, // Cache keys to reduce network calls
156
- cacheMaxAge: 600000, // Cache for 10 minutes
157
- rateLimit: true, // Prevent abuse
158
- jwksRequestsPerMinute: 5 // Max 5 key fetches per minute
159
- });
160
- }
161
-
162
- /* -----------------------------------------------------------------------
163
- * TOKEN REVOCATION SETUP
164
- *
165
- * Supports two storage backends:
166
- * 1. Database: Persistent, survives restarts, scalable
167
- * 2. Memory: Fast, ephemeral, good for development
168
- * ----------------------------------------------------------------------- */
169
-
170
- if (config.revocation.enabled && config.revocation.storage === 'database') {
171
- // Create the revoked_tokens table if it doesn't exist
172
- await createRevocationResource(api, config.revocation.tableName);
173
-
174
- // Set up periodic cleanup of expired tokens
175
- // This prevents the revocation table from growing indefinitely
176
- if (config.revocation.cleanupInterval > 0) {
177
- const cleanupJob = setInterval(async () => {
178
- try {
179
- const deleted = await cleanupExpiredTokens(api, config.revocation.tableName);
180
- if (deleted > 0) {
181
- log.debug(`Cleaned up ${deleted} expired tokens from revocation list`);
182
- }
183
- } catch (error) {
184
- log.error('Failed to cleanup expired tokens:', error);
185
- }
186
- }, config.revocation.cleanupInterval);
187
-
188
- // Store cleanup job in state for proper cleanup later
189
- state.cleanupJob = cleanupJob;
190
- }
191
- }
192
- // Note: In-memory revocation store has limitations:
193
- // - Tokens are NOT persisted across restarts
194
- // - No automatic cleanup of expired tokens
195
- // - Not suitable for multi-instance deployments
196
- // Use database storage for production systems
197
-
198
- /* -----------------------------------------------------------------------
199
- * BUILT-IN AUTH CHECKERS
200
- *
201
- * These are the authorization rules that can be used in resource definitions.
202
- * Each checker is a function that returns true/false for permission.
203
- *
204
- * Usage in resource definition:
205
- * auth: {
206
- * query: ['public'],
207
- * post: ['authenticated'],
208
- * patch: ['is_owner', 'admin'],
209
- * delete: ['admin']
210
- * }
211
- * ----------------------------------------------------------------------- */
212
-
213
- // 'public' - Anyone can access, no authentication required
214
- state.authCheckers.set('public', (context) => true);
215
-
216
- // 'authenticated' - User must be logged in (have a valid token)
217
- state.authCheckers.set('authenticated', (context) => {
218
- return !!context.auth?.userId;
219
- });
220
-
221
- // 'is_owner' - User must own the resource
222
- // This is the most complex checker as it handles multiple data formats
223
- state.authCheckers.set('is_owner', (context, { existingRecord, scopeVars }) => {
224
- if (!context.auth?.userId) return false;
225
-
226
- // For new records being created, ownership check passes
227
- const record = existingRecord || context.attributes;
228
- if (!record) return true;
229
-
230
- // Determine which field indicates ownership (configurable per scope)
231
- const ownerField = scopeVars?.ownershipField || config.ownershipField;
232
-
233
- // Handle JSON:API format (record has type and attributes)
234
- if (record.type && record.attributes) {
235
- // First check attributes for the ownership field
236
- if (record.attributes[ownerField] !== undefined) {
237
- return record.attributes[ownerField] === context.auth.userId;
238
- }
239
-
240
- // If not in attributes, check relationships
241
- // This handles belongsTo relationships (e.g., user_id stored as 'user' relationship)
242
- if (record.relationships) {
243
- // Convert 'user_id' to 'user' for relationship name
244
- const relationshipName = ownerField.replace(/_id$/, '');
245
- const relationship = record.relationships[relationshipName];
246
- if (relationship?.data?.id) {
247
- return String(relationship.data.id) === String(context.auth.userId);
248
- }
249
- }
250
-
251
- return false;
252
- }
253
-
254
- // Handle simplified/flat format
255
- return record[ownerField] === context.auth.userId;
256
- });
257
-
258
- // 'admin' - User must have the 'admin' role
259
- state.authCheckers.set('admin', (context) => {
260
- return context.auth?.roles?.includes('admin');
261
- });
262
-
263
- // 'has_role:X' - User must have a specific role
264
- // Usage: 'has_role:editor', 'has_role:moderator'
265
- state.authCheckers.set('has_role', (context, { param }) => {
266
- if (!param) throw new Error('has_role requires a role parameter');
267
- return context.auth?.roles?.includes(param);
268
- });
269
-
270
- // 'has_permission:X' - User must have a specific permission
271
- // Usage: 'has_permission:posts:write', 'has_permission:users:delete'
272
- state.authCheckers.set('has_permission', (context, { param }) => {
273
- if (!param) throw new Error('has_permission requires a permission parameter');
274
- const permissions = context.auth?.permissions || [];
275
-
276
- // Check for exact permission match
277
- if (permissions.includes(param)) return true;
278
-
279
- // Check for wildcard permissions (e.g., 'posts:*' or '*')
280
- const [resource] = param.split(':');
281
- return permissions.includes(`${resource}:*`) || permissions.includes('*');
282
- });
283
-
284
- /*
285
- * Summary: All built-in auth checkers are now registered in state.
286
- * Resources can use these in their auth definitions:
287
- * - 'public', 'authenticated', 'is_owner', 'admin'
288
- * - 'has_role:X', 'has_permission:X'
289
- * Custom checkers can be added via helpers.auth.registerChecker()
290
- */
291
-
292
- /* -----------------------------------------------------------------------
293
- * HOOK REGISTRATIONS
294
- *
295
- * Hooks integrate the JWT plugin with the REST API flow.
296
- * They execute in this order for a typical authenticated request:
297
- *
298
- * 1. scope:added (at startup) - Process auth rules from resource definitions
299
- * 2. transport:request (per request) - Validate JWT and populate context.auth
300
- * 3. checkPermissions (per operation) - Enforce auth rules before data access
301
- *
302
- * Example request flow:
303
- * - Client: POST /api/posts with Bearer token
304
- * - Hook 2: Validates token, sets context.auth = {userId: '123', roles: ['user']}
305
- * - REST API: Routes to posts resource, method = 'post'
306
- * - Hook 3: Checks posts.auth.post rules against context.auth
307
- * - If authorized: Operation proceeds
308
- * - If not: 403 Forbidden error
309
- * ----------------------------------------------------------------------- */
310
-
311
- // HOOK 1: Process auth rules when a scope is added
312
- // This extracts the 'auth' configuration from resource definitions
313
- //
314
- // Example resource definition:
315
- // api.declareResource('posts', {
316
- // auth: {
317
- // query: ['public'], // Anyone can read
318
- // post: ['authenticated'], // Must be logged in to create
319
- // patch: ['is_owner', 'admin'], // Owner or admin can edit
320
- // delete: ['admin'] // Only admin can delete
321
- // }
322
- // });
323
- addHook('scope:added', 'jwt-process-auth-rules', {}, ({ context, scopes }) => {
324
- const { scopeName } = context;
325
- const scope = scopes[scopeName];
326
-
327
- // Extract auth rules from the resource definition
328
- const auth = context.scopeOptions?.auth;
329
-
330
- if (!auth) return;
331
-
332
- // Store auth rules in scope vars for later permission checking
333
- scope.vars.authRules = auth;
334
-
335
- log.debug(`Auth rules registered for ${scopeName}:`, scope.vars.authRules);
336
- });
337
-
338
- // HOOK 2: Authenticate incoming requests
339
- //
340
- // WHEN: This hook fires for EVERY incoming HTTP request, very early in the pipeline
341
- // WHO CALLS IT: The transport plugin (e.g., express-plugin) triggers this hook
342
- // EXECUTION ORDER: After request parsing but BEFORE any resource operation
343
- //
344
- // DATA FLOW:
345
- // 1. Transport extracts JWT from Authorization header: "Bearer <token>"
346
- // 2. Transport sets context.request.token = extracted token string
347
- // 3. This hook validates the token and populates context.auth
348
- // 4. All subsequent hooks/operations can access context.auth
349
- //
350
- // FAILURE HANDLING:
351
- // - No token provided → context.auth = null → request continues as anonymous
352
- // - Invalid/expired token → context.auth = null → request continues as anonymous
353
- // - Revoked token → context.auth = null → request continues as anonymous
354
- //
355
- // The request is NEVER blocked here. Instead:
356
- // - Resources with auth: { query: ['public'] } → Will allow anonymous access
357
- // - Resources with auth: { query: ['authenticated'] } → Will reject with 403 later
358
- //
359
- // IMPORTANT: This hook ALWAYS returns true (never blocks requests)
360
- // Authorization happens later in the checkPermissions hook
361
- addHook('transport:request', 'jwt-populate-auth', {}, async ({ context }) => {
362
- // Token is extracted by transport layer (e.g., Express plugin)
363
- const token = context.request.token;
364
-
365
- if (!token) {
366
- // No token provided - this is fine, anonymous access is allowed
367
- // Individual resources will enforce their own auth requirements
368
- context.auth = null;
369
- return true;
370
- }
371
-
372
- try {
373
- // Step 1: Verify the token signature and claims
374
- const payload = await verifyToken(token, {
375
- secret: config.secret,
376
- publicKey: config.publicKey,
377
- algorithms: config.algorithms,
378
- audience: config.audience,
379
- issuer: config.issuer,
380
- jwksClient: jwksClientInstance
381
- });
382
-
383
- // Step 2: Check if token has been revoked (logout functionality)
384
- if (config.revocation.enabled && payload.jti) {
385
- const isRevoked = await checkRevocation(
386
- payload.jti,
387
- api,
388
- config.revocation,
389
- state.memoryRevocationStore
390
- );
391
-
392
- if (isRevoked) {
393
- // Token has been revoked - treat as anonymous
394
- context.auth = null;
395
- return true;
396
- }
397
- }
398
-
399
- // Step 3: Extract user information from token claims
400
-
401
- /**
402
- * Helper to extract potentially nested values from JWT payload
403
- * Some providers (like Supabase) nest roles/permissions under app_metadata
404
- *
405
- * Examples:
406
- * - 'roles' → payload.roles
407
- * - 'app_metadata.roles' → payload.app_metadata.roles
408
- * - 'user.permissions' → payload.user.permissions
409
- *
410
- * @param {object} payload - JWT payload object
411
- * @param {string} field - Dot-notation path to the field
412
- * @returns {array} - Array of values, or empty array if not found
413
- */
414
- const getRoleValue = (payload, field) => {
415
- const keys = field.split('.');
416
- let value = payload;
417
-
418
- for (const key of keys) {
419
- value = value?.[key];
420
- if (value === undefined) break;
421
- }
422
-
423
- return Array.isArray(value) ? value : [];
424
- };
425
-
426
- // Step 4: Populate context.auth with user information
427
- // This object will be available throughout the request lifecycle
428
- context.auth = {
429
- userId: payload[config.userIdField],
430
- email: payload[config.emailField],
431
- roles: getRoleValue(payload, config.rolesField),
432
- permissions: getRoleValue(payload, config.permissionsField),
433
- token: payload, // Full token payload for advanced use cases
434
- tokenId: payload.jti // JWT ID for revocation
435
- };
436
-
437
- // Step 5: Allow other plugins to react to successful authentication
438
- context.authPayload = payload;
439
- await runHooks('afterAuthentication', context);
440
-
441
- } catch (error) {
442
- // Invalid token - log for debugging but treat as anonymous
443
- // This allows requests to continue with no auth context
444
- //
445
- // Common errors:
446
- // - TokenExpiredError: JWT has expired (exp claim in past)
447
- // - JsonWebTokenError: Invalid signature, malformed token
448
- // - NotBeforeError: Token not active yet (nbf claim in future)
449
- //
450
- // By setting context.auth = null, we allow:
451
- // - Public endpoints to still work
452
- // - Protected endpoints to return proper 403 errors
453
- // - Better error messages from checkPermissions hook
454
- log.debug('Token validation failed:', error.message);
455
- context.auth = null;
456
- }
457
-
458
- // Always return true - authentication is separate from authorization
459
- // Resources will check permissions based on context.auth
460
- return true;
461
- });
462
-
463
- /*
464
- * Summary: Hooks are registered. Every request now:
465
- * 1. Has its JWT validated and context.auth populated
466
- * 2. Has its permissions checked against resource auth rules
467
- * This creates a declarative, centralized auth system.
468
- *
469
- * Example flow with bad token:
470
- * - Client: POST /api/posts with expired Bearer token
471
- * - HOOK 2: Token validation fails, sets context.auth = null
472
- * - REST API: Routes to posts resource, method = 'post'
473
- * - HOOK 3: Checks posts.auth.post = ['authenticated']
474
- * - Since context.auth is null, 'authenticated' checker returns false
475
- * - Result: 403 Forbidden - "Access denied. Required one of: authenticated"
476
- */
477
-
478
- // HOOK 3: Enforce authorization rules
479
- //
480
- // WHEN: Fires AFTER successful routing, BEFORE the actual operation executes
481
- // WHO CALLS IT: The REST API plugin triggers this after determining which resource/method
482
- // EXECUTION ORDER: After transport:request, route matching, but BEFORE data operations
483
- //
484
- // DATA PROVIDED:
485
- // - context: Contains auth (from HOOK 2), method, attributes, request details
486
- // - scope: The resource definition including vars.authRules from HOOK 1
487
- // - scopeName: The resource name (e.g., 'posts', 'users')
488
- //
489
- // FLOW:
490
- // 1. Check if resource has auth rules (from scope.vars.authRules)
491
- // 2. Get rules for current operation (e.g., auth.post = ['authenticated'])
492
- // 3. Evaluate each rule using registered auth checkers
493
- // 4. If ANY rule passes = allow, if ALL fail = throw 403 error
494
- //
495
- // Sequence -100 ensures this runs BEFORE custom permission checks
496
- addHook('checkPermissions', 'declarative-auth-check', { sequence: -100 },
497
- async ({ context, scope, scopeName }) => {
498
- const operation = context.method; // 'post', 'get', 'patch', etc.
499
- const scopeVars = scope?.vars;
500
- const existingRecord = context.minimalRecord;
501
-
502
- const authRules = scopeVars?.authRules;
503
- if (!authRules) return; // No auth rules defined
504
-
505
- const rules = authRules[operation];
506
- if (!rules) {
507
- // No rules for this operation - deny by default
508
- // Example: If posts only defines auth: { query: ['public'] }
509
- // Then POST, PATCH, DELETE will all be denied
510
- throw new Error(`Operation '${operation}' not allowed on resource '${scopeName}'`);
511
- }
512
-
513
- // Check if any rule passes (OR logic)
514
- // Example: auth.patch = ['is_owner', 'admin'] means:
515
- // Allow if user is owner OR user is admin
516
- let passed = false;
517
- let failureReasons = [];
518
-
519
- for (const rule of rules) {
520
- try {
521
- let checker;
522
- let param;
523
-
524
- // Parse rule (e.g., 'has_role:moderator' -> checker='has_role', param='moderator')
525
- if (rule.includes(':')) {
526
- const [checkerName, ...paramParts] = rule.split(':');
527
- checker = state.authCheckers.get(checkerName);
528
- param = paramParts.join(':');
529
- } else {
530
- checker = state.authCheckers.get(rule);
531
- }
532
-
533
- if (!checker) {
534
- throw new Error(`Unknown auth rule: ${rule}`);
535
- }
536
-
537
- // Run the checker
538
- const result = await checker(context, {
539
- existingRecord,
540
- scopeVars,
541
- param
542
- });
543
-
544
- if (result) {
545
- passed = true;
546
- break; // Any rule passing is enough
547
- } else {
548
- failureReasons.push(rule);
549
- }
550
- } catch (error) {
551
- log.error(`Error checking auth rule ${rule}:`, error);
552
- failureReasons.push(`${rule} (error: ${error.message})`);
553
- }
554
- }
555
-
556
- if (!passed) {
557
- const error = new Error(
558
- `Access denied. Required one of: ${rules.join(', ')}. ` +
559
- `Failed checks: ${failureReasons.join(', ')}`
560
- );
561
- error.statusCode = 403;
562
- throw error;
563
- }
564
- }
565
- );
566
-
567
- /* -----------------------------------------------------------------------
568
- * HELPER METHODS
569
- *
570
- * These methods provide programmatic access to auth functionality.
571
- * They can be used in hooks, custom endpoints, or other plugins.
572
- *
573
- * Access via: helpers.auth.methodName()
574
- * ----------------------------------------------------------------------- */
575
-
576
- // Add verifyToken helper for other plugins (like socketio)
577
- // This allows external plugins to verify JWT tokens using our config
578
- helpers.verifyToken = (token) => verifyToken(token, config);
579
-
580
- // Add auth helpers using the standard pattern
581
- helpers.auth = {
582
- /**
583
- * HELPER: requireAuth
584
- * Require user to be authenticated
585
- * Throws 401 error if no valid auth context exists
586
- *
587
- * @example
588
- * // In a custom hook
589
- * helpers.auth.requireAuth(context);
590
- * console.log('User ID:', context.auth.userId);
591
- */
592
- requireAuth(context) {
593
- if (!context.auth?.userId) {
594
- const error = new Error('Authentication required');
595
- error.statusCode = 401;
596
- throw error;
597
- }
598
- return context.auth;
599
- },
600
-
601
- /**
602
- * HELPER: requireRoles
603
- * Require user to have one of the specified roles
604
- * Automatically calls requireAuth first
605
- *
606
- * @example
607
- * // Require admin OR moderator role
608
- * helpers.auth.requireRoles(context, ['admin', 'moderator']);
609
- */
610
- requireRoles(context, requiredRoles) {
611
- this.requireAuth(context);
612
-
613
- const userRoles = context.auth.roles || [];
614
- const hasRole = requiredRoles.some(role => userRoles.includes(role));
615
-
616
- if (!hasRole) {
617
- const error = new Error(`Required role(s): ${requiredRoles.join(', ')}`);
618
- error.statusCode = 403;
619
- throw error;
620
- }
621
-
622
- return context.auth;
623
- },
624
-
625
- /**
626
- * HELPER: requireOwnership
627
- * Require user to own the resource (or be admin)
628
- * Supports multiple formats: direct user ID, resource object, or uses context
629
- *
630
- * @example
631
- * // Check ownership of a fetched record
632
- * const post = await api.resources.posts.get(postId);
633
- * helpers.auth.requireOwnership(context, post);
634
- *
635
- * // Check direct user ID
636
- * helpers.auth.requireOwnership(context, '123');
637
- */
638
- requireOwnership(context, resourceOrUserId) {
639
- this.requireAuth(context);
640
-
641
- let resourceUserId;
642
-
643
- // If second parameter is an object (the record), extract the ownership field
644
- if (typeof resourceOrUserId === 'object' && resourceOrUserId !== null) {
645
- resourceUserId = resourceOrUserId[config.ownershipField];
646
- if (!resourceUserId) {
647
- throw new Error(`Resource does not have ownership field '${config.ownershipField}'`);
648
- }
649
- } else if (resourceOrUserId !== undefined) {
650
- // Direct user ID provided
651
- resourceUserId = resourceOrUserId;
652
- } else {
653
- // No second parameter - try to get from context.existingRecord
654
- if (context.existingRecord) {
655
- resourceUserId = context.existingRecord[config.ownershipField];
656
- if (!resourceUserId) {
657
- throw new Error(`Resource does not have ownership field '${config.ownershipField}'`);
658
- }
659
- } else {
660
- throw new Error('No resource or user ID provided for ownership check');
661
- }
662
- }
663
-
664
- // Check ownership
665
- if (context.auth.userId !== resourceUserId && !context.auth.roles?.includes('admin')) {
666
- const error = new Error('Access denied: you do not own this resource');
667
- error.statusCode = 403;
668
- throw error;
669
- }
670
-
671
- return context.auth;
672
- },
673
-
674
- /**
675
- * HELPER: registerChecker
676
- * Register a custom auth checker for use in declarative rules
677
- *
678
- * @example
679
- * // Register a time-based checker
680
- * helpers.auth.registerChecker('business_hours', (context) => {
681
- * const hour = new Date().getHours();
682
- * return hour >= 9 && hour < 17;
683
- * });
684
- *
685
- * // Use in resource: auth: { post: ['business_hours'] }
686
- */
687
- registerChecker(name, checkerFn) {
688
- state.authCheckers.set(name, checkerFn);
689
- log.debug(`Registered custom auth checker: ${name}`);
690
- },
691
-
692
- /**
693
- * HELPER: checkPermission
694
- * Check if context passes any of the given auth rules
695
- * Returns true if any rule passes, false otherwise
696
- *
697
- * @example
698
- * // Check multiple rules programmatically
699
- * const canEdit = await helpers.auth.checkPermission(
700
- * context,
701
- * ['is_owner', 'admin', 'has_role:editor']
702
- * );
703
- */
704
- async checkPermission(context, rules, options = {}) {
705
- if (!rules || rules.length === 0) return true;
706
-
707
- const { existingRecord, scopeVars } = options;
708
-
709
- for (const rule of rules) {
710
- const [checkerName, param] = rule.split(':');
711
- const checker = state.authCheckers.get(checkerName);
712
-
713
- if (checker && await checker(context, { existingRecord, scopeVars, param })) {
714
- return true;
715
- }
716
- }
717
-
718
- return false;
719
- },
720
-
721
- /**
722
- * HELPER: logout
723
- * Logout the current session by revoking the JWT token
724
- * Requires token to have 'jti' claim for revocation tracking
725
- *
726
- * @example
727
- * // In a custom logout endpoint
728
- * const result = await helpers.auth.logout(context);
729
- * return { message: 'Logged out successfully' };
730
- */
731
- async logout(context) {
732
- if (!context.auth?.token) {
733
- throw new Error('No active session to logout');
734
- }
735
-
736
- const token = context.auth.token;
737
- if (!token.jti) {
738
- throw new Error('Token must have jti claim for revocation');
739
- }
740
-
741
- // Add to revocation store
742
- if (config.revocation.enabled) {
743
- if (config.revocation.storage === 'database') {
744
- await api.resources[config.revocation.tableName].post({
745
- jti: token.jti,
746
- user_id: context.auth.userId,
747
- expires_at: new Date(token.exp * 1000),
748
- revoked_at: new Date()
749
- });
750
- } else {
751
- // Memory storage
752
- state.memoryRevocationStore.set(token.jti, {
753
- userId: context.auth.userId,
754
- expiresAt: token.exp * 1000,
755
- revokedAt: Date.now()
756
- });
757
- }
758
- }
759
-
760
- // Run logout hooks
761
- context.logoutUserId = context.auth.userId;
762
- await runHooks('afterLogout', context);
763
-
764
- return { success: true, message: 'Logged out successfully' };
765
- },
766
-
767
- /**
768
- * HELPER: revokeToken
769
- * Manually revoke a specific token by its JWT ID
770
- * Useful for external auth system integration
771
- *
772
- * @example
773
- * // Revoke a token received from webhook
774
- * await helpers.auth.revokeToken('token-id-123', 'user-456', 1234567890);
775
- */
776
- async revokeToken(jti, userId, expiresAt) {
777
- if (!config.revocation.enabled) {
778
- throw new Error('Token revocation is not enabled');
779
- }
780
-
781
- if (config.revocation.storage === 'database') {
782
- await api.resources[config.revocation.tableName].post({
783
- jti,
784
- user_id: userId,
785
- expires_at: new Date(expiresAt * 1000),
786
- revoked_at: new Date()
787
- });
788
- } else {
789
- state.memoryRevocationStore.set(jti, {
790
- userId,
791
- expiresAt: expiresAt * 1000,
792
- revokedAt: Date.now()
793
- });
794
- }
795
- },
796
-
797
- /**
798
- * HELPER: cleanup
799
- * Clean up all plugin resources
800
- * IMPORTANT: Call this in tests to prevent memory leaks and hanging processes
801
- *
802
- * @example
803
- * // In test teardown
804
- * afterAll(() => helpers.auth.cleanup());
805
- */
806
- cleanup() {
807
- // Clean up all resources
808
- if (state.cleanupJob) {
809
- clearInterval(state.cleanupJob);
810
- state.cleanupJob = null;
811
- }
812
- state.memoryRevocationStore.clear();
813
- state.authCheckers.clear();
814
- }
815
- };
816
-
817
- /* -----------------------------------------------------------------------
818
- * END OF HELPER METHODS
819
- * -----------------------------------------------------------------------
820
- * Summary: Auth helpers are now available for programmatic auth checks.
821
- * Use these in hooks, custom endpoints, or other plugins via helpers.auth.*
822
- * ----------------------------------------------------------------------- */
823
-
824
- /* -----------------------------------------------------------------------
825
- * OPTIONAL ENDPOINTS
826
- *
827
- * The plugin can automatically create REST endpoints for auth operations.
828
- * These are opt-in via configuration.
829
- * ----------------------------------------------------------------------- */
830
-
831
- // Add logout endpoint if configured
832
- if (config.endpoints.logout && api.addRoute) {
833
- // Add as a public route that checks its own auth
834
- await api.addRoute({
835
- method: 'POST',
836
- path: config.endpoints.logout,
837
- handler: async ({ context }) => {
838
- try {
839
- if (!context.auth) {
840
- return {
841
- statusCode: 401,
842
- body: { error: 'Authentication required' }
843
- };
844
- }
845
-
846
- const result = await helpers.auth.logout(context);
847
- return { statusCode: 200, body: result };
848
- } catch (error) {
849
- return {
850
- statusCode: 400,
851
- body: { error: error.message }
852
- };
853
- }
854
- }});
855
-
856
- log.info(`Added logout endpoint: POST ${config.endpoints.logout}`);
857
- }
858
-
859
- // Add session endpoint if configured
860
- // Returns current user info or {authenticated: false} for anonymous
861
- if (config.endpoints.session && api.addRoute) {
862
- await api.addRoute({
863
- method: 'GET',
864
- path: config.endpoints.session,
865
- handler: async ({ context }) => {
866
- if (!context.auth) {
867
- return {
868
- statusCode: 200,
869
- body: { authenticated: false }
870
- };
871
- }
872
-
873
- return {
874
- statusCode: 200,
875
- body: {
876
- authenticated: true,
877
- user: {
878
- id: context.auth.userId,
879
- email: context.auth.email,
880
- roles: context.auth.roles
881
- },
882
- expiresAt: new Date(context.auth.token.exp * 1000).toISOString()
883
- }
884
- };
885
- }});
886
-
887
- log.info(`Added session endpoint: GET ${config.endpoints.session}`);
888
- }
889
-
890
- /*
891
- * Summary: Optional endpoints provide REST API access to auth operations.
892
- * Enable via config: endpoints: { logout: '/auth/logout', session: '/auth/session' }
893
- */
894
-
895
- log.info('JWT authentication plugin installed with declarative auth support');
896
- }
897
- };