@naturalcycles/backend-lib 9.73.1 → 9.75.0
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.
- package/dist/admin/adminMiddleware.d.ts +17 -0
- package/dist/admin/adminMiddleware.js +11 -0
- package/dist/admin/base.admin.service.d.ts +17 -0
- package/dist/admin/base.admin.service.js +7 -2
- package/dist/admin/firebaseAuth.service.d.ts +41 -0
- package/dist/admin/firebaseAuth.service.js +103 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/secureHeaderMiddleware.d.ts +5 -0
- package/dist/admin/secureHeaderMiddleware.js +2 -0
- package/dist/auth/auth.service.d.ts +119 -0
- package/dist/auth/auth.service.js +147 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/sentry/sentry.shared.service.js +14 -15
- package/dist/server/asyncLocalStorageMiddleware.js +2 -6
- package/dist/server/genericErrorMiddleware.js +2 -2
- package/dist/server/logMiddleware.js +25 -66
- package/dist/server/server.model.d.ts +3 -11
- package/package.json +3 -1
- package/src/admin/adminMiddleware.ts +17 -0
- package/src/admin/base.admin.service.ts +22 -5
- package/src/admin/firebaseAuth.service.ts +139 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/secureHeaderMiddleware.ts +5 -0
- package/src/auth/auth.service.ts +303 -0
- package/src/auth/index.ts +1 -0
- package/src/sentry/sentry.shared.service.ts +15 -17
- package/src/server/asyncLocalStorageMiddleware.ts +4 -6
- package/src/server/genericErrorMiddleware.ts +2 -2
- package/src/server/logMiddleware.ts +35 -69
- package/src/server/server.model.ts +3 -13
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { _lazyValue } from '@naturalcycles/js-lib';
|
|
3
|
+
import { commonLoggerCreate } from '@naturalcycles/js-lib/log';
|
|
3
4
|
import { ciLogger, devLogger, gcpStructuredLogger } from './logMiddleware.js';
|
|
4
5
|
const { GAE_INSTANCE, K_SERVICE, CI } = process.env;
|
|
5
6
|
const isGAE = !!GAE_INSTANCE;
|
|
@@ -43,9 +44,4 @@ export function getRequestLogger() {
|
|
|
43
44
|
*
|
|
44
45
|
* @experimental
|
|
45
46
|
*/
|
|
46
|
-
export const requestLogger = {
|
|
47
|
-
debug: (...args) => getRequestLogger().debug(...args),
|
|
48
|
-
log: (...args) => getRequestLogger().log(...args),
|
|
49
|
-
warn: (...args) => getRequestLogger().warn(...args),
|
|
50
|
-
error: (...args) => getRequestLogger().error(...args),
|
|
51
|
-
};
|
|
47
|
+
export const requestLogger = commonLoggerCreate(({ level, ...data }) => getRequestLogger()[level](data));
|
|
@@ -40,10 +40,10 @@ export function respondWithError(req, res, err) {
|
|
|
40
40
|
else {
|
|
41
41
|
// because errorService was not provided - we are going to log the error here
|
|
42
42
|
if (headersSent) {
|
|
43
|
-
req.error(`error after headersSent:`, err);
|
|
43
|
+
req.error({ msg: `error after headersSent:`, err });
|
|
44
44
|
}
|
|
45
45
|
else {
|
|
46
|
-
req.error(err);
|
|
46
|
+
req.error({ err });
|
|
47
47
|
}
|
|
48
48
|
// todo: add endpoint to the log
|
|
49
49
|
// todo: add userId from the "Context" (or, just req.userId?) to the log
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { inspect } from 'node:util';
|
|
2
|
-
import { _isPlainObject } from '@naturalcycles/js-lib';
|
|
3
2
|
import { _anyToErrorObject } from '@naturalcycles/js-lib/error';
|
|
3
|
+
import { commonLoggerCreate } from '@naturalcycles/js-lib/log';
|
|
4
4
|
import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringify.js';
|
|
5
5
|
import { _objectAssign } from '@naturalcycles/js-lib/types';
|
|
6
|
-
import {
|
|
6
|
+
import { logEntryToString } from '@naturalcycles/nodejs-lib';
|
|
7
7
|
import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
|
|
8
8
|
const { GOOGLE_CLOUD_PROJECT, GAE_INSTANCE, K_SERVICE, APP_ENV } = process.env;
|
|
9
9
|
const isGAE = !!GAE_INSTANCE;
|
|
@@ -16,81 +16,49 @@ let reqCounter = 0;
|
|
|
16
16
|
* Logger that logs in "GCP structured log" format.
|
|
17
17
|
* To be used in outside-of-request situations (otherwise req.log should be used).
|
|
18
18
|
*/
|
|
19
|
-
export const gcpStructuredLogger = {
|
|
20
|
-
debug: (...args) => writeGCPStructuredLog({ severity: 'DEBUG' }, args),
|
|
21
|
-
log: (...args) => writeGCPStructuredLog({}, args),
|
|
22
|
-
warn: (...args) => writeGCPStructuredLog({ severity: 'WARNING' }, args),
|
|
23
|
-
error: (...args) => writeGCPStructuredLog({ severity: 'ERROR' }, args),
|
|
24
|
-
};
|
|
19
|
+
export const gcpStructuredLogger = commonLoggerCreate(entry => writeGCPStructuredLog({}, entry));
|
|
25
20
|
/**
|
|
26
21
|
* Fancy development logger, to be used in outside-of-request situations
|
|
27
22
|
* (otherwise req.log should be used).
|
|
28
23
|
*/
|
|
29
|
-
export const devLogger =
|
|
30
|
-
debug: (...args) => logToDev(null, args),
|
|
31
|
-
log: (...args) => logToDev(null, args),
|
|
32
|
-
warn: (...args) => logToDev(null, args),
|
|
33
|
-
error: (...args) => logToDev(null, args),
|
|
34
|
-
};
|
|
24
|
+
export const devLogger = commonLoggerCreate(entry => logToDev(null, entry));
|
|
35
25
|
/**
|
|
36
26
|
* Same as devLogger, but without colors (e.g to not confuse Sentry).
|
|
37
27
|
*/
|
|
38
|
-
export const ciLogger =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
28
|
+
export const ciLogger = commonLoggerCreate(logToCI);
|
|
29
|
+
const gcpSeverityByLevel = {
|
|
30
|
+
debug: 'DEBUG',
|
|
31
|
+
log: 'INFO',
|
|
32
|
+
warn: 'WARNING',
|
|
33
|
+
error: 'ERROR',
|
|
43
34
|
};
|
|
44
35
|
// Documented here: https://cloud.google.com/logging/docs/structured-logging
|
|
45
36
|
// Cloud Run logging: https://cloud.google.com/run/docs/logging
|
|
46
|
-
function writeGCPStructuredLog(meta,
|
|
47
|
-
|
|
48
|
-
console.log(JSON.stringify({
|
|
49
|
-
message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '),
|
|
50
|
-
...meta,
|
|
51
|
-
}));
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const { msg, err, ...entry } = args[0];
|
|
37
|
+
function writeGCPStructuredLog(meta, entry) {
|
|
38
|
+
const { level, msg, err, ...fields } = entry;
|
|
55
39
|
if (err instanceof Error) {
|
|
56
|
-
|
|
57
|
-
|
|
40
|
+
fields['err'] = _anyToErrorObject(err);
|
|
41
|
+
fields['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err);
|
|
58
42
|
}
|
|
59
43
|
else {
|
|
60
44
|
if (err !== undefined)
|
|
61
|
-
|
|
45
|
+
fields['err'] = err;
|
|
62
46
|
if (msg !== undefined)
|
|
63
|
-
|
|
47
|
+
fields['message'] = msg;
|
|
64
48
|
}
|
|
65
|
-
console.log(_safeJsonStringify({ ...
|
|
49
|
+
console.log(_safeJsonStringify({ ...fields, ...meta, severity: gcpSeverityByLevel[level] }));
|
|
66
50
|
}
|
|
67
|
-
function logToDev(requestId,
|
|
51
|
+
function logToDev(requestId, entry) {
|
|
68
52
|
// Run on local machine
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
requestId ? [dimGrey(`[${requestId}]`)] : [],
|
|
72
|
-
...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })),
|
|
73
|
-
].join(' '));
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const { msg, err, ...rest } = args[0];
|
|
77
|
-
const parts = [];
|
|
78
|
-
if (requestId)
|
|
79
|
-
parts.push(dimGrey(`[${requestId}]`));
|
|
80
|
-
if (msg !== undefined)
|
|
81
|
-
parts.push(String(msg));
|
|
82
|
-
if (err !== undefined)
|
|
83
|
-
parts.push(_inspect(err, { includeErrorStack: true, colors: true }));
|
|
84
|
-
if (Object.keys(rest).length)
|
|
85
|
-
parts.push(dimGrey(_inspect(rest, { colors: false })));
|
|
86
|
-
console.log(parts.join(' '));
|
|
53
|
+
const line = logEntryToString(entry, { colors: true });
|
|
54
|
+
console.log(requestId ? `${dimGrey(`[${requestId}]`)} ${line}` : line);
|
|
87
55
|
}
|
|
88
56
|
/**
|
|
89
57
|
* Same as logToDev, but without request and without colors.
|
|
90
58
|
* This is to not confuse e.g Sentry when it picks up messages with colors
|
|
91
59
|
*/
|
|
92
|
-
function logToCI(
|
|
93
|
-
console.log(
|
|
60
|
+
function logToCI(entry) {
|
|
61
|
+
console.log(logEntryToString(entry, { colors: false }));
|
|
94
62
|
}
|
|
95
63
|
export function logMiddleware() {
|
|
96
64
|
if (isGAE || isCloudRun) {
|
|
@@ -117,12 +85,7 @@ export function logMiddleware() {
|
|
|
117
85
|
if (isGAE) {
|
|
118
86
|
meta['appengine.googleapis.com/request_id'] = req.header('x-appengine-request-log-id');
|
|
119
87
|
}
|
|
120
|
-
_objectAssign(req,
|
|
121
|
-
debug: (...args) => writeGCPStructuredLog({ ...meta, severity: 'DEBUG' }, args),
|
|
122
|
-
log: (...args) => writeGCPStructuredLog({ ...meta, severity: 'INFO' }, args),
|
|
123
|
-
warn: (...args) => writeGCPStructuredLog({ ...meta, severity: 'WARNING' }, args),
|
|
124
|
-
error: (...args) => writeGCPStructuredLog({ ...meta, severity: 'ERROR' }, args),
|
|
125
|
-
});
|
|
88
|
+
_objectAssign(req, commonLoggerCreate(entry => writeGCPStructuredLog(meta, entry)));
|
|
126
89
|
next();
|
|
127
90
|
};
|
|
128
91
|
}
|
|
@@ -131,18 +94,14 @@ export function logMiddleware() {
|
|
|
131
94
|
return function devLogHandler(req, _res, next) {
|
|
132
95
|
// Local machine
|
|
133
96
|
req.requestId = String(++reqCounter);
|
|
134
|
-
req.
|
|
135
|
-
req.log =
|
|
136
|
-
req.warn =
|
|
137
|
-
req.error =
|
|
138
|
-
(...args) => logToDev(req.requestId, args);
|
|
97
|
+
_objectAssign(req, commonLoggerCreate(entry => logToDev(req.requestId, entry)));
|
|
139
98
|
next();
|
|
140
99
|
};
|
|
141
100
|
}
|
|
142
101
|
// Otherwise, return "simple" logger
|
|
143
102
|
// This includes: unit tests, CI environments
|
|
144
103
|
return function simpleLogHandler(req, _res, next) {
|
|
145
|
-
req
|
|
104
|
+
_objectAssign(req, ciLogger);
|
|
146
105
|
next();
|
|
147
106
|
};
|
|
148
107
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CommonLogger } from '@naturalcycles/js-lib/log';
|
|
2
2
|
import type { Promisable } from '@naturalcycles/js-lib/types';
|
|
3
3
|
import type { Application, IRouter, NextFunction, Request, Response } from 'express';
|
|
4
4
|
/**
|
|
@@ -8,11 +8,7 @@ import type { Application, IRouter, NextFunction, Request, Response } from 'expr
|
|
|
8
8
|
* `BackendRequest` seems to not conflict with anything right now.
|
|
9
9
|
* Previous name `ExpressRequest` was clashing with Sentry.
|
|
10
10
|
*/
|
|
11
|
-
export interface BackendRequest<BODY = unknown> extends Request {
|
|
12
|
-
debug: CommonLogFunction;
|
|
13
|
-
log: CommonLogFunction;
|
|
14
|
-
warn: CommonLogFunction;
|
|
15
|
-
error: CommonLogFunction;
|
|
11
|
+
export interface BackendRequest<BODY = unknown> extends Request, CommonLogger {
|
|
16
12
|
requestId?: string;
|
|
17
13
|
/**
|
|
18
14
|
* Only used for request logging purposes.
|
|
@@ -50,11 +46,7 @@ export type BackendErrorRequestHandler = (err: any, req: BackendRequest, res: Ba
|
|
|
50
46
|
export type BackendRouter = IRouter;
|
|
51
47
|
export type BackendApplication = Application;
|
|
52
48
|
declare module 'http' {
|
|
53
|
-
interface IncomingMessage {
|
|
54
|
-
debug: CommonLogFunction;
|
|
55
|
-
log: CommonLogFunction;
|
|
56
|
-
warn: CommonLogFunction;
|
|
57
|
-
error: CommonLogFunction;
|
|
49
|
+
interface IncomingMessage extends CommonLogger {
|
|
58
50
|
requestId?: string;
|
|
59
51
|
requestTimeout?: NodeJS.Timeout;
|
|
60
52
|
bodyParserTimeout?: NodeJS.Timeout;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/backend-lib",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.75.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@naturalcycles/db-lib": "^10",
|
|
7
7
|
"@naturalcycles/js-lib": "^15",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
".": "./dist/index.js",
|
|
54
54
|
"./admin": "./dist/admin/index.js",
|
|
55
55
|
"./admin/*.js": "./dist/admin/*.js",
|
|
56
|
+
"./auth": "./dist/auth/index.js",
|
|
57
|
+
"./auth/*.js": "./dist/auth/*.js",
|
|
56
58
|
"./db": "./dist/db/index.js",
|
|
57
59
|
"./deploy": "./dist/deploy/index.js",
|
|
58
60
|
"./deploy/*.js": "./dist/deploy/*.js",
|
|
@@ -6,6 +6,9 @@ import type { BackendRequestHandler } from '../server/server.model.js'
|
|
|
6
6
|
import type { BaseAdminService } from './base.admin.service.js'
|
|
7
7
|
import type { FirebaseSharedServiceCfg } from './firebase.shared.service.js'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* @deprecated Use `AuthLoginRedirectMiddlewareCfg`.
|
|
11
|
+
*/
|
|
9
12
|
export interface RequireAdminCfg {
|
|
10
13
|
/**
|
|
11
14
|
* @default '/login.html'
|
|
@@ -35,11 +38,17 @@ export interface RequireAdminCfg {
|
|
|
35
38
|
andComparison?: boolean
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* @deprecated Use `AuthMiddleware`.
|
|
43
|
+
*/
|
|
38
44
|
export type AdminMiddleware = (
|
|
39
45
|
reqPermissions?: string[],
|
|
40
46
|
cfg?: RequireAdminCfg,
|
|
41
47
|
) => BackendRequestHandler
|
|
42
48
|
|
|
49
|
+
/**
|
|
50
|
+
* @deprecated Use `AuthService.getLoginRedirectMiddleware`.
|
|
51
|
+
*/
|
|
43
52
|
export function createAdminMiddleware(
|
|
44
53
|
adminService: BaseAdminService,
|
|
45
54
|
cfgDefaults: RequireAdminCfg = {},
|
|
@@ -56,6 +65,8 @@ export function createAdminMiddleware(
|
|
|
56
65
|
* If not authenticated - will redirect to `loginHtmlPath` (default to /login.html).
|
|
57
66
|
* If authenticated, but not authorized - will throw 403.
|
|
58
67
|
* Otherwise will just pass.
|
|
68
|
+
*
|
|
69
|
+
* @deprecated Use `AuthService.getLoginRedirectMiddleware`.
|
|
59
70
|
*/
|
|
60
71
|
export function requireAdminPermissions(
|
|
61
72
|
adminService: BaseAdminService,
|
|
@@ -97,6 +108,9 @@ interface LoginHtmlCfg {
|
|
|
97
108
|
firebaseAuthProvider: string
|
|
98
109
|
}
|
|
99
110
|
|
|
111
|
+
/**
|
|
112
|
+
* @deprecated Use `loginHtmlHandler`.
|
|
113
|
+
*/
|
|
100
114
|
export function loginHtml(firebaseServiceCfg: FirebaseSharedServiceCfg): BackendRequestHandler {
|
|
101
115
|
const {
|
|
102
116
|
apiKey: firebaseApiKey,
|
|
@@ -124,6 +138,9 @@ const getLoginHtml = _memoized((cfg: LoginHtmlCfg) => {
|
|
|
124
138
|
.replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider)
|
|
125
139
|
})
|
|
126
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @deprecated Use `AuthService.getLoginRedirectMiddleware`.
|
|
143
|
+
*/
|
|
127
144
|
export function getLoginHtmlRedirect(href: string): string {
|
|
128
145
|
return `
|
|
129
146
|
<html>
|
|
@@ -7,6 +7,8 @@ import type { BackendRequest, BackendRequestHandler } from '../server/server.mod
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
|
|
10
|
+
*
|
|
11
|
+
* @deprecated Use `AuthService`, with `FirebaseAuthService` for `authenticate`.
|
|
10
12
|
*/
|
|
11
13
|
export class BaseAdminService {
|
|
12
14
|
constructor(
|
|
@@ -220,7 +222,7 @@ export class BaseAdminService {
|
|
|
220
222
|
return // skip logging, expected error
|
|
221
223
|
}
|
|
222
224
|
|
|
223
|
-
req.error(`getEmailByToken error:`, err)
|
|
225
|
+
req.error({ msg: `getEmailByToken error:`, err })
|
|
224
226
|
}
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -257,12 +259,12 @@ export class BaseAdminService {
|
|
|
257
259
|
granted: boolean,
|
|
258
260
|
meta: AnyObject = {},
|
|
259
261
|
): Promise<void> {
|
|
260
|
-
req.log(
|
|
261
|
-
|
|
262
|
+
req.log({
|
|
263
|
+
...meta,
|
|
264
|
+
msg: `${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey(
|
|
262
265
|
reqPermissions.join(', '),
|
|
263
266
|
)}]: ${granted ? green('GRANTED') : red('DENIED')}`,
|
|
264
|
-
|
|
265
|
-
)
|
|
267
|
+
})
|
|
266
268
|
}
|
|
267
269
|
|
|
268
270
|
/**
|
|
@@ -309,6 +311,9 @@ const adminInfoDisabled = (): AdminInfo => ({
|
|
|
309
311
|
permissions: [],
|
|
310
312
|
})
|
|
311
313
|
|
|
314
|
+
/**
|
|
315
|
+
* @deprecated Use `AuthServiceCfg` and `FirebaseAuthServiceCfg`.
|
|
316
|
+
*/
|
|
312
317
|
export interface AdminServiceCfg {
|
|
313
318
|
/**
|
|
314
319
|
* @default 'admin_token'
|
|
@@ -323,11 +328,17 @@ export interface AdminServiceCfg {
|
|
|
323
328
|
authEnabled?: boolean
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
/**
|
|
332
|
+
* @deprecated Use `PermissionInfo`.
|
|
333
|
+
*/
|
|
326
334
|
export interface AdminInfo {
|
|
327
335
|
email: string
|
|
328
336
|
permissions: string[]
|
|
329
337
|
}
|
|
330
338
|
|
|
339
|
+
/**
|
|
340
|
+
* @deprecated Use `ResolvedAuth`.
|
|
341
|
+
*/
|
|
331
342
|
export interface ResolvedAdmin {
|
|
332
343
|
/** undefined - no valid admin token */
|
|
333
344
|
email?: string
|
|
@@ -335,6 +346,9 @@ export interface ResolvedAdmin {
|
|
|
335
346
|
permissions?: Set<string>
|
|
336
347
|
}
|
|
337
348
|
|
|
349
|
+
/**
|
|
350
|
+
* @deprecated Use `AuthCheckOptions`.
|
|
351
|
+
*/
|
|
338
352
|
export interface CheckPermissionsOptions {
|
|
339
353
|
/**
|
|
340
354
|
* false - one granted permission is enough.
|
|
@@ -344,6 +358,9 @@ export interface CheckPermissionsOptions {
|
|
|
344
358
|
andComparison?: boolean
|
|
345
359
|
}
|
|
346
360
|
|
|
361
|
+
/**
|
|
362
|
+
* @deprecated Use `AuthService.has` / `AuthService.require`.
|
|
363
|
+
*/
|
|
347
364
|
export interface CheckPermissionsResult {
|
|
348
365
|
/** undefined - no valid admin token */
|
|
349
366
|
email?: string
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { _Memo, _memoized } from '@naturalcycles/js-lib/decorators'
|
|
2
|
+
import { _assert } from '@naturalcycles/js-lib/error'
|
|
3
|
+
import { fs2 } from '@naturalcycles/nodejs-lib/fs2'
|
|
4
|
+
import type { Auth, FirebaseAuthError } from 'firebase-admin/auth'
|
|
5
|
+
import { srcDir } from '../paths.cnst.js'
|
|
6
|
+
import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js'
|
|
7
|
+
import type { FirebaseSharedServiceCfg } from './firebase.shared.service.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Verifies Firebase Auth tokens.
|
|
11
|
+
*/
|
|
12
|
+
export class FirebaseAuthService {
|
|
13
|
+
constructor(
|
|
14
|
+
private loadFirebaseAuth: () => Promise<FirebaseTokenVerifier>,
|
|
15
|
+
public cfg: FirebaseAuthServiceCfg,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the configured cookie first, then the header.
|
|
20
|
+
*/
|
|
21
|
+
getToken(req: BackendRequest): string | undefined {
|
|
22
|
+
const { tokenCookie, tokenHeader } = this.cfg
|
|
23
|
+
|
|
24
|
+
if (tokenCookie) {
|
|
25
|
+
const token = req.cookies?.[tokenCookie]
|
|
26
|
+
if (token) return token
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (tokenHeader) return req.header(tokenHeader)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns undefined if there's no token, or it's expired.
|
|
34
|
+
* Rejects on any other verification failure.
|
|
35
|
+
*/
|
|
36
|
+
async getEmailByToken(token: string | undefined): Promise<string | undefined> {
|
|
37
|
+
if (!token) return
|
|
38
|
+
|
|
39
|
+
const auth = await this.getFirebaseAuth()
|
|
40
|
+
try {
|
|
41
|
+
const decodedToken = await auth.verifyIdToken(token)
|
|
42
|
+
return decodedToken.email
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if ((err as Partial<FirebaseAuthError> | undefined)?.hasCode?.('id-token-expired')) return
|
|
45
|
+
throw err
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@_Memo()
|
|
50
|
+
private async getFirebaseAuth(): Promise<FirebaseTokenVerifier> {
|
|
51
|
+
return await this.loadFirebaseAuth()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* POST handler that stores the token from the `Authentication` header in `tokenCookie` (not validated).
|
|
56
|
+
* `Authentication: logout` clears it. The bundled login.html posts to `/admin/login`.
|
|
57
|
+
*/
|
|
58
|
+
getLoginHandler(): BackendRequestHandler {
|
|
59
|
+
const { tokenCookie } = this.cfg
|
|
60
|
+
_assert(tokenCookie, 'getLoginHandler requires tokenCookie to be configured')
|
|
61
|
+
|
|
62
|
+
return async (req, res) => {
|
|
63
|
+
const token = req.header('authentication')
|
|
64
|
+
_assert(token, `401 Unauthenticated`, {
|
|
65
|
+
userFriendly: true,
|
|
66
|
+
backendResponseStatusCode: 401,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
let maxAge = 1000 * 60 * 60 * 24 * 30 // 30 days
|
|
70
|
+
|
|
71
|
+
if (token === 'logout') {
|
|
72
|
+
// delete the cookie
|
|
73
|
+
maxAge = 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res
|
|
77
|
+
.cookie(tokenCookie, token, {
|
|
78
|
+
maxAge,
|
|
79
|
+
sameSite: 'lax', // can be: none, lax, strict
|
|
80
|
+
// comment these 2 lines to debug on localhost
|
|
81
|
+
httpOnly: true,
|
|
82
|
+
secure: true,
|
|
83
|
+
})
|
|
84
|
+
.status(204)
|
|
85
|
+
.end()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Serves login.html, configured for Firebase Auth.
|
|
92
|
+
*/
|
|
93
|
+
export function loginHtmlHandler(
|
|
94
|
+
firebaseServiceCfg: FirebaseSharedServiceCfg,
|
|
95
|
+
): BackendRequestHandler {
|
|
96
|
+
const {
|
|
97
|
+
apiKey: firebaseApiKey,
|
|
98
|
+
authDomain: firebaseAuthDomain,
|
|
99
|
+
adminAuthProvider: firebaseAuthProvider = 'GoogleAuthProvider',
|
|
100
|
+
} = firebaseServiceCfg
|
|
101
|
+
|
|
102
|
+
return (_req, res) => {
|
|
103
|
+
res.send(
|
|
104
|
+
getLoginHtml({
|
|
105
|
+
firebaseApiKey,
|
|
106
|
+
firebaseAuthDomain,
|
|
107
|
+
firebaseAuthProvider,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const getLoginHtml = _memoized((cfg: LoginHtmlCfg) => {
|
|
114
|
+
return fs2
|
|
115
|
+
.readText(`${srcDir}/admin/login.html`)
|
|
116
|
+
.replaceAll('<%= firebaseApiKey %>', cfg.firebaseApiKey)
|
|
117
|
+
.replaceAll('<%= firebaseAuthDomain %>', cfg.firebaseAuthDomain)
|
|
118
|
+
.replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The part of Firebase `Auth` that FirebaseAuthService uses.
|
|
123
|
+
*/
|
|
124
|
+
export type FirebaseTokenVerifier = Pick<Auth, 'verifyIdToken'>
|
|
125
|
+
|
|
126
|
+
export interface FirebaseAuthServiceCfg {
|
|
127
|
+
/**
|
|
128
|
+
* Read by `getToken`, set by `getLoginHandler`.
|
|
129
|
+
*/
|
|
130
|
+
tokenCookie?: string
|
|
131
|
+
|
|
132
|
+
tokenHeader?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface LoginHtmlCfg {
|
|
136
|
+
firebaseApiKey: string
|
|
137
|
+
firebaseAuthDomain: string
|
|
138
|
+
firebaseAuthProvider: string
|
|
139
|
+
}
|
package/src/admin/index.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type { AdminMiddleware, RequireAdminCfg } from './adminMiddleware.js'
|
|
|
5
5
|
import { requireAdminPermissions } from './adminMiddleware.js'
|
|
6
6
|
import type { BaseAdminService } from './base.admin.service.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* @deprecated Use `AuthLoginRedirectMiddlewareCfg` with `secureHeader`.
|
|
10
|
+
*/
|
|
8
11
|
export interface SecureHeaderMiddlewareCfg extends RequireAdminCfg {
|
|
9
12
|
adminService: BaseAdminService
|
|
10
13
|
|
|
@@ -22,6 +25,8 @@ export interface SecureHeaderMiddlewareCfg extends RequireAdminCfg {
|
|
|
22
25
|
/**
|
|
23
26
|
* Secures the endpoint by requiring a secret header to be present.
|
|
24
27
|
* Throws Error401Admin otherwise.
|
|
28
|
+
*
|
|
29
|
+
* @deprecated Use `AuthService.getLoginRedirectMiddleware` with `secureHeader`.
|
|
25
30
|
*/
|
|
26
31
|
export function createSecureHeaderMiddleware(cfg: SecureHeaderMiddlewareCfg): AdminMiddleware {
|
|
27
32
|
return reqPermissions => requireSecureHeaderOrAdmin(cfg, reqPermissions)
|