@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
@@ -0,0 +1,303 @@
1
+ import { _assert, AppError } from '@naturalcycles/js-lib/error'
2
+ import type { ErrorData } from '@naturalcycles/js-lib/error'
3
+ import type { AnyObject, Promisable } from '@naturalcycles/js-lib/types'
4
+ import { timingSafeStringEqual } from '@naturalcycles/nodejs-lib'
5
+ import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js'
6
+
7
+ /**
8
+ * Authenticates requests and checks permissions.
9
+ */
10
+ export class AuthService<CTX = AnyObject> {
11
+ constructor(cfg: AuthServiceCfg<CTX>) {
12
+ this.cfg = {
13
+ authEnabled: true,
14
+ ...cfg,
15
+ }
16
+ }
17
+
18
+ cfg: AuthServiceCfg<CTX> & { authEnabled: boolean }
19
+
20
+ /**
21
+ * Responds JSON 401 / 403.
22
+ */
23
+ getMiddleware(cfg: AuthMiddlewareCfg<CTX> = {}): AuthMiddleware {
24
+ return reqPermissions => this.createHandler(cfg, reqPermissions)
25
+ }
26
+
27
+ /**
28
+ * Like `getMiddleware`, but 401 redirects to the login page.
29
+ */
30
+ getLoginRedirectMiddleware(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): AuthMiddleware {
31
+ const loginHref = this.getLoginHref(cfg)
32
+ return reqPermissions => this.createHandler(cfg, reqPermissions, loginHref)
33
+ }
34
+
35
+ private createHandler(
36
+ cfg: AuthMiddlewareCfg<CTX>,
37
+ reqPermissions: string[] = [],
38
+ loginHref?: string,
39
+ ): BackendRequestHandler {
40
+ const {
41
+ getResolved = req => this.resolve(req),
42
+ getCtx,
43
+ andComparison = true,
44
+ secureHeader,
45
+ } = cfg
46
+
47
+ return async (req, res, next) => {
48
+ if (!this.cfg.authEnabled) return next()
49
+
50
+ if (secureHeader) {
51
+ const providedHeader = req.get(secureHeader.name)
52
+ if (providedHeader) {
53
+ if (timingSafeStringEqual(providedHeader, secureHeader.value)) {
54
+ return next()
55
+ }
56
+
57
+ return next(
58
+ new AppError('secureHeader or authentication required', this.authRequiredData()),
59
+ )
60
+ }
61
+ }
62
+
63
+ try {
64
+ const resolved = await getResolved(req)
65
+ if (!resolved?.subject && loginHref) {
66
+ res.status(401).send(this.getLoginHtmlRedirect(loginHref))
67
+ return
68
+ }
69
+
70
+ this.require(resolved, reqPermissions, { andComparison, ctx: getCtx?.(req) })
71
+ return next()
72
+ } catch (err) {
73
+ return next(err)
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Runs `authenticate`, then `getPermissions` and `isAlwaysAuthorized` if there's a subject.
80
+ * Ignores `authEnabled`.
81
+ */
82
+ async resolve(req: BackendRequest): Promise<ResolvedAuth> {
83
+ const { authenticate, getPermissions, isAlwaysAuthorized } = this.cfg
84
+
85
+ const subject = await authenticate(req)
86
+ if (!subject) return {}
87
+
88
+ const permissions = await getPermissions(subject, req)
89
+ const alwaysAuthorized = isAlwaysAuthorized
90
+ ? await isAlwaysAuthorized(subject, permissions, req)
91
+ : false
92
+
93
+ return { subject, permissions, alwaysAuthorized }
94
+ }
95
+
96
+ /**
97
+ * Throws 401 if there's no subject, 403 if not granted.
98
+ * Returns the granted permissions (all of the subject's if alwaysAuthorized),
99
+ * or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled.
100
+ */
101
+ require(
102
+ resolved: ResolvedAuth | undefined,
103
+ reqPermissions: string[] = [],
104
+ opt: AuthCheckOptions<CTX> = {},
105
+ ): PermissionInfo {
106
+ if (!this.cfg.authEnabled) return { subject: 'authDisabled', permissions: [] }
107
+
108
+ this.assertAuthenticated(resolved)
109
+ const { subject, permissions, alwaysAuthorized } = resolved
110
+
111
+ const grantedPermissions = this.checkSubject(resolved, reqPermissions, opt)
112
+ this.assertGranted(subject, grantedPermissions, reqPermissions)
113
+
114
+ if (alwaysAuthorized) {
115
+ return { subject, permissions: permissions?.slice() || [] }
116
+ }
117
+
118
+ return { subject, permissions: grantedPermissions.slice() }
119
+ }
120
+
121
+ private checkSubject(
122
+ resolved: AuthenticatedAuth,
123
+ reqPermissions: string[],
124
+ opt: AuthCheckOptions<CTX>,
125
+ ): string[] | undefined {
126
+ const { subject, permissions, alwaysAuthorized = false } = resolved
127
+ const { andComparison = true, ctx } = opt
128
+
129
+ const grantedPermissions = alwaysAuthorized
130
+ ? reqPermissions
131
+ : this.getGrantedPermissions(permissions, reqPermissions, andComparison)
132
+
133
+ this.cfg.onCheck?.(
134
+ {
135
+ subject,
136
+ granted: !!grantedPermissions,
137
+ alwaysAuthorized,
138
+ reqPermissions,
139
+ checkedPermissions: grantedPermissions || reqPermissions,
140
+ },
141
+ ctx,
142
+ )
143
+
144
+ return grantedPermissions
145
+ }
146
+
147
+ private assertAuthenticated(
148
+ resolved: ResolvedAuth | undefined,
149
+ ): asserts resolved is AuthenticatedAuth {
150
+ _assert(resolved?.subject, 'authentication required', this.authRequiredData())
151
+ }
152
+
153
+ private authRequiredData(): ErrorData {
154
+ return {
155
+ authRequired: true,
156
+ backendResponseStatusCode: 401,
157
+ userFriendly: true,
158
+ }
159
+ }
160
+
161
+ private assertGranted(
162
+ subject: string,
163
+ grantedPermissions: string[] | undefined,
164
+ reqPermissions: string[],
165
+ ): asserts grantedPermissions is string[] {
166
+ _assert(grantedPermissions, `Permissions required: [${reqPermissions.join(', ')}]`, {
167
+ permissionsRequired: reqPermissions,
168
+ subject,
169
+ backendResponseStatusCode: 403,
170
+ userFriendly: true,
171
+ })
172
+ }
173
+
174
+ /**
175
+ * undefined - not granted.
176
+ */
177
+ private getGrantedPermissions(
178
+ permissions: string[] | undefined,
179
+ reqPermissions: string[],
180
+ andComparison: boolean,
181
+ ): string[] | undefined {
182
+ if (!permissions) return
183
+ const grantedPermissions = reqPermissions.filter(p => permissions.includes(p))
184
+ const granted = andComparison
185
+ ? grantedPermissions.length === reqPermissions.length
186
+ : grantedPermissions.length > 0
187
+ return granted ? grantedPermissions : undefined
188
+ }
189
+
190
+ private getLoginHref(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): string {
191
+ return `${cfg.loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}`
192
+ }
193
+
194
+ private getLoginHtmlRedirect(href: string): string {
195
+ return `
196
+ <html>
197
+ <body>401 Authentication Required
198
+ <script>
199
+ document.write(\`: <a href="${href}" id="loginLink">Login</a>\`)
200
+ document.getElementById('loginLink').click()
201
+ </script>
202
+ </body>
203
+ </html>
204
+ `
205
+ }
206
+ }
207
+
208
+ export interface AuthServiceCfg<CTX> {
209
+ /**
210
+ * Returns the subject, or undefined if not authenticated.
211
+ */
212
+ authenticate: (req: BackendRequest) => Promise<string | undefined>
213
+
214
+ /**
215
+ * undefined - no permission set (never granted).
216
+ */
217
+ getPermissions: (subject: string, req: BackendRequest) => Promise<string[] | undefined>
218
+
219
+ /**
220
+ * true - every check is granted for this subject.
221
+ */
222
+ isAlwaysAuthorized?: (
223
+ subject: string,
224
+ permissions: string[] | undefined,
225
+ req: BackendRequest,
226
+ ) => Promisable<boolean>
227
+
228
+ /**
229
+ * Called on every `require` check that has a subject, while auth is enabled. Must not throw.
230
+ */
231
+ onCheck?: (check: AuthCheck, ctx: (CTX & AnyObject) | undefined) => void
232
+
233
+ /**
234
+ * false - every check is granted. For tests / local debugging. Read on every check.
235
+ *
236
+ * @default true
237
+ */
238
+ authEnabled?: boolean
239
+ }
240
+
241
+ export interface AuthCheck {
242
+ subject: string
243
+ granted: boolean
244
+ alwaysAuthorized: boolean
245
+ reqPermissions: string[]
246
+ /** The granted permissions, or reqPermissions if denied */
247
+ checkedPermissions: string[]
248
+ }
249
+
250
+ export interface AuthLoginRedirectMiddlewareCfg<CTX> extends AuthMiddlewareCfg<CTX> {
251
+ loginHtmlPath: string
252
+ }
253
+
254
+ export interface AuthMiddlewareCfg<CTX> {
255
+ /**
256
+ * Defaults to `resolve(req)`.
257
+ */
258
+ getResolved?: (req: BackendRequest) => Promisable<ResolvedAuth | undefined>
259
+ getCtx?: (req: BackendRequest) => (CTX & AnyObject) | undefined
260
+ /**
261
+ * @default true
262
+ */
263
+ andComparison?: boolean
264
+ /**
265
+ * A request with this header passes without auth. A wrong value responds 401.
266
+ */
267
+ secureHeader?: SecureHeaderCfg
268
+ }
269
+
270
+ export interface SecureHeaderCfg {
271
+ /** e.g. 'Authorization' */
272
+ name: string
273
+ value: string
274
+ }
275
+
276
+ export type AuthMiddleware = (reqPermissions?: string[]) => BackendRequestHandler
277
+
278
+ export interface AuthCheckOptions<CTX> {
279
+ /**
280
+ * false - one granted permission is enough.
281
+ *
282
+ * @default true
283
+ */
284
+ andComparison?: boolean
285
+ /** Passed to onCheck: the app's CTX, plus any extra keys */
286
+ ctx?: CTX & AnyObject
287
+ }
288
+
289
+ export interface PermissionInfo {
290
+ subject: string
291
+ permissions: string[]
292
+ }
293
+
294
+ type AuthenticatedAuth = ResolvedAuth & { subject: string }
295
+
296
+ export interface ResolvedAuth {
297
+ /** undefined - not authenticated */
298
+ subject?: string
299
+ /** undefined - no permission set */
300
+ permissions?: string[]
301
+ /** Result of `isAlwaysAuthorized` */
302
+ alwaysAuthorized?: boolean
303
+ }
@@ -0,0 +1 @@
1
+ export * from './auth.service.js'
@@ -31,6 +31,7 @@ export function basicAuthMiddleware(cfg: BasicAuthMiddlewareCfg): BackendRequest
31
31
  res
32
32
  .set('WWW-Authenticate', `Basic${realm ? ` realm="${realm}"` : ''}`)
33
33
  .status(401)
34
+ .type('text/plain')
34
35
  .send('Unauthorized')
35
36
  }
36
37
  }
@@ -10,9 +10,34 @@ import type { BackendRequest } from './server.model.js'
10
10
  *
11
11
  * If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
12
12
  * it will be stripped from the beginning of the path.
13
+ *
14
+ * The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
15
+ * (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
16
+ * `req.route.path` is the server-defined route pattern, so it's used as-is.
17
+ *
18
+ * Use `getRequestEndpointUnsafe` if the exact path is needed.
13
19
  */
14
20
  export function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): string {
15
- let path = (req.baseUrl + (req.route?.path || req.path)).toLowerCase()
21
+ return buildRequestEndpoint(req, stripPrefix, sanitizePath)
22
+ }
23
+
24
+ /**
25
+ * Same as `getRequestEndpoint`, but without sanitization:
26
+ * for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
27
+ *
28
+ * Use at your own risk: the result must be escaped before it's embedded in html
29
+ * (or any other context where it could be interpreted).
30
+ */
31
+ export function getRequestEndpointUnsafe(req: BackendRequest, stripPrefix?: string): string {
32
+ return buildRequestEndpoint(req, stripPrefix, identity)
33
+ }
34
+
35
+ function buildRequestEndpoint(
36
+ req: BackendRequest,
37
+ stripPrefix: string | undefined,
38
+ sanitize: (path: string) => string,
39
+ ): string {
40
+ let path = (sanitize(req.baseUrl) + (req.route?.path || sanitize(req.path))).toLowerCase()
16
41
  if (path.length > 1 && path.endsWith('/')) {
17
42
  path = path.slice(0, path.length - 1)
18
43
  }
@@ -23,3 +48,14 @@ export function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): s
23
48
 
24
49
  return [req.method, path].join(' ')
25
50
  }
