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