initial commit
[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) {
21 super(logger, {
22 ...options.dingus,
23 ignoreTrailingSlash: false,
24 });
25
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', 'HEAD'], 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', 'HEAD'], '/.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', 'HEAD'], '/', this.handlerGetRoot.bind(this));
57
58 // Give load-balancers something to check
59 this.on(['GET', 'HEAD'], 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', 'HEAD'], '/static', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/static/`);
64 this.on(['GET', 'HEAD'], '/static/', this.handlerGetStaticFile.bind(this), 'index.html');
65 this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStaticFile.bind(this));
66 this.on(['GET', 'HEAD'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico');
67 this.on(['GET', 'HEAD'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt');
68
69 // Profile and token management for authenticated sessions
70 this.on(['GET', 'HEAD'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`);
71 this.on(['GET', 'HEAD'], '/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', 'HEAD'], '/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', 'HEAD'], '/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', 'HEAD'], '/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 await super.preHandler(req, res, ctx);
105 ctx.url = req.url; // Persist this for logout redirect
106 }
107
108
109 /**
110 * @param {http.IncomingMessage} req
111 * @param {http.ServerResponse} res
112 * @param {Object} ctx
113 */
114 async handlerGetAdminLogin(req, res, ctx) {
115 const _scope = _fileScope('handlerGetAdminLogin');
116 this.logger.debug(_scope, 'called', { req, ctx });
117
118 Dingus.setHeadHandler(req, res, ctx);
119
120 this.setResponseType(this.responseTypes, req, res, ctx);
121
122 await this.sessionManager.getAdminLogin(res, ctx);
123 }
124
125
126 /**
127 * @param {http.IncomingMessage} req
128 * @param {http.ServerResponse} res
129 * @param {Object} ctx
130 */
131 async handlerPostAdminLogin(req, res, ctx) {
132 const _scope = _fileScope('handlerPostAdminLogin');
133 this.logger.debug(_scope, 'called', { req, ctx });
134
135 initContext(ctx);
136
137 this.setResponseType(this.responseTypes, req, res, ctx);
138
139 await this.authenticator.sessionOptionalLocal(req, res, ctx);
140
141 await this.ingestBody(req, res, ctx);
142
143 await this.sessionManager.postAdminLogin(res, ctx);
144 }
145
146
147 /**
148 * @param {http.IncomingMessage} req
149 * @param {http.ServerResponse} res
150 * @param {Object} ctx
151 */
152 async handlerGetAdminLogout(req, res, ctx) {
153 const _scope = _fileScope('handlerGetAdminLogout');
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.sessionManager.getAdminLogout(res, ctx);
163 }
164
165
166 /**
167 * @param {http.IncomingMessage} req
168 * @param {http.ServerResponse} res
169 * @param {Object} ctx
170 */
171 async handlerGetAdmin(req, res, ctx) {
172 const _scope = _fileScope('handlerGetAdmin');
173 this.logger.debug(_scope, 'called', { req, ctx });
174
175 initContext(ctx);
176
177 Dingus.setHeadHandler(req, res, ctx);
178
179 this.setResponseType(this.responseTypes, req, res, ctx);
180
181 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
182 await this.manager.getAdmin(res, ctx);
183 }
184 }
185
186
187 /**
188 * @param {http.IncomingMessage} req
189 * @param {http.ServerResponse} res
190 * @param {Object} ctx
191 */
192 async handlerPostAdmin(req, res, ctx) {
193 const _scope = _fileScope('handlerPostAdmin');
194 this.logger.debug(_scope, 'called', { req, ctx });
195
196 initContext(ctx);
197
198 Dingus.setHeadHandler(req, res, ctx);
199
200 this.setResponseType(this.responseTypes, req, res, ctx);
201
202 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
203 await this.ingestBody(req, res, ctx);
204 await this.manager.postAdmin(res, ctx);
205 }
206 }
207
208
209 /**
210 * @param {http.IncomingMessage} req
211 * @param {http.ServerResponse} res
212 * @param {Object} ctx
213 */
214 async handlerGetAdminTicket(req, res, ctx) {
215 const _scope = _fileScope('handlerGetAdminTicket');
216 this.logger.debug(_scope, 'called', { req, ctx });
217
218 initContext(ctx);
219
220 Dingus.setHeadHandler(req, res, ctx);
221
222 this.setResponseType(this.responseTypes, req, res, ctx);
223
224 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
225 await this.manager.getAdminTicket(res, ctx);
226 }
227 }
228
229
230 /**
231 * @param {http.IncomingMessage} req
232 * @param {http.ServerResponse} res
233 * @param {Object} ctx
234 */
235 async handlerPostAdminTicket(req, res, ctx) {
236 const _scope = _fileScope('handlerPostAdminTicket');
237 this.logger.debug(_scope, 'called', { req, ctx });
238
239 initContext(ctx);
240
241 this.setResponseType(this.responseTypes, req, res, ctx);
242
243 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
244 await this.ingestBody(req, res, ctx);
245 await this.manager.postAdminTicket(res, ctx);
246 }
247 }
248
249
250 /**
251 * @param {http.IncomingMessage} req
252 * @param {http.ServerResponse} res
253 * @param {Object} ctx
254 */
255 async handlerGetMeta(req, res, ctx) {
256 const _scope = _fileScope('handlerGetMeta');
257 this.logger.debug(_scope, 'called', { req, ctx });
258
259 const responseTypes = [
260 Enum.ContentType.ApplicationJson,
261 Enum.ContentType.TextPlain,
262 ];
263
264 Dingus.setHeadHandler(req, res, ctx);
265
266 this.setResponseType(responseTypes, req, res, ctx);
267
268 await this.authenticator.sessionOptionalLocal(req, res, ctx);
269
270 await this.manager.getMeta(res, ctx);
271 }
272
273
274 /**
275 * @param {http.IncomingMessage} req
276 * @param {http.ServerResponse} res
277 * @param {Object} ctx
278 */
279 async handlerGetAuthorization(req, res, ctx) {
280 const _scope = _fileScope('handlerGetAuthorization');
281 this.logger.debug(_scope, 'called', { req, ctx });
282
283 initContext(ctx);
284
285 this.setResponseType(this.responseTypes, req, res, ctx);
286
287 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
288 await this.manager.getAuthorization(res, ctx);
289 }
290 }
291
292
293 /**
294 * @param {http.IncomingMessage} req
295 * @param {http.ServerResponse} res
296 * @param {Object} ctx
297 */
298 async handlerPostAuthorization(req, res, ctx) {
299 const _scope = _fileScope('handlerPostAuthorization');
300 this.logger.debug(_scope, 'called', { req, ctx });
301
302 const responseTypes = [
303 Enum.ContentType.ApplicationJson,
304 Enum.ContentType.TextPlain,
305 ];
306
307 this.setResponseType(responseTypes, req, res, ctx);
308
309 await this.authenticator.sessionOptionalLocal(req, res, ctx);
310
311 await this.ingestBody(req, res, ctx);
312
313 await this.manager.postAuthorization(res, ctx);
314 }
315
316
317 /**
318 * @param {http.IncomingMessage} req
319 * @param {http.ServerResponse} res
320 * @param {Object} ctx
321 */
322 async handlerPostConsent(req, res, ctx) {
323 const _scope = _fileScope('handlerPostConsent');
324 this.logger.debug(_scope, 'called', { req, ctx });
325
326 initContext(ctx);
327
328 this.setResponseType(this.responseTypes, req, res, ctx);
329
330 // This isn't specified as required as any valid payload carries intrinsic auth data.
331 await this.authenticator.sessionOptionalLocal(req, res, ctx);
332
333 await this.ingestBody(req, res, ctx);
334
335 await this.manager.postConsent(res, ctx);
336 }
337
338
339 /**
340 * @param {http.IncomingMessage} req
341 * @param {http.ServerResponse} res
342 * @param {Object} ctx
343 */
344 async handlerPostTicket(req, res, ctx) {
345 const _scope = _fileScope('handlerPostTicket');
346 this.logger.debug(_scope, 'called', { req, ctx });
347
348 const responseTypes = [
349 Enum.ContentType.ApplicationJson,
350 Enum.ContentType.TextPlain,
351 ];
352
353 this.setResponseType(responseTypes, req, res, ctx);
354
355 await this.ingestBody(req, res, ctx);
356
357 await this.manager.postTicket(req, res, ctx);
358 }
359
360
361 /**
362 * @param {http.IncomingMessage} req
363 * @param {http.ServerResponse} res
364 * @param {Object} ctx
365 */
366 async handlerPostToken(req, res, ctx) {
367 const _scope = _fileScope('handlerPostToken');
368 this.logger.debug(_scope, 'called', { req, ctx });
369
370 const responseTypes = [
371 Enum.ContentType.ApplicationJson,
372 Enum.ContentType.TextPlain,
373 ];
374
375 this.setResponseType(responseTypes, req, res, ctx);
376
377 await this.ingestBody(req, res, ctx);
378
379 await this.manager.postToken(req, res, ctx);
380 }
381
382
383 /**
384 * @param {http.IncomingMessage} req
385 * @param {http.ServerResponse} res
386 * @param {Object} ctx
387 */
388 async handlerPostRevocation(req, res, ctx) {
389 const _scope = _fileScope('handlerPostRevocation');
390 this.logger.debug(_scope, 'called', { req, ctx });
391
392 const responseTypes = [
393 Enum.ContentType.ApplicationJson,
394 Enum.ContentType.TextPlain,
395 ];
396
397 this.setResponseType(responseTypes, req, res, ctx);
398
399 await this.ingestBody(req, res, ctx);
400
401 await this.manager.postRevocation(req, res, ctx);
402 }
403
404
405 /**
406 * @param {http.IncomingMessage} req
407 * @param {http.ServerResponse} res
408 * @param {Object} ctx
409 */
410 async handlerPostIntrospection(req, res, ctx) {
411 const _scope = _fileScope('handlerPostIntrospection');
412 this.logger.debug(_scope, 'called', { req, ctx });
413
414 const responseTypes = [
415 Enum.ContentType.ApplicationJson,
416 Enum.ContentType.TextPlain,
417 ];
418
419 await this.resourceAuthenticator.required(req, res, ctx);
420
421 this.setResponseType(responseTypes, req, res, ctx);
422
423 await this.ingestBody(req, res, ctx);
424
425 await this.manager.postIntrospection(res, ctx);
426 }
427
428
429 /**
430 * @param {http.IncomingMessage} req
431 * @param {http.ServerResponse} res
432 * @param {Object} ctx
433 */
434 async handlerPostUserInfo(req, res, ctx) {
435 const _scope = _fileScope('handlerPostUserInfo');
436 this.logger.debug(_scope, 'called', { req, ctx });
437
438 const responseTypes = [
439 Enum.ContentType.ApplicationJson,
440 Enum.ContentType.TextPlain,
441 ];
442
443 this.setResponseType(responseTypes, req, res, ctx);
444
445 await this.ingestBody(req, res, ctx);
446
447 await this.manager.postUserInfo(res, ctx);
448 }
449
450
451 /**
452 * @param {http.IncomingMessage} req
453 * @param {http.ServerResponse} res
454 * @param {Object} ctx
455 */
456 async handlerGetRoot(req, res, ctx) {
457 const _scope = _fileScope('handlerGetRoot');
458 const responseTypes = [
459 Enum.ContentType.TextHTML,
460 ];
461 this.logger.debug(_scope, 'called', { req, ctx });
462
463 initContext(ctx);
464
465 Dingus.setHeadHandler(req, res, ctx);
466
467 this.setResponseType(responseTypes, req, res, ctx);
468
469 await this.authenticator.sessionOptionalLocal(req, res, ctx);
470
471 await this.manager.getRoot(res, ctx);
472 }
473
474
475 /**
476 * @param {http.IncomingMessage} req
477 * @param {http.ServerResponse} res
478 * @param {Object} ctx
479 */
480 async handlerGetHealthcheck(req, res, ctx) {
481 const _scope = _fileScope('handlerGetHealthcheck');
482 this.logger.debug(_scope, 'called', { req, ctx });
483
484 Dingus.setHeadHandler(req, res, ctx);
485
486 this.setResponseType(this.responseTypes, req, res, ctx);
487
488 await this.manager.getHealthcheck(res, ctx);
489 }
490
491
492 /**
493 * @param {http.IncomingMessage} req
494 * @param {http.ServerResponse} res
495 * @param {Object} ctx
496 */
497 async handlerGetAdminMaintenance(req, res, ctx) {
498 const _scope = _fileScope('handlerGetAdminMaintenance');
499 this.logger.debug(_scope, 'called', { req, ctx });
500
501 initContext(ctx);
502
503 Dingus.setHeadHandler(req, res, ctx);
504
505 this.setResponseType(this.responseTypes, req, res, ctx);
506
507 if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
508 await this.manager.getAdminMaintenance(res, ctx);
509 }
510 }
511
512
513 /**
514 * FIXME: This doesn't seem to be working as envisioned. Maybe override render error method instead???
515 * Intercept this and redirect if we have enough information, otherwise default to framework.
516 * The redirect attempt should probably be contained in a Manager method, but here it is for now.
517 * @param {http.IncomingMessage} req
518 * @param {http.ServerResponse} res
519 * @param {Object} ctx
520 */
521 async handlerInternalServerError(req, res, ctx) {
522 const _scope = _fileScope('handlerInternalServerError');
523 this.logger.debug(_scope, 'called', { req, ctx });
524
525 if (ctx?.session?.redirectUri && ctx?.session?.clientIdentifier) {
526 Object.entries({
527 ...(ctx.session.state && { 'state': ctx.session.state }),
528 'error': 'server_error',
529 'error_description': 'An internal server error occurred',
530 }).forEach(([name, value]) => ctx.session.redirectUri.searchParams.set(name, value));
531 res.statusCode = 302; // Found
532 res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
533 res.end();
534 return;
535 }
536
537 super.handlerInternalServerError(req, res, ctx);
538 }
539
540
541 }
542
543 module.exports = Service;
544