51
+
52
+ // Letters, digits and the url punctuation that is inert in html, js, json and logs.
53
+ // Everything else (< > & " ' ( ) ` \ control chars, etc) is replaced, not encoded:
54
+ // the exact bytes are not worth preserving here, `req.url` still has them.
55
+ const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu
56
+
57
+ function sanitizePath(path: string): string {
58
+ return path.replace(UNSAFE_PATH_CHAR, '_')
59
+ }
60
+
61
+ const identity = (path: string): string => path
@@ -21,6 +21,14 @@ export interface RequestTimeoutMiddlewareCfg {
21
21
  * @default 'Request timed out'
22
22
  */
23
23
  httpErrorMessage?: string
24
+
25
+ /**
26
+ * Upper bound for the `?requestTimeout=N` query override.
27
+ * Higher values are clamped to it, so a client cannot hold a request open for arbitrarily long.
28
+ *
29
+ * @default 600
30
+ */
31
+ maxTimeoutSeconds?: NumberOfSeconds
24
32
  }
25
33
 
26
34
  const code = 'REQUEST_TIMEOUT'
@@ -33,6 +41,7 @@ export function requestTimeoutMiddleware(
33
41
  timeoutSeconds: defTimeoutSeconds,
34
42
  backendResponseStatusCode,
35
43
  httpErrorMessage,
44
+ maxTimeoutSeconds,
36
45
  } = {
37
46
  // Considerations about the default value of the timeout.
38
47
  // Ideally the default value here would be HIGHER than the default timeout for getGot (in nodejs-lib),
@@ -41,13 +50,12 @@ export function requestTimeoutMiddleware(
41
50
  timeoutSeconds: 120,
42
51
  backendResponseStatusCode: 503,
43
52
  httpErrorMessage: 'Request timed out',
53
+ maxTimeoutSeconds: 600,
44
54
  ...cfg,
45
55
  }
46
56
 
47
57
  return function requestTimeoutHandler(req, res, next) {
48
- const timeoutSeconds = req.query[REQUEST_TIMEOUT_QUERY_KEY]
49
- ? Number.parseInt(req.query[REQUEST_TIMEOUT_QUERY_KEY] as string)
50
- : defTimeoutSeconds
58
+ const timeoutSeconds = getTimeoutSeconds(req, defTimeoutSeconds, maxTimeoutSeconds)
51
59
 
52
60
  // If requestTimeout was previously set - cancel it first
53
61
  // Then set the new requestTimeout and handler
@@ -76,6 +84,26 @@ export function requestTimeoutMiddleware(
76
84
  }
77
85
  }
78
86
 
87
+ /**
88
+ * `?requestTimeout=N` query param lets the client override the timeout of its request
89
+ * (used e.g by Cloud Tasks for long-running tasks).
90
+ * Being client-controlled, it is validated: non-numeric or non-positive values are ignored
91
+ * (fall back to the default), values above `maxTimeoutSeconds` are clamped.
92
+ */
93
+ function getTimeoutSeconds(
94
+ req: BackendRequest,
95
+ defTimeoutSeconds: NumberOfSeconds,
96
+ maxTimeoutSeconds: NumberOfSeconds,
97
+ ): NumberOfSeconds {
98
+ const raw = req.query[REQUEST_TIMEOUT_QUERY_KEY]
99
+ if (typeof raw !== 'string') return defTimeoutSeconds
100
+
101
+ const requested = Number.parseInt(raw, 10)
102
+ if (Number.isNaN(requested) || requested <= 0) return defTimeoutSeconds
103
+
104
+ return Math.min(requested, maxTimeoutSeconds)
105
+ }
106
+
79
107
  export interface CustomRequestTimeoutMiddlewareCfg {
80
108
  /**
81
109
  * @default 120
@@ -87,7 +115,7 @@ export interface CustomRequestTimeoutMiddlewareCfg {
87
115
  * Example:
88
116
  *
89
117
  * router.get('/', customRequestTimeoutMiddleware(
90
- * (req, res) => res.status(409).send('my custom message!'),
118
+ * (req, res) => res.status(409).type('text/plain').send('my custom message!'),
91
119
  * { timeoutSeconds: 30 },
92
120
  * )
93
121
  */
@@ -5,11 +5,12 @@ 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 type { StringMap } from '@naturalcycles/js-lib/types'
10
11
  import { onFinished } from '../onFinished.js'
11
12
  import { getRequestEndpoint } from './request.util.js'
12
- import type { BackendRequestHandler } from './server.model.js'
13
+ import type { BackendRequest, BackendRequestHandler } from './server.model.js'
13
14
 
14
15
  const { GAE_INSTANCE } = process.env
15
16
 
@@ -86,7 +87,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
86
87
  }).map(([endpoint, stat]) => {
87
88
  return [
88
89
  '<tr>',
89
- `<td><pre>${endpoint}</pre></td>`,
90
+ `<td><pre>${htmlEscape(endpoint)}</pre></td>`,
90
91
  `<td align="right"><pre>${stat.total}</pre></td>`,
91
92
  // oxlint-disable-next-line @typescript-eslint/no-base-to-string
92
93
  ...families.map(f => `<td align="right"><pre>${stat[f]}</pre></td>`),
@@ -97,7 +98,7 @@ export const serverStatsHTMLHandler: BackendRequestHandler = (req, res) => {
97
98
  '</table>',
98
99
  ].join('\n')
99
100
 
100
- res.send(html)
101
+ res.type('html').send(html)
101
102
  }
102
103
 
103
104
  /**
@@ -113,7 +114,12 @@ export function serverStatsMiddleware(): BackendRequestHandler {
113
114
  const now = Date.now()
114
115
  const latency = now - started
115
116
 
116
- const endpoint = getRequestEndpoint(req)
117
+ // Only requests that matched a route are recorded by their route pattern.
118
+ // Unmatched requests (404s, requests rejected by router-level middleware) carry an
119
+ // arbitrary client-controlled path: recording them per-path would let any client
120
+ // grow the map unboundedly and plant arbitrary strings into it.
121
+ // So they're bucketed per mount point instead.
122
+ const endpoint = req.route ? getRequestEndpoint(req) : getUnmatchedEndpoint(req)
117
123
 
118
124
  serverStatsMap[endpoint] ||= {
119
125
  stack: new NumberStack(SIZE),
@@ -137,6 +143,14 @@ export function serverStatsMiddleware(): BackendRequestHandler {
137
143
  }
138
144
  }
139
145
 
146
+ /**
147
+ * Returns e.g `GET /*` or `GET /api/v3/admin/*`.
148
+ * `req.baseUrl` is the mount point that matched, so it's bounded by the app's routing config.
149
+ */
150
+ function getUnmatchedEndpoint(req: BackendRequest): string {
151
+ return `${req.method} ${req.baseUrl.toLowerCase()}/*`
152
+ }
153
+
140
154
  function cleanupServerStats(): void {
141
155
  if (Object.keys(serverStatsMap).length <= MAX_ENDPOINTS) return
142
156