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