@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.
- 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 +2 -0
- 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/server/basicAuthMiddleware.js +1 -0
- package/dist/server/request.util.d.ts +14 -0
- package/dist/server/request.util.js +28 -1
- package/dist/server/requestTimeoutMiddleware.d.ts +8 -1
- package/dist/server/requestTimeoutMiddleware.js +19 -5
- package/dist/server/serverStatsMiddleware.js +16 -3
- package/package.json +3 -1
- package/src/admin/adminMiddleware.ts +17 -0
- package/src/admin/base.admin.service.ts +17 -0
- package/src/admin/firebaseAuth.service.ts +139 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/login.html +16 -1
- package/src/admin/secureHeaderMiddleware.ts +5 -0
- package/src/auth/auth.service.ts +303 -0
- package/src/auth/index.ts +1 -0
- package/src/server/basicAuthMiddleware.ts +1 -0
- package/src/server/request.util.ts +37 -1
- package/src/server/requestTimeoutMiddleware.ts +32 -4
- 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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/admin/index.ts
CHANGED
package/src/admin/login.html
CHANGED
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
const qs = Object.fromEntries(new URLSearchParams(location.search))
|
|
66
|
-
const { autoLogin, logout,
|
|
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)
|