proteum 2.1.1 → 2.1.3-1

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.
@@ -25,12 +25,6 @@ import UsersRequestService from './request';
25
25
  - TYPES
26
26
  ----------------------------------*/
27
27
 
28
- /*----------------------------------
29
- - CONFIG
30
- ----------------------------------*/
31
-
32
- const LogPrefix = '[router][auth]';
33
-
34
28
  /*----------------------------------
35
29
  - SERVICE
36
30
  ----------------------------------*/
@@ -59,11 +53,32 @@ export default class AuthenticationRouterService<
59
53
  this.users = this.config.users;
60
54
  }
61
55
 
56
+ private traceRouteAuth(
57
+ request: TRequest,
58
+ route: TAnyRoute,
59
+ details: Record<string, any>,
60
+ minimumCapture: 'summary' | 'resolve' | 'deep' = 'resolve',
61
+ ) {
62
+ this.app.container.Trace.record(
63
+ request.id,
64
+ 'auth.route',
65
+ {
66
+ routePath: route.path || '',
67
+ routeId: route.options.id || '',
68
+ authInput: route.options.auth ?? null,
69
+ tracking: route.options.authTracking ?? null,
70
+ redirectLogged: route.options.redirectLogged ?? null,
71
+ ...details,
72
+ },
73
+ minimumCapture,
74
+ );
75
+ }
76
+
62
77
  public async ready() {
63
78
  // Decode current user
64
79
  this.parent.on('request', async (request: TRequest) => {
65
80
  // TODO: Typings. (context.user ?)
66
- const decoded = await this.users.decode(request.req, true);
81
+ const decoded = await this.users.decode(request.req, true, request.id);
67
82
 
68
83
  request.user = decoded || null;
69
84
  });
@@ -72,10 +87,48 @@ export default class AuthenticationRouterService<
72
87
  this.parent.on('resolved', async (route: TAnyRoute, request: TRequest, response: ServerResponse<TRouter>) => {
73
88
  if (route.options.auth !== undefined) {
74
89
  const tracking = route.options.authTracking ?? null;
90
+ const strategy =
91
+ route.options.auth === false
92
+ ? 'guest-only'
93
+ : route.options.auth === null
94
+ ? 'authenticated'
95
+ : typeof route.options.auth === 'object'
96
+ ? 'conditions'
97
+ : route.options.auth === true
98
+ ? tracking !== null && this.users.config.rules
99
+ ? 'user-via-rules'
100
+ : 'user'
101
+ : tracking !== null && this.users.config.rules
102
+ ? 'role-via-rules'
103
+ : 'role';
104
+
105
+ this.traceRouteAuth(
106
+ request,
107
+ route,
108
+ {
109
+ phase: 'start',
110
+ strategy,
111
+ },
112
+ 'resolve',
113
+ );
75
114
 
76
115
  // Guest-only routes can still redirect authenticated users away.
77
116
  if (route.options.auth === false) {
78
117
  const currentUser = this.users.check(request, false, tracking);
118
+ const redirected = Boolean(route.options.redirectLogged && currentUser);
119
+
120
+ this.traceRouteAuth(
121
+ request,
122
+ route,
123
+ {
124
+ phase: 'result',
125
+ strategy,
126
+ outcome: redirected ? 'redirected' : 'allowed',
127
+ userPresent: currentUser !== null,
128
+ redirectTo: redirected ? route.options.redirectLogged : null,
129
+ },
130
+ 'resolve',
131
+ );
79
132
 
80
133
  if (route.options.redirectLogged && currentUser) response.redirect(route.options.redirectLogged);
81
134
  return;
@@ -83,11 +136,13 @@ export default class AuthenticationRouterService<
83
136
 
84
137
  if (route.options.auth === null) {
85
138
  this.users.check(request, null, tracking);
139
+ this.traceRouteAuth(request, route, { phase: 'result', strategy, outcome: 'allowed' }, 'resolve');
86
140
  return;
87
141
  }
88
142
 
89
143
  if (typeof route.options.auth === 'object') {
90
144
  this.users.check(request, route.options.auth as TAuthCheckConditions, tracking);
145
+ this.traceRouteAuth(request, route, { phase: 'result', strategy, outcome: 'allowed' }, 'resolve');
91
146
  return;
92
147
  }
93
148
 
@@ -95,10 +150,32 @@ export default class AuthenticationRouterService<
95
150
  if (route.options.auth === true) {
96
151
  if (tracking !== null && this.users.config.rules) {
97
152
  this.users.check(request, { role: 'USER' }, tracking);
153
+ this.traceRouteAuth(
154
+ request,
155
+ route,
156
+ {
157
+ phase: 'result',
158
+ strategy,
159
+ outcome: 'allowed',
160
+ requiredRole: 'USER',
161
+ },
162
+ 'resolve',
163
+ );
98
164
  return;
99
165
  }
100
166
 
101
167
  this.users.check(request, true);
168
+ this.traceRouteAuth(
169
+ request,
170
+ route,
171
+ {
172
+ phase: 'result',
173
+ strategy,
174
+ outcome: 'allowed',
175
+ requiredRole: 'USER',
176
+ },
177
+ 'resolve',
178
+ );
102
179
  return;
103
180
  }
104
181
 
@@ -106,10 +183,32 @@ export default class AuthenticationRouterService<
106
183
 
107
184
  if (tracking !== null && this.users.config.rules) {
108
185
  this.users.check(request, { role: requiredRole }, tracking);
186
+ this.traceRouteAuth(
187
+ request,
188
+ route,
189
+ {
190
+ phase: 'result',
191
+ strategy,
192
+ outcome: 'allowed',
193
+ requiredRole,
194
+ },
195
+ 'resolve',
196
+ );
109
197
  return;
110
198
  }
111
199
 
112
200
  this.users.check(request, requiredRole);
201
+ this.traceRouteAuth(
202
+ request,
203
+ route,
204
+ {
205
+ phase: 'result',
206
+ strategy,
207
+ outcome: 'allowed',
208
+ requiredRole,
209
+ },
210
+ 'resolve',
211
+ );
113
212
  }
114
213
  });
115
214
  }
@@ -17,13 +17,14 @@ import helmet from 'helmet'; // Diverses protections
17
17
  import compression from 'compression';
18
18
  import fileUpload from 'express-fileupload';
19
19
  import cookieParser from 'cookie-parser';
20
- import * as csp from 'express-csp-header';
21
20
 
22
21
  // Core
23
22
  import Container from '@server/app/container';
24
23
  import type CronManager from '@server/services/cron';
25
24
  import type CronTask from '@server/services/cron/CronTask';
25
+ import type { TBasicUser } from '@server/services/auth';
26
26
  import type { TServerRouter } from '..';
27
+ import type { TDevSessionStartResponse, TDevSessionUserSummary } from '@common/dev/session';
27
28
  import { serverHotReloadMessageType } from '@common/dev/serverHotReload';
28
29
  import { explainSectionNames } from '@common/dev/diagnostics';
29
30
 
@@ -53,6 +54,36 @@ export type Config = {
53
54
 
54
55
  export type Hooks = {};
55
56
 
57
+ type TContentSecurityPolicyOptions = NonNullable<Parameters<typeof helmet.contentSecurityPolicy>[0]>;
58
+ type TContentSecurityPolicyDirectives = NonNullable<TContentSecurityPolicyOptions['directives']>;
59
+ type TDevSessionAuthService = {
60
+ config: {
61
+ jwt: {
62
+ expiration: number;
63
+ };
64
+ };
65
+ createSession: (session: { email: string }, request: { id: string; res: express.Response }) => string;
66
+ decodeSession: (session: { email: string }, req: express.Request) => Promise<TBasicUser | null>;
67
+ };
68
+
69
+ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPolicyOptions => {
70
+ const directives: TContentSecurityPolicyDirectives = {
71
+ defaultSrc:
72
+ config.default && config.default.length > 0
73
+ ? [...config.default]
74
+ : helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
75
+ scriptSrc: ["'unsafe-inline'", "'self'", "'unsafe-eval'", ...config.scripts],
76
+ };
77
+
78
+ if (config.styles && config.styles.length > 0) directives.styleSrc = [...config.styles];
79
+ if (config.images && config.images.length > 0) directives.imgSrc = [...config.images];
80
+
81
+ return {
82
+ useDefaults: false,
83
+ directives,
84
+ };
85
+ };
86
+
56
87
  /*----------------------------------
57
88
  - FUNCTION
58
89
  ----------------------------------*/
@@ -81,6 +112,32 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
81
112
  this.app.on('cleanup', () => this.cleanup());
82
113
  }
83
114
 
115
+ private resolveDevSessionAuthService(): TDevSessionAuthService {
116
+ const plugins = Object.values(this.router.config.plugins || {}) as Array<{ users?: unknown }>;
117
+
118
+ for (const plugin of plugins) {
119
+ const users = plugin?.users as Partial<TDevSessionAuthService> | undefined;
120
+ if (!users) continue;
121
+ if (typeof users.createSession !== 'function') continue;
122
+ if (typeof users.decodeSession !== 'function') continue;
123
+ if (typeof users.config?.jwt?.expiration !== 'number') continue;
124
+
125
+ return users as TDevSessionAuthService;
126
+ }
127
+
128
+ throw new Error('No auth router plugin with a compatible users service is registered.');
129
+ }
130
+
131
+ private summarizeDevSessionUser(user: TBasicUser): TDevSessionUserSummary {
132
+ return {
133
+ email: user.email,
134
+ name: user.name,
135
+ type: user.type,
136
+ roles: [...user.roles],
137
+ locale: user.locale ?? null,
138
+ };
139
+ }
140
+
84
141
  /*----------------------------------
85
142
  - HOOKS
86
143
  ----------------------------------*/
@@ -203,11 +260,7 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
203
260
 
204
261
  if (this.config.cors !== undefined) routes.use(cors(this.config.cors));
205
262
 
206
- routes.use(
207
- csp.expressCspHeader({
208
- directives: { 'script-src': [csp.INLINE, csp.SELF, csp.UNSAFE_EVAL, ...this.config.csp.scripts] },
209
- }),
210
- );
263
+ routes.use(helmet.contentSecurityPolicy(createContentSecurityPolicy(this.config.csp)));
211
264
 
212
265
  this.registerDevTraceRoutes(routes);
213
266
  routes.use(routeRequest);
@@ -342,6 +395,55 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
342
395
  }
