@naturalcycles/backend-lib 9.74.0 → 9.76.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.
Files changed (33) hide show
  1. package/dist/admin/adminMiddleware.d.ts +17 -0
  2. package/dist/admin/adminMiddleware.js +11 -0
  3. package/dist/admin/base.admin.service.d.ts +17 -0
  4. package/dist/admin/base.admin.service.js +2 -0
  5. package/dist/admin/firebaseAuth.service.d.ts +41 -0
  6. package/dist/admin/firebaseAuth.service.js +103 -0
  7. package/dist/admin/index.d.ts +1 -0
  8. package/dist/admin/index.js +1 -0
  9. package/dist/admin/secureHeaderMiddleware.d.ts +5 -0
  10. package/dist/admin/secureHeaderMiddleware.js +2 -0
  11. package/dist/auth/auth.service.d.ts +119 -0
  12. package/dist/auth/auth.service.js +147 -0
  13. package/dist/auth/index.d.ts +1 -0
  14. package/dist/auth/index.js +1 -0
  15. package/dist/server/basicAuthMiddleware.js +1 -0
  16. package/dist/server/request.util.d.ts +14 -0
  17. package/dist/server/request.util.js +28 -1
  18. package/dist/server/requestTimeoutMiddleware.d.ts +8 -1
  19. package/dist/server/requestTimeoutMiddleware.js +19 -5
  20. package/dist/server/serverStatsMiddleware.js +16 -3
  21. package/package.json +3 -1
  22. package/src/admin/adminMiddleware.ts +17 -0
  23. package/src/admin/base.admin.service.ts +17 -0
  24. package/src/admin/firebaseAuth.service.ts +139 -0
  25. package/src/admin/index.ts +1 -0
  26. package/src/admin/login.html +16 -1
  27. package/src/admin/secureHeaderMiddleware.ts +5 -0
  28. package/src/auth/auth.service.ts +303 -0
  29. package/src/auth/index.ts +1 -0
  30. package/src/server/basicAuthMiddleware.ts +1 -0
  31. package/src/server/request.util.ts +37 -1
  32. package/src/server/requestTimeoutMiddleware.ts +32 -4
  33. package/src/server/serverStatsMiddleware.ts +18 -4
@@ -13,6 +13,13 @@ export interface RequestTimeoutMiddlewareCfg {
13
13
  * @default 'Request timed out'
14
14
  */
15
15
  httpErrorMessage?: string;
16
+ /**
17
+ * Upper bound for the `?requestTimeout=N` query override.
18
+ * Higher values are clamped to it, so a client cannot hold a request open for arbitrarily long.
19
+ *
20
+ * @default 600
21
+ */
22
+ maxTimeoutSeconds?: NumberOfSeconds;
16
23
  }
17
24
  export declare function requestTimeoutMiddleware(cfg?: RequestTimeoutMiddlewareCfg): BackendRequestHandler;
