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.
- package/.claude/settings.local.json +9 -1
- package/AGENTS.md +34 -0
- package/CLAUDE.md +26 -1
- package/CRUSH.md +55 -0
- package/README.md +3 -3
- package/TODO.todo +8 -0
- package/docs/API.md +17 -18
- package/docs/COMPARISON.md +18 -13
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
- package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
- package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
- package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
- package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
- package/docs/GUIDE/index.md +3 -0
- package/docs/QUICKSTART.md +12 -12
- package/docs/index.md +2 -2
- package/docs/mini-guides/file-uploads-complete.md +0 -1
- package/examples/url-override-example.js +137 -0
- package/index.js +7 -1
- package/package.json +7 -11
- package/plugins/core/bulk-operations-plugin.js +2 -0
- package/plugins/core/connectors/express-plugin.js +43 -24
- package/plugins/core/connectors/lib/request-helpers.js +2 -2
- package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
- package/plugins/core/lib/anyapi/schema-utils.js +186 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
- package/plugins/core/lib/dbTablesOperations.js +305 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
- package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
- package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
- package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
- package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
- package/plugins/core/lib/querying/url-helpers.js +119 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
- package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
- package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
- package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
- package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
- package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
- package/plugins/core/lib/storage/storage-adapter.js +160 -0
- package/plugins/core/lib/writing/relationship-processor.js +4 -11
- package/plugins/core/rest-api-access.js +456 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
- package/plugins/core/rest-api-knex-plugin.js +226 -87
- package/plugins/core/rest-api-multihome-plugin.js +89 -27
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
- package/plugins/core/rest-api-plugin-methods/common.js +46 -10
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
- package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
- package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
- package/plugins/core/rest-api-plugin-methods/get.js +2 -5
- package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
- package/plugins/core/rest-api-plugin-methods/post.js +23 -11
- package/plugins/core/rest-api-plugin-methods/put.js +18 -21
- package/plugins/core/rest-api-plugin-methods/query.js +3 -4
- package/plugins/core/rest-api-plugin-methods/release.js +13 -0
- package/plugins/core/rest-api-plugin.js +3 -4
- package/plugins/core/rest-api-positioning-plugin.js +253 -76
- package/plugins/core/socketio-plugin.js +436 -502
- package/quickTest.js +2 -2
- package/scripts/debug-anyapi-review-filter.mjs +63 -0
- package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
- package/scripts/inspect-anyapi-descriptor.mjs +19 -0
- package/test-debug.js +59 -0
- package/tests/README.md +7 -1
- package/tests/anyapi-basic.test.js +102 -0
- package/tests/anyapi-cursor-pagination.test.js +144 -0
- package/tests/computed-fields.test.js +27 -12
- package/tests/cors.test.js +5 -6
- package/tests/curl.test.js +1 -1
- package/tests/custom-idproperty-relationships.test.js +37 -29
- package/tests/field-getters.test.js +25 -7
- package/tests/fixtures/api-configs.js +576 -150
- package/tests/helpers/storage-mode.js +65 -0
- package/tests/helpers/test-utils.js +85 -3
- package/tests/include-depth-validation.test.js +35 -1
- package/tests/nested-includes.test.js +130 -103
- package/tests/pagination-cursor-multifield.test.js +17 -13
- package/tests/pagination-enhanced.test.js +19 -24
- package/tests/polymorphic-simple.test.js +9 -9
- package/tests/positioning-advanced.test.js +19 -19
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +62 -62
- package/tests/positioning.test.js +7 -7
- package/tests/queries.test.js +5 -5
- package/tests/query-limits.test.js +77 -92
- package/tests/rest-api-access.test.js +206 -0
- package/tests/searchschema-merge.test.js +182 -0
- package/tests/socketio.test.js +33 -14
- package/tests/virtual-fields.test.js +0 -1
- package/docs/DOCS_ALL.md +0 -21894
- package/plugins/core/jwt-auth-plugin.js +0 -897
- package/plugins/core/lib/jwt-auth-helpers.js +0 -130
- 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
|
-
};
|