@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.
@@ -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 { _inspect } from '@naturalcycles/nodejs-lib';
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
- debug: (...args) => logToCI(args),
40
- log: (...args) => logToCI(args),
41
- warn: (...args) => logToCI(args),
42
- error: (...args) => logToCI(args),
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, args) {
47
- if (args.length !== 1 || !_isPlainObject(args[0])) {
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
- entry['err'] = _anyToErrorObject(err);
57
- entry['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err);
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
- entry['err'] = err;
45
+ fields['err'] = err;
62
46
  if (msg !== undefined)
63
- entry['message'] = msg;
47
+ fields['message'] = msg;
64
48
  }
65
- console.log(_safeJsonStringify({ ...entry, ...meta }));
49
+ console.log(_safeJsonStringify({ ...fields, ...meta, severity: gcpSeverityByLevel[level] }));
66
50
  }
67
- function logToDev(requestId, args) {
51
+ function logToDev(requestId, entry) {
68
52
  // Run on local machine
69
- if (args.length !== 1 || !_isPlainObject(args[0])) {
70
- console.log([
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(args) {
93
- console.log(args.map(a => _inspect(a, { includeErrorStack: true, colors: false })).join(' '));
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.debug =
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.debug = req.log = req.warn = req.error = (...args) => logToCI(args);
104
+ _objectAssign(req, ciLogger);
146
105
  next();
147
106
  };
148
107
  }
@@ -1,4 +1,4 @@
1
- import type { CommonLogFunction } from '@naturalcycles/js-lib/log';
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.73.1",
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
- `${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey(
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
- meta,
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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './adminMiddleware.js'
2
2
  export * from './base.admin.service.js'
3
3
  export * from './firebase.shared.service.js'
4
+ export * from './firebaseAuth.service.js'
4
5
  export * from './secureHeaderMiddleware.js'
@@ -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)