18
25
  export interface CustomRequestTimeoutMiddlewareCfg {
@@ -25,7 +32,7 @@ export interface CustomRequestTimeoutMiddlewareCfg {
25
32
  * Example:
26
33
  *
27
34
  * router.get('/', customRequestTimeoutMiddleware(
28
- * (req, res) => res.status(409).send('my custom message!'),
35
+ * (req, res) => res.status(409).type('text/plain').send('my custom message!'),
29
36
  * { timeoutSeconds: 30 },
30
37
  * )
31
38
  */
@@ -6,7 +6,7 @@ import { getRequestEndpoint } from './request.util.js';
6
6
  const code = 'REQUEST_TIMEOUT';
7
7
  const REQUEST_TIMEOUT_QUERY_KEY = 'requestTimeout';
8
8
  export function requestTimeoutMiddleware(cfg = {}) {
9
- const { timeoutSeconds: defTimeoutSeconds, backendResponseStatusCode, httpErrorMessage, } = {
9
+ const { timeoutSeconds: defTimeoutSeconds, backendResponseStatusCode, httpErrorMessage, maxTimeoutSeconds, } = {
10
10
  // Considerations about the default value of the timeout.
11
11
  // Ideally the default value here would be HIGHER than the default timeout for getGot (in nodejs-lib),
12
12
  // so, cross-service communication has a chance to fail SOONER than server times out,
@@ -14,12 +14,11 @@ export function requestTimeoutMiddleware(cfg = {}) {
14
14
  timeoutSeconds: 120,
15
15
  backendResponseStatusCode: 503,
16
16
  httpErrorMessage: 'Request timed out',
17
+ maxTimeoutSeconds: 600,
17
18
  ...cfg,
18
19
  };
19
20
  return function requestTimeoutHandler(req, res, next) {
20
- const timeoutSeconds = req.query[REQUEST_TIMEOUT_QUERY_KEY]
21
- ? Number.parseInt(req.query[REQUEST_TIMEOUT_QUERY_KEY])
22
- : defTimeoutSeconds;
21
+ const timeoutSeconds = getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds);
23
22
  // If requestTimeout was previously set - cancel it first
24
23
  // Then set the new requestTimeout and handler
25
24
  if (req.requestTimeout)
@@ -39,11 +38,26 @@ export function requestTimeoutMiddleware(cfg = {}) {
39
38
  next();
40
39
  };
41
40
  }
41
+ /**
42
+ * `?requestTimeout=N` query param lets the client override the timeout of its request
43
+ * (used e.g by Cloud Tasks for long-running tasks).
44
+ * Being client-controlled, it is validated: non-numeric or non-positive values are ignored
45
+ * (fall back to the default), values above `maxTimeoutSeconds` are clamped.
46
+ */
47
+ function getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds) {
48
+ const raw = req.query[REQUEST_TIMEOUT_QUERY_KEY];
49
+ if (typeof raw !== 'string')
50
+ return defTimeoutSeconds;
51
+ const requested = Number.parseInt(raw, 10);
52
+ if (Number.isNaN(requested) || requested <= 0)
53
+ return defTimeoutSeconds;
54
+ return Math.min(requested, maxTimeoutSeconds);
55
+ }
42
56
  /**
43
57
  * Example:
44
58
  *
45
59
  * router.get('/', customRequestTimeoutMiddleware(
46
- * (req, res) => res.status(409).send('my custom message!'),
60
+ * (req, res) => res.status(409).type('text/plain').send('my custom message!'),
47
61
  * { timeoutSeconds: 30 },
48
62
  * )
49
63
  */
@@ -5,6 +5,7 @@ import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js';
5
5
  import { _percentile } from '@naturalcycles/js-lib/math/math.util.js';
6
6
  import { NumberStack } from '@naturalcycles/js-lib/math/stack.util.js';
7
7
  import { _get, _mapValues } from '@naturalcycles/js-lib/object/object.util.js';
8
+ import { htmlEscape } from '@naturalcycles/js-lib/string/escape.js';
8
9
  import { _stringMapEntries, _stringMapValues } from '@naturalcycles/js-lib/types';
9
10
  import { onFinished } from '../onFinished.js';
10
11
  import { getRequestEndpoint } from './request.util.js';
@@ -61,7 +62,7 @@ export const serverStatsHTMLHandler = (req, res) => {
61
62
  }).map(([endpoint, stat]) => {
62
63
  return [
63
64
  '<tr>',
64
- `<td><pre>${endpoint}</pre></td>`,
65
+ `<td><pre>${htmlEscape(endpoint)}</pre></td>`,
65
66
  `<td align="right"><pre>${stat.total}</pre></td>`,
66
67
  // oxlint-disable-next-line @typescript-eslint/no-base-to-string
67
68
  ...families.map(f => `<td align="right"><pre>${stat[f]}</pre></td>`),
@@ -71,7 +72,7 @@ export const serverStatsHTMLHandler = (req, res) => {
71
72
  }),
72
73
  '</table>',
73
74
  ].join('\n');
74
- res.send(html);
75
+ res.type('html').send(html);
75
76
  };
76
77
  /**
77
78
  * Unlocks serverStatsHTMLHandler
@@ -83,7 +84,12 @@ export function serverStatsMiddleware() {
83
84
  onFinished(res, () => {
84
85
  const now = Date.now();
85
86
  const latency = now - started;
86
- const endpoint = getRequestEndpoint(req);
87
+ // Only requests that matched a route are recorded by their route pattern.
88
+ // Unmatched requests (404s, requests rejected by router-level middleware) carry an
89
+ // arbitrary client-controlled path: recording them per-path would let any client
90
+ // grow the map unboundedly and plant arbitrary strings into it.
91
+ // So they're bucketed per mount point instead.
92
+ const endpoint = req.route ? getRequestEndpoint(req) : getUnmatchedEndpoint(req);
87
93
  serverStatsMap[endpoint] ||= {
88
94
  stack: new NumberStack(SIZE),
89
95
  '2xx': 0,
@@ -102,6 +108,13 @@ export function serverStatsMiddleware() {
102
108
  next();
103
109
  };
104
110
  }
111
+ /**
112
+ * Returns e.g `GET /*` or `GET /api/v3/admin/*`.
113
+ * `req.baseUrl` is the mount point that matched, so it's bounded by the app's routing config.
114
+ */
115
+ function getUnmatchedEndpoint(req) {
116
+ return `${req.method} ${req.baseUrl.toLowerCase()}/*`;
117
+ }
105
118
  function cleanupServerStats() {
106
119
  if (Object.keys(serverStatsMap).length <= MAX_ENDPOINTS)
107
120
  return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.74.0",
4
+ "version": "9.76.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(
@@ -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'
@@ -63,7 +63,8 @@
63
63
  })
64
64
 
65
65
  const qs = Object.fromEntries(new URLSearchParams(location.search))
66
- const { autoLogin, logout, returnUrl, adminTokenKey = 'admin_token' } = qs
66
+ const { autoLogin, logout, adminTokenKey = 'admin_token' } = qs
67
+ const returnUrl = toSafeReturnUrl(qs.returnUrl)
67
68
  // console.log(qs)
68
69
 
69
70
  const app = createApp({
@@ -137,6 +138,20 @@
137
138
  alert('Error\n ' + JSON.stringify(err, null, 2))
138
139
  }
139
140
 
141
+ /**
142
+ * `returnUrl` is client-controlled. Only http(s) urls are allowed:
143
+ * anything else (e.g `javascript:`) would otherwise be executed by `location.href = returnUrl`
144
+ * in the context of this page, where the token is exposed.
145
+ */
146
+ function toSafeReturnUrl(url) {
147
+ if (!url) return
148
+ try {
149
+ const { protocol } = new URL(url, location.href)
150
+ if (protocol === 'http:' || protocol === 'https:') return url
151
+ } catch {}
152
+ console.warn('ignoring invalid returnUrl', url)
153
+ }
154
+
140
155
  async function postToken(token) {
141
156
  await fetch(`/admin/login`, {
142
157
  method: 'post',
@@ -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)