343
396
  });
344
397
 
398
+ routes.post('/__proteum/session/start', async (req, res) => {
399
+ const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '';
400
+ const requiredRole = typeof req.body?.role === 'string' ? req.body.role.trim() : '';
401
+
402
+ if (!email) {
403
+ res.status(400).json({ error: 'Email is required.' });
404
+ return;
405
+ }
406
+
407
+ try {
408
+ const auth = this.resolveDevSessionAuthService();
409
+ const user = await auth.decodeSession({ email }, req);
410
+
411
+ if (!user) {
412
+ res.status(404).json({ error: `No user could be resolved for "${email}".` });
413
+ return;
414
+ }
415
+
416
+ if (requiredRole && !user.roles.includes(requiredRole)) {
417
+ res.status(403).json({ error: `User "${email}" does not have required role "${requiredRole}".` });
418
+ return;
419
+ }
420
+
421
+ const token = auth.createSession(
422
+ { email },
423
+ {
424
+ id: `proteum-session:${Date.now()}`,
425
+ res,
426
+ },
427
+ );
428
+ const issuedAt = new Date().toISOString();
429
+ const expiresAt = new Date(Date.now() + auth.config.jwt.expiration).toISOString();
430
+ const response: TDevSessionStartResponse = {
431
+ user: this.summarizeDevSessionUser(user),
432
+ session: {
433
+ token,
434
+ cookieName: 'authorization',
435
+ expiresInMs: auth.config.jwt.expiration,
436
+ issuedAt,
437
+ expiresAt,
438
+ },
439
+ };
440
+
441
+ res.json(response);
442
+ } catch (error) {
443
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
444
+ }
445
+ });
446
+
345
447
  routes.post('/__proteum/cron/tasks/run', async (req, res) => {
346
448
  const cron = this.getCronManager();
347
449
  if (!cron) {
@@ -338,6 +338,7 @@ export default class ServerResponse<
338
338
  // NOTE: On évite le filtrage sans masque spécifié (performances + risques erreurs)
339
339
  if (mask !== undefined) data = await jsonMask(data, mask);
340
340
 
341
+ this.app.container.Trace.setRequestResult(this.request.id, data);
341
342
  this.headers['Content-Type'] = 'application/json';
342
343
  this.data = (this.request.isVirtual ? data : JSON.stringify(data)) as TData;
343
344
  return this.end();