update dependencies and devDependencies, address lint issues
[squeep-indie-auther] / src / service.js
1 'use strict';
2
3 /**
4 * Here we extend the base API server to define our routes and any route-specific
5 * behavior (middlewares) before handing off to the manager.
6 */
7
8 const path = require('path');
9 const { Dingus } = require('@squeep/api-dingus');
10 const common = require('./common');
11 const Manager = require('./manager');
12 const { Authenticator, ResourceAuthenticator, SessionManager } = require('@squeep/authentication-module');
13 const { initContext, navLinks } = require('./template/template-helper');
14 const Enum = require('./enum');
15 const { ResponseError } = require('./errors');
16
17 const _fileScope = common.fileScope(__filename);
18
19 /**
20 * @typedef {import('node:http')} http
21 */
22
23 class Service extends Dingus {
24 constructor(logger, db, options, asyncLocalStorage) {
25 super(logger, {
26 ...options.dingus,
27 ignoreTrailingSlash: false,
28 });
29 this.options = options;
30 this.asyncLocalStorage = asyncLocalStorage;
31 this.staticPath = path.normalize(path.join(__dirname, '..', 'static'));
32 this.manager = new Manager(logger, db, options);
33 this.authenticator = new Authenticator(logger, db, options);
34 this.sessionManager = new SessionManager(logger, this.authenticator, options);
35 this.resourceAuthenticator = new ResourceAuthenticator(logger, db, options);
36 this.loginPath = `${options.dingus.proxyPrefix}/admin/login`;
37
38 // N.B. /admin routes not currently configurable
39 const route = (r) => `/${options.route[r]}`; // eslint-disable-line security/detect-object-injection
40
41 // Service discovery
42 this.on(['GET'], route('metadata'), this.handlerGetMeta.bind(this));
43 // Also respond with metadata on well-known oauth2 endpoint if base has no prefix
44 if ((options?.dingus?.selfBaseUrl?.match(/\//g) || []).length === 3) {
45 this.on(['GET'], '/.well-known/oauth-authorization-server', this.handlerGetMeta.bind(this));
46 }
47
48 // Primary endpoints
49 this.on(['GET'], route('authorization'), this.handlerGetAuthorization.bind(this));
50 this.on(['POST'], route('authorization'), this.handlerPostAuthorization.bind(this));
51 this.on(['POST'], route('consent'), this.handlerPostConsent.bind(this));
52 this.on(['POST'], route('revocation'), this.handlerPostRevocation.bind(this));
53 this.on(['POST'], route('ticket'), this.handlerPostTicket.bind(this));
54 this.on(['POST'], route('token'), this.handlerPostToken.bind(this));
55
56 // Resource endpoints
57 this.on('POST', route('introspection'), this.handlerPostIntrospection.bind(this));
58 this.on('POST', route('userinfo'), this.handlerPostUserInfo.bind(this));
59
60 // Information page about service
61 this.on(['GET'], '/', this.handlerGetRoot.bind(this));
62
63 // Temmporary to see what rando payload someone is sending us unsolicited
64 this.on(['POST'], '/', this.handlerWhaGwan.bind(this));
65
66 // Give load-balancers something to check
67 this.on(['GET'], route('healthcheck'), this.handlerGetHealthcheck.bind(this));
68
69 // These routes are intended for accessing static content during development.
70 // In production, a proxy server would likely handle these first.
71 this.on(['GET'], '/static', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/static/`);
72 this.on(['GET'], '/static/', this.handlerGetStaticFile.bind(this), 'index.html');
73 this.on(['GET'], '/static/:file', this.handlerGetStaticFile.bind(this));
74 this.on(['GET'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico');
75 this.on(['GET'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt');
76
77 // Profile and token management for authenticated sessions
78 this.on(['GET'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`);
79 this.on(['GET'], '/admin/', this.handlerGetAdmin.bind(this));
80 this.on(['POST'], '/admin/', this.handlerPostAdmin.bind(this));
81
82 // Ticket-proffering interface for authenticated sessions
83 this.on(['GET'], '/admin/ticket', this.handlerGetAdminTicket.bind(this));
84 this.on(['POST'], '/admin/ticket', this.handlerPostAdminTicket.bind(this));
85
86 // User authentication and session establishment
87 this.on(['GET'], '/admin/login', this.handlerGetAdminLogin.bind(this));
88 this.on(['POST'], '/admin/login', this.handlerPostAdminLogin.bind(this));
89 this.on(['GET'], '/admin/logout', this.handlerGetAdminLogout.bind(this));
90 this.on(['GET'], '/admin/settings', this.handlerGetAdminSettings.bind(this));
91 this.on(['POST'], '/admin/settings', this.handlerPostAdminSettings.bind(this));
92
93 // Page for upkeep info et cetera
94 this.on(['GET'], '/admin/maintenance', this.handlerGetAdminMaintenance.bind(this));
95
96 }
97
98
99 /**
100 * Perform any async startup tasks.
101 */
102 async initialize() {
103 await this.manager.initialize();
104 }
105
106
107 /**
108 * Do a little more on each request.
109 * @param {http.IncomingMessage} req request
110 * @param {http.ServerResponse} res response
111 * @param {object} ctx context
112 */
113 async preHandler(req, res, ctx) {
114 const _scope = _fileScope('preHandler');
115
116 await super.preHandler(req, res, ctx);
117 ctx.url = req.url; // Persist this for logout redirect
118
119 const logObject = this.asyncLocalStorage.getStore();
120 // istanbul ignore else
121 if (logObject) { // Debugging in vscode seems to kill ALS, work around
122 logObject.requestId = ctx.requestId;
123 delete ctx.requestId;
124 } else {
125 this.logger.debug(_scope, 'no async local store');
126 }
127 }
128
129
130 /**
131 * @param {http.IncomingMessage} req request
132 * @param {http.ServerResponse} res response
133 * @param {object} ctx context
134 */
135 async handlerGetAdminLogin(req, res, ctx) {
136 const _scope = _fileScope('handlerGetAdminLogin');
137 this.logger.debug(_scope, 'called', { req, ctx });
138
139 this.setResponseType(this.responseTypes, req, res, ctx);
140
141 await this.authenticator.sessionOptionalLocal(req, res, ctx);
142
143 await this.sessionManager.getAdminLogin(res, ctx, navLinks);
144 }
145
146
147 /**
148 * @param {http.IncomingMessage} req request
149 * @param {http.ServerResponse} res response
150 * @param {object} ctx context
151 */
152 async handlerPostAdminLogin(req, res, ctx) {
153 const _scope = _fileScope('handlerPostAdminLogin');
154 this.logger.debug(_scope, 'called', { req, ctx });
155
156 initContext(ctx);
157
158 this.setResponseType(this.responseTypes, req, res, ctx);
159
160 await this.authenticator.sessionOptionalLocal(req, res, ctx);
161
162 await this.ingestBody(req, res, ctx);
163
164 await this.sessionManager.postAdminLogin(res, ctx, navLinks);
165 }
166
167
168 /**
169 * @param {http.IncomingMessage} req request
170 * @param {http.ServerResponse} res response
171 * @param {object} ctx context
172 */
173 async handlerGetAdminSettings(req, res, ctx) {
174 const _scope = _fileScope('handlerGetAdminSettings');
175 this.logger.debug(_scope, 'called', { req, ctx });
176
177 initContext(ctx);
178
179 this.setResponseType(this.responseTypes, req, res, ctx);
180
181 if (await this.authenticator.sessionRequiredLocal(req, res, ctx)) {
182 await this.sessionManager.getAdminSettings(res, ctx, navLinks);
183 }
184 }
185
186
187 /**
188 * @param {http.IncomingMessage} req request
189 * @param {http.ServerResponse} res response
190 * @param {object} ctx context
191 */
192 async handlerPostAdminSettings(req, res, ctx) {
193 const _scope = _fileScope('handlerPostAdminSettings');
194 this.logger.debug(_scope, 'called', { req, ctx });
195
196 initContext(ctx);
197
198 this.setResponseType(this.responseTypes, req, res, ctx);
199
200 if (await this.authenticator.sessionRequiredLocal(req, res, ctx)) {
201 await this.ingestBody(req, res, ctx);
202 await this.sessionManager.postAdminSettings(res, ctx, navLinks);
203 }
204 }
205
206
207 /**
208 * @param {http.IncomingMessage} req request
209 * @param {http.ServerResponse} res response
210 * @param {object} ctx context
211 */
212 async handlerGetAdminLogout(req, res, ctx) {
213 const _scope = _fileScope('handlerGetAdminLogout');
214 this.logger.debug(_scope, 'called', { req, ctx });
215
216 initContext(ctx);
217
218 this.setResponseType(this.responseTypes, req, res, ctx);
219
220 await this.authenticator.sessionOptionalLocal(req, res, ctx);
221
222 await this.sessionManager.getAdminLogout(res, ctx);
223 }
224
225
226 /**
227 * @param {http.IncomingMessage} req request
228 * @param {http.ServerResponse} res response
229 * @param {object} ctx context
230 */
231 async handlerGetAdmin(req, res, ctx) {
232 const _scope = _fileScope('handlerGetAdmin');
233 this.logger.debug(_scope, 'called', { req, ctx });
234
235 initContext(ctx);
236
237 this.setResponseType(this.responseTypes, req, res, ctx);
238
239 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
240 await this.manager.getAdmin(res, ctx);
241 }
242 }
243
244
245 /**
246 * @param {http.IncomingMessage} req request
247 * @param {http.ServerResponse} res response
248 * @param {object} ctx context
249 */
250 async handlerPostAdmin(req, res, ctx) {
251 const _scope = _fileScope('handlerPostAdmin');
252 this.logger.debug(_scope, 'called', { req, ctx });
253
254 initContext(ctx);
255
256 this.setResponseType(this.responseTypes, req, res, ctx);
257
258 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
259 await this.ingestBody(req, res, ctx);
260 await this.manager.postAdmin(res, ctx);
261 }
262 }
263
264
265 /**
266 * @param {http.IncomingMessage} req request
267 * @param {http.ServerResponse} res response
268 * @param {object} ctx context
269 */
270 async handlerGetAdminTicket(req, res, ctx) {
271 const _scope = _fileScope('handlerGetAdminTicket');
272 this.logger.debug(_scope, 'called', { req, ctx });
273
274 initContext(ctx);
275
276 this.setResponseType(this.responseTypes, req, res, ctx);
277
278 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
279 await this.manager.getAdminTicket(res, ctx);
280 }
281 }
282
283
284 /**
285 * @param {http.IncomingMessage} req request
286 * @param {http.ServerResponse} res response
287 * @param {object} ctx context
288 */
289 async handlerPostAdminTicket(req, res, ctx) {
290 const _scope = _fileScope('handlerPostAdminTicket');
291 this.logger.debug(_scope, 'called', { req, ctx });
292
293 initContext(ctx);
294
295 this.setResponseType(this.responseTypes, req, res, ctx);
296
297 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
298 await this.ingestBody(req, res, ctx);
299 await this.manager.postAdminTicket(res, ctx);
300 }
301 }
302
303
304 /**
305 * @param {http.IncomingMessage} req request
306 * @param {http.ServerResponse} res response
307 * @param {object} ctx context
308 */
309 async handlerGetMeta(req, res, ctx) {
310 const _scope = _fileScope('handlerGetMeta');
311 this.logger.debug(_scope, 'called', { req, ctx });
312
313 const responseTypes = [
314 Enum.ContentType.ApplicationJson,
315 Enum.ContentType.TextPlain,
316 ];
317
318 this.setResponseType(responseTypes, req, res, ctx);
319
320 await this.authenticator.sessionOptionalLocal(req, res, ctx);
321
322 await this.manager.getMeta(res, ctx);
323 }
324
325
326 /**
327 * @param {http.IncomingMessage} req request
328 * @param {http.ServerResponse} res response
329 * @param {object} ctx context
330 */
331 async handlerGetAuthorization(req, res, ctx) {
332 const _scope = _fileScope('handlerGetAuthorization');
333 this.logger.debug(_scope, 'called', { req, ctx });
334
335 initContext(ctx);
336
337 this.setResponseType(this.responseTypes, req, res, ctx);
338
339 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
340 await this.manager.getAuthorization(res, ctx);
341 }
342 }
343
344
345 /**
346 * @param {http.IncomingMessage} req request
347 * @param {http.ServerResponse} res response
348 * @param {object} ctx context
349 */
350 async handlerPostAuthorization(req, res, ctx) {
351 const _scope = _fileScope('handlerPostAuthorization');
352 this.logger.debug(_scope, 'called', { req, ctx });
353
354 const responseTypes = [
355 Enum.ContentType.ApplicationJson,
356 Enum.ContentType.TextPlain,
357 ];
358
359 this.setResponseType(responseTypes, req, res, ctx);
360
361 await this.authenticator.sessionOptionalLocal(req, res, ctx);
362
363 await this.ingestBody(req, res, ctx);
364
365 await this.manager.postAuthorization(res, ctx);
366 }
367
368
369 /**
370 * @param {http.IncomingMessage} req request
371 * @param {http.ServerResponse} res response
372 * @param {object} ctx context
373 */
374 async handlerPostConsent(req, res, ctx) {
375 const _scope = _fileScope('handlerPostConsent');
376 this.logger.debug(_scope, 'called', { req, ctx });
377
378 initContext(ctx);
379
380 this.setResponseType(this.responseTypes, req, res, ctx);
381
382 // This isn't specified as required as any valid payload carries intrinsic auth data.
383 await this.authenticator.sessionOptionalLocal(req, res, ctx);
384
385 await this.ingestBody(req, res, ctx);
386
387 await this.manager.postConsent(res, ctx);
388 }
389
390
391 /**
392 * @param {http.IncomingMessage} req request
393 * @param {http.ServerResponse} res response
394 * @param {object} ctx context
395 */
396 async handlerPostTicket(req, res, ctx) {
397 const _scope = _fileScope('handlerPostTicket');
398 this.logger.debug(_scope, 'called', { req, ctx });
399
400 const responseTypes = [
401 Enum.ContentType.ApplicationJson,
402 Enum.ContentType.TextPlain,
403 ];
404
405 this.setResponseType(responseTypes, req, res, ctx);
406
407 await this.ingestBody(req, res, ctx);
408
409 await this.manager.postTicket(req, res, ctx);
410 }
411
412
413 /**
414 * @param {http.IncomingMessage} req request
415 * @param {http.ServerResponse} res response
416 * @param {object} ctx context
417 */
418 async handlerPostToken(req, res, ctx) {
419 const _scope = _fileScope('handlerPostToken');
420 this.logger.debug(_scope, 'called', { req, ctx });
421
422 const responseTypes = [
423 Enum.ContentType.ApplicationJson,
424 Enum.ContentType.TextPlain,
425 ];
426
427 this.setResponseType(responseTypes, req, res, ctx);
428
429 await this.ingestBody(req, res, ctx);
430
431 await this.manager.postToken(req, res, ctx);
432 }
433
434
435 /**
436 * @param {http.IncomingMessage} req request
437 * @param {http.ServerResponse} res response
438 * @param {object} ctx context
439 */
440 async handlerPostRevocation(req, res, ctx) {
441 const _scope = _fileScope('handlerPostRevocation');
442 this.logger.debug(_scope, 'called', { req, ctx });
443
444 const responseTypes = [
445 Enum.ContentType.ApplicationJson,
446 Enum.ContentType.TextPlain,
447 ];
448
449 this.setResponseType(responseTypes, req, res, ctx);
450
451 await this.ingestBody(req, res, ctx);
452
453 await this.manager.postRevocation(req, res, ctx);
454 }
455
456
457 /**
458 * @param {http.IncomingMessage} req request
459 * @param {http.ServerResponse} res response
460 * @param {object} ctx context
461 */
462 async handlerPostIntrospection(req, res, ctx) {
463 const _scope = _fileScope('handlerPostIntrospection');
464 this.logger.debug(_scope, 'called', { req, ctx });
465
466 const responseTypes = [
467 Enum.ContentType.ApplicationJson,
468 Enum.ContentType.TextPlain,
469 ];
470
471 await this.resourceAuthenticator.required(req, res, ctx);
472
473 this.setResponseType(responseTypes, req, res, ctx);
474
475 await this.ingestBody(req, res, ctx);
476
477 await this.manager.postIntrospection(res, ctx);
478 }
479
480
481 /**
482 * @param {http.IncomingMessage} req request
483 * @param {http.ServerResponse} res response
484 * @param {object} ctx context
485 */
486 async handlerPostUserInfo(req, res, ctx) {
487 const _scope = _fileScope('handlerPostUserInfo');
488 this.logger.debug(_scope, 'called', { req, ctx });
489
490 const responseTypes = [
491 Enum.ContentType.ApplicationJson,
492 Enum.ContentType.TextPlain,
493 ];
494
495 this.setResponseType(responseTypes, req, res, ctx);
496
497 await this.ingestBody(req, res, ctx);
498
499 await this.manager.postUserInfo(res, ctx);
500 }
501
502
503 /**
504 * @param {http.IncomingMessage} req request
505 * @param {http.ServerResponse} res response
506 * @param {object} ctx context
507 */
508 async handlerGetRoot(req, res, ctx) {
509 const _scope = _fileScope('handlerGetRoot');
510 const responseTypes = [
511 Enum.ContentType.TextHTML,
512 ];
513 this.logger.debug(_scope, 'called', { req, ctx });
514
515 initContext(ctx);
516
517 this.setResponseType(responseTypes, req, res, ctx);
518
519 await this.authenticator.sessionOptionalLocal(req, res, ctx);
520
521 await this.manager.getRoot(res, ctx);
522 }
523
524
525 /**
526 * Temporary to see what an unsolicited payload contains.
527 * @param {http.IncomingMessage} req request
528 * @param {http.ServerResponse} res response
529 * @param {object} ctx context
530 */
531 async handlerWhaGwan(req, res, ctx) {
532 this.setResponseType(this.responseTypes, req, res, ctx);
533 await this.ingestBody(req, res, ctx);
534 throw new ResponseError(Enum.ErrorResponse.MethodNotAllowed);
535 }
536
537 /**
538 * @param {http.IncomingMessage} req request
539 * @param {http.ServerResponse} res response
540 * @param {object} ctx context
541 */
542 async handlerGetHealthcheck(req, res, ctx) {
543 const _scope = _fileScope('handlerGetHealthcheck');
544 this.logger.debug(_scope, 'called', { req, ctx });
545
546 this.setResponseType(this.responseTypes, req, res, ctx);
547
548 await this.manager.getHealthcheck(res, ctx);
549 }
550
551
552 /**
553 * @param {http.IncomingMessage} req request
554 * @param {http.ServerResponse} res response
555 * @param {object} ctx context
556 */
557 async handlerGetAdminMaintenance(req, res, ctx) {
558 const _scope = _fileScope('handlerGetAdminMaintenance');
559 this.logger.debug(_scope, 'called', { req, ctx });
560
561 initContext(ctx);
562
563 this.setResponseType(this.responseTypes, req, res, ctx);
564
565 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
566 await this.manager.getAdminMaintenance(res, ctx);
567 }
568 }
569
570
571 /**
572 * FIXME: This doesn't seem to be working as envisioned. Maybe override render error method instead???
573 * Intercept this and redirect if we have enough information, otherwise default to framework.
574 * Fixing this will likely have to wait until an e2e test framework is in place.
575 * The redirect attempt should probably be contained in a Manager method, but here it is for now.
576 * @param {http.IncomingMessage} req request
577 * @param {http.ServerResponse} res response
578 * @param {object} ctx context
579 */
580 async handlerInternalServerError(req, res, ctx) {
581 const _scope = _fileScope('handlerInternalServerError');
582 this.logger.debug(_scope, 'called', { req, ctx });
583
584 if (ctx?.session?.redirectUri && ctx?.session?.clientIdentifier) {
585 Object.entries({
586 ...(ctx.session.state && { 'state': ctx.session.state }),
587 'error': 'server_error',
588 'error_description': 'An internal server error occurred',
589 }).forEach(([name, value]) => ctx.session.redirectUri.searchParams.set(name, value));
590 res.statusCode = 302; // Found
591 res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
592 res.end();
593 return;
594 }
595
596 await super.handlerInternalServerError(req, res, ctx);
597 }
598
599
600 }
601
602 module.exports = Service;
603