@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
@@ -1,6 +1,9 @@
1
1
  import type { BackendRequestHandler } from '../server/server.model.js';
2
2
  import type { BaseAdminService } from './base.admin.service.js';
3
3
  import type { FirebaseSharedServiceCfg } from './firebase.shared.service.js';
4
+ /**
5
+ * @deprecated Use `AuthLoginRedirectMiddlewareCfg`.
6
+ */
4
7
  export interface RequireAdminCfg {
5
8
  /**
6
9
  * @default '/login.html'
@@ -25,14 +28,28 @@ export interface RequireAdminCfg {
25
28
  */
26
29
  andComparison?: boolean;
27
30
  }
31
+ /**
32
+ * @deprecated Use `AuthMiddleware`.
33
+ */
28
34
  export type AdminMiddleware = (reqPermissions?: string[], cfg?: RequireAdminCfg) => BackendRequestHandler;
35
+ /**
36
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
37
+ */
29
38
  export declare function createAdminMiddleware(adminService: BaseAdminService, cfgDefaults?: RequireAdminCfg): AdminMiddleware;
30
39
  /**
31
40
  * Require Admin permission.
32
41
  * If not authenticated - will redirect to `loginHtmlPath` (default to /login.html).
33
42
  * If authenticated, but not authorized - will throw 403.
34
43
  * Otherwise will just pass.
44
+ *
45
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
35
46
  */
36
47
  export declare function requireAdminPermissions(adminService: BaseAdminService, reqPermissions?: string[], cfg?: RequireAdminCfg): BackendRequestHandler;
48
+ /**
49
+ * @deprecated Use `loginHtmlHandler`.
50
+ */
37
51
  export declare function loginHtml(firebaseServiceCfg: FirebaseSharedServiceCfg): BackendRequestHandler;
52
+ /**
53
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
54
+ */
38
55
  export declare function getLoginHtmlRedirect(href: string): string;
@@ -2,6 +2,9 @@ import { _memoized } from '@naturalcycles/js-lib/decorators';
2
2
  import { AppError } from '@naturalcycles/js-lib/error/error.util.js';
3
3
  import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
4
4
  import { srcDir } from '../paths.cnst.js';
5
+ /**
6
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
7
+ */
5
8
  export function createAdminMiddleware(adminService, cfgDefaults = {}) {
6
9
  return (reqPermissions, cfg) => requireAdminPermissions(adminService, reqPermissions, {
7
10
  ...cfgDefaults,
@@ -13,6 +16,8 @@ export function createAdminMiddleware(adminService, cfgDefaults = {}) {
13
16
  * If not authenticated - will redirect to `loginHtmlPath` (default to /login.html).
14
17
  * If authenticated, but not authorized - will throw 403.
15
18
  * Otherwise will just pass.
19
+ *
20
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
16
21
  */
17
22
  export function requireAdminPermissions(adminService, reqPermissions = [], cfg = {}) {
18
23
  const { loginHtmlPath = '/login.html', urlStartsWith, apiHost, autoLogin = true, andComparison = true, } = cfg;
@@ -36,6 +41,9 @@ export function requireAdminPermissions(adminService, reqPermissions = [], cfg =
36
41
  }
37
42
  };
38
43
  }
44
+ /**
45
+ * @deprecated Use `loginHtmlHandler`.
46
+ */
39
47
  export function loginHtml(firebaseServiceCfg) {
40
48
  const { apiKey: firebaseApiKey, authDomain: firebaseAuthDomain, adminAuthProvider: firebaseAuthProvider = 'GoogleAuthProvider', } = firebaseServiceCfg;
41
49
  return (_req, res) => {
@@ -54,6 +62,9 @@ const getLoginHtml = _memoized((cfg) => {
54
62
  .replaceAll('<%= firebaseAuthDomain %>', cfg.firebaseAuthDomain)
55
63
  .replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider);
56
64
  });
65
+ /**
66
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware`.
67
+ */
57
68
  export function getLoginHtmlRedirect(href) {
58
69
  return `
59
70
  <html>
@@ -3,6 +3,8 @@ import type { Auth } from 'firebase-admin/auth';
3
3
  import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js';
4
4
  /**
5
5
  * Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
6
+ *
7
+ * @deprecated Use `AuthService`, with `FirebaseAuthService` for `authenticate`.
6
8
  */
7
9
  export declare class BaseAdminService {
8
10
  private loadFirebaseAuth;
@@ -61,6 +63,9 @@ export declare class BaseAdminService {
61
63
  */
62
64
  getFirebaseAuthLoginHandler(): BackendRequestHandler;
63
65
  }
66
+ /**
67
+ * @deprecated Use `AuthServiceCfg` and `FirebaseAuthServiceCfg`.
68
+ */
64
69
  export interface AdminServiceCfg {
65
70
  /**
66
71
  * @default 'admin_token'
@@ -73,16 +78,25 @@ export interface AdminServiceCfg {
73
78
  */
74
79
  authEnabled?: boolean;
75
80
  }
81
+ /**
82
+ * @deprecated Use `PermissionInfo`.
83
+ */
76
84
  export interface AdminInfo {
77
85
  email: string;
78
86
  permissions: string[];
79
87
  }
88
+ /**
89
+ * @deprecated Use `ResolvedAuth`.
90
+ */
80
91
  export interface ResolvedAdmin {
81
92
  /** undefined - no valid admin token */
82
93
  email?: string;
83
94
  /** undefined - not an Admin */
84
95
  permissions?: Set<string>;
85
96
  }
97
+ /**
98
+ * @deprecated Use `AuthCheckOptions`.
99
+ */
86
100
  export interface CheckPermissionsOptions {
87
101
  /**
88
102
  * false - one granted permission is enough.
@@ -91,6 +105,9 @@ export interface CheckPermissionsOptions {
91
105
  */
92
106
  andComparison?: boolean;
93
107
  }
108
+ /**
109
+ * @deprecated Use `AuthService.has` / `AuthService.require`.
110
+ */
94
111
  export interface CheckPermissionsResult {
95
112
  /** undefined - no valid admin token */
96
113
  email?: string;
@@ -4,6 +4,8 @@ import { _assert } from '@naturalcycles/js-lib/error';
4
4
  import { dimGrey, green, red } from '@naturalcycles/nodejs-lib/colors';
5
5
  /**
6
6
  * Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
7
+ *
8
+ * @deprecated Use `AuthService`, with `FirebaseAuthService` for `authenticate`.
7
9
  */
8
10
  export class BaseAdminService {
9
11
  loadFirebaseAuth;
@@ -0,0 +1,41 @@
1
+ import type { Auth } from 'firebase-admin/auth';
2
+ import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js';
3
+ import type { FirebaseSharedServiceCfg } from './firebase.shared.service.js';
4
+ /**
5
+ * Verifies Firebase Auth tokens.
6
+ */
7
+ export declare class FirebaseAuthService {
8
+ private loadFirebaseAuth;
9
+ cfg: FirebaseAuthServiceCfg;
10
+ constructor(loadFirebaseAuth: () => Promise<FirebaseTokenVerifier>, cfg: FirebaseAuthServiceCfg);
11
+ /**
12
+ * Reads the configured cookie first, then the header.
13
+ */
14
+ getToken(req: BackendRequest): string | undefined;
15
+ /**
16
+ * Returns undefined if there's no token, or it's expired.
17
+ * Rejects on any other verification failure.
18
+ */
19
+ getEmailByToken(token: string | undefined): Promise<string | undefined>;
20
+ private getFirebaseAuth;
21
+ /**
22
+ * POST handler that stores the token from the `Authentication` header in `tokenCookie` (not validated).
23
+ * `Authentication: logout` clears it. The bundled login.html posts to `/admin/login`.
24
+ */
25
+ getLoginHandler(): BackendRequestHandler;
26
+ }
27
+ /**
28
+ * Serves login.html, configured for Firebase Auth.
29
+ */
30
+ export declare function loginHtmlHandler(firebaseServiceCfg: FirebaseSharedServiceCfg): BackendRequestHandler;
31
+ /**
32
+ * The part of Firebase `Auth` that FirebaseAuthService uses.
33
+ */
34
+ export type FirebaseTokenVerifier = Pick<Auth, 'verifyIdToken'>;
35
+ export interface FirebaseAuthServiceCfg {
36
+ /**
37
+ * Read by `getToken`, set by `getLoginHandler`.
38
+ */
39
+ tokenCookie?: string;
40
+ tokenHeader?: string;
41
+ }
@@ -0,0 +1,103 @@
1
+ import { __decorate } from "tslib";
2
+ import { _Memo, _memoized } from '@naturalcycles/js-lib/decorators';
3
+ import { _assert } from '@naturalcycles/js-lib/error';
4
+ import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
5
+ import { srcDir } from '../paths.cnst.js';
6
+ /**
7
+ * Verifies Firebase Auth tokens.
8
+ */
9
+ export class FirebaseAuthService {
10
+ loadFirebaseAuth;
11
+ cfg;
12
+ constructor(loadFirebaseAuth, cfg) {
13
+ this.loadFirebaseAuth = loadFirebaseAuth;
14
+ this.cfg = cfg;
15
+ }
16
+ /**
17
+ * Reads the configured cookie first, then the header.
18
+ */
19
+ getToken(req) {
20
+ const { tokenCookie, tokenHeader } = this.cfg;
21
+ if (tokenCookie) {
22
+ const token = req.cookies?.[tokenCookie];
23
+ if (token)
24
+ return token;
25
+ }
26
+ if (tokenHeader)
27
+ return req.header(tokenHeader);
28
+ }
29
+ /**
30
+ * Returns undefined if there's no token, or it's expired.
31
+ * Rejects on any other verification failure.
32
+ */
33
+ async getEmailByToken(token) {
34
+ if (!token)
35
+ return;
36
+ const auth = await this.getFirebaseAuth();
37
+ try {
38
+ const decodedToken = await auth.verifyIdToken(token);
39
+ return decodedToken.email;
40
+ }
41
+ catch (err) {
42
+ if (err?.hasCode?.('id-token-expired'))
43
+ return;
44
+ throw err;
45
+ }
46
+ }
47
+ async getFirebaseAuth() {
48
+ return await this.loadFirebaseAuth();
49
+ }
50
+ /**
51
+ * POST handler that stores the token from the `Authentication` header in `tokenCookie` (not validated).
52
+ * `Authentication: logout` clears it. The bundled login.html posts to `/admin/login`.
53
+ */
54
+ getLoginHandler() {
55
+ const { tokenCookie } = this.cfg;
56
+ _assert(tokenCookie, 'getLoginHandler requires tokenCookie to be configured');
57
+ return async (req, res) => {
58
+ const token = req.header('authentication');
59
+ _assert(token, `401 Unauthenticated`, {
60
+ userFriendly: true,
61
+ backendResponseStatusCode: 401,
62
+ });
63
+ let maxAge = 1000 * 60 * 60 * 24 * 30; // 30 days
64
+ if (token === 'logout') {
65
+ // delete the cookie
66
+ maxAge = 0;
67
+ }
68
+ res
69
+ .cookie(tokenCookie, token, {
70
+ maxAge,
71
+ sameSite: 'lax', // can be: none, lax, strict
72
+ // comment these 2 lines to debug on localhost
73
+ httpOnly: true,
74
+ secure: true,
75
+ })
76
+ .status(204)
77
+ .end();
78
+ };
79
+ }
80
+ }
81
+ __decorate([
82
+ _Memo()
83
+ ], FirebaseAuthService.prototype, "getFirebaseAuth", null);
84
+ /**
85
+ * Serves login.html, configured for Firebase Auth.
86
+ */
87
+ export function loginHtmlHandler(firebaseServiceCfg) {
88
+ const { apiKey: firebaseApiKey, authDomain: firebaseAuthDomain, adminAuthProvider: firebaseAuthProvider = 'GoogleAuthProvider', } = firebaseServiceCfg;
89
+ return (_req, res) => {
90
+ res.send(getLoginHtml({
91
+ firebaseApiKey,
92
+ firebaseAuthDomain,
93
+ firebaseAuthProvider,
94
+ }));
95
+ };
96
+ }
97
+ const getLoginHtml = _memoized((cfg) => {
98
+ return fs2
99
+ .readText(`${srcDir}/admin/login.html`)
100
+ .replaceAll('<%= firebaseApiKey %>', cfg.firebaseApiKey)
101
+ .replaceAll('<%= firebaseAuthDomain %>', cfg.firebaseAuthDomain)
102
+ .replaceAll('<%= firebaseAuthProvider %>', cfg.firebaseAuthProvider);
103
+ });
@@ -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';
@@ -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';
@@ -1,5 +1,8 @@
1
1
  import type { AdminMiddleware, RequireAdminCfg } from './adminMiddleware.js';
2
2
  import type { BaseAdminService } from './base.admin.service.js';
3
+ /**
4
+ * @deprecated Use `AuthLoginRedirectMiddlewareCfg` with `secureHeader`.
5
+ */
3
6
  export interface SecureHeaderMiddlewareCfg extends RequireAdminCfg {
4
7
  adminService: BaseAdminService;
5
8
  /**
@@ -14,5 +17,7 @@ export interface SecureHeaderMiddlewareCfg extends RequireAdminCfg {
14
17
  /**
15
18
  * Secures the endpoint by requiring a secret header to be present.
16
19
  * Throws Error401Admin otherwise.
20
+ *
21
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware` with `secureHeader`.
17
22
  */
18
23
  export declare function createSecureHeaderMiddleware(cfg: SecureHeaderMiddlewareCfg): AdminMiddleware;
@@ -4,6 +4,8 @@ import { requireAdminPermissions } from './adminMiddleware.js';
4
4
  /**
5
5
  * Secures the endpoint by requiring a secret header to be present.
6
6
  * Throws Error401Admin otherwise.
7
+ *
8
+ * @deprecated Use `AuthService.getLoginRedirectMiddleware` with `secureHeader`.
7
9
  */
8
10
  export function createSecureHeaderMiddleware(cfg) {
9
11
  return reqPermissions => requireSecureHeaderOrAdmin(cfg, reqPermissions);
@@ -0,0 +1,119 @@
1
+ import type { AnyObject, Promisable } from '@naturalcycles/js-lib/types';
2
+ import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js';
3
+ /**
4
+ * Authenticates requests and checks permissions.
5
+ */
6
+ export declare class AuthService<CTX = AnyObject> {
7
+ constructor(cfg: AuthServiceCfg<CTX>);
8
+ cfg: AuthServiceCfg<CTX> & {
9
+ authEnabled: boolean;
10
+ };
11
+ /**
12
+ * Responds JSON 401 / 403.
13
+ */
14
+ getMiddleware(cfg?: AuthMiddlewareCfg<CTX>): AuthMiddleware;
15
+ /**
16
+ * Like `getMiddleware`, but 401 redirects to the login page.
17
+ */
18
+ getLoginRedirectMiddleware(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): AuthMiddleware;
19
+ private createHandler;
20
+ /**
21
+ * Runs `authenticate`, then `getPermissions` and `isAlwaysAuthorized` if there's a subject.
22
+ * Ignores `authEnabled`.
23
+ */
24
+ resolve(req: BackendRequest): Promise<ResolvedAuth>;
25
+ /**
26
+ * Throws 401 if there's no subject, 403 if not granted.
27
+ * Returns the granted permissions (all of the subject's if alwaysAuthorized),
28
+ * or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled.
29
+ */
30
+ require(resolved: ResolvedAuth | undefined, reqPermissions?: string[], opt?: AuthCheckOptions<CTX>): PermissionInfo;
31
+ private checkSubject;
32
+ private assertAuthenticated;
33
+ private authRequiredData;
34
+ private assertGranted;
35
+ /**
36
+ * undefined - not granted.
37
+ */
38
+ private getGrantedPermissions;
39
+ private getLoginHref;
40
+ private getLoginHtmlRedirect;
41
+ }
42
+ export interface AuthServiceCfg<CTX> {
43
+ /**
44
+ * Returns the subject, or undefined if not authenticated.
45
+ */
46
+ authenticate: (req: BackendRequest) => Promise<string | undefined>;
47
+ /**
48
+ * undefined - no permission set (never granted).
49
+ */
50
+ getPermissions: (subject: string, req: BackendRequest) => Promise<string[] | undefined>;
51
+ /**
52
+ * true - every check is granted for this subject.
53
+ */
54
+ isAlwaysAuthorized?: (subject: string, permissions: string[] | undefined, req: BackendRequest) => Promisable<boolean>;
55
+ /**
56
+ * Called on every `require` check that has a subject, while auth is enabled. Must not throw.
57
+ */
58
+ onCheck?: (check: AuthCheck, ctx: (CTX & AnyObject) | undefined) => void;
59
+ /**
60
+ * false - every check is granted. For tests / local debugging. Read on every check.
61
+ *
62
+ * @default true
63
+ */
64
+ authEnabled?: boolean;
65
+ }
66
+ export interface AuthCheck {
67
+ subject: string;
68
+ granted: boolean;
69
+ alwaysAuthorized: boolean;
70
+ reqPermissions: string[];
71
+ /** The granted permissions, or reqPermissions if denied */
72
+ checkedPermissions: string[];
73
+ }
74
+ export interface AuthLoginRedirectMiddlewareCfg<CTX> extends AuthMiddlewareCfg<CTX> {
75
+ loginHtmlPath: string;
76
+ }
77
+ export interface AuthMiddlewareCfg<CTX> {
78
+ /**
79
+ * Defaults to `resolve(req)`.
80
+ */
81
+ getResolved?: (req: BackendRequest) => Promisable<ResolvedAuth | undefined>;
82
+ getCtx?: (req: BackendRequest) => (CTX & AnyObject) | undefined;
83
+ /**
84
+ * @default true
85
+ */
86
+ andComparison?: boolean;
87
+ /**
88
+ * A request with this header passes without auth. A wrong value responds 401.
89
+ */
90
+ secureHeader?: SecureHeaderCfg;
91
+ }
92
+ export interface SecureHeaderCfg {
93
+ /** e.g. 'Authorization' */
94
+ name: string;
95
+ value: string;
96
+ }
97
+ export type AuthMiddleware = (reqPermissions?: string[]) => BackendRequestHandler;
98
+ export interface AuthCheckOptions<CTX> {
99
+ /**
100
+ * false - one granted permission is enough.
101
+ *
102
+ * @default true
103
+ */
104
+ andComparison?: boolean;
105
+ /** Passed to onCheck: the app's CTX, plus any extra keys */
106
+ ctx?: CTX & AnyObject;
107
+ }
108
+ export interface PermissionInfo {
109
+ subject: string;
110
+ permissions: string[];
111
+ }
112
+ export interface ResolvedAuth {
113
+ /** undefined - not authenticated */
114
+ subject?: string;
115
+ /** undefined - no permission set */
116
+ permissions?: string[];
117
+ /** Result of `isAlwaysAuthorized` */
118
+ alwaysAuthorized?: boolean;
119
+ }
@@ -0,0 +1,147 @@
1
+ import { _assert, AppError } from '@naturalcycles/js-lib/error';
2
+ import { timingSafeStringEqual } from '@naturalcycles/nodejs-lib';
3
+ /**
4
+ * Authenticates requests and checks permissions.
5
+ */
6
+ export class AuthService {
7
+ constructor(cfg) {
8
+ this.cfg = {
9
+ authEnabled: true,
10
+ ...cfg,
11
+ };
12
+ }
13
+ cfg;
14
+ /**
15
+ * Responds JSON 401 / 403.
16
+ */
17
+ getMiddleware(cfg = {}) {
18
+ return reqPermissions => this.createHandler(cfg, reqPermissions);
19
+ }
20
+ /**
21
+ * Like `getMiddleware`, but 401 redirects to the login page.
22
+ */
23
+ getLoginRedirectMiddleware(cfg) {
24
+ const loginHref = this.getLoginHref(cfg);
25
+ return reqPermissions => this.createHandler(cfg, reqPermissions, loginHref);
26
+ }
27
+ createHandler(cfg, reqPermissions = [], loginHref) {
28
+ const { getResolved = req => this.resolve(req), getCtx, andComparison = true, secureHeader, } = cfg;
29
+ return async (req, res, next) => {
30
+ if (!this.cfg.authEnabled)
31
+ return next();
32
+ if (secureHeader) {
33
+ const providedHeader = req.get(secureHeader.name);
34
+ if (providedHeader) {
35
+ if (timingSafeStringEqual(providedHeader, secureHeader.value)) {
36
+ return next();
37
+ }
38
+ return next(new AppError('secureHeader or authentication required', this.authRequiredData()));
39
+ }
40
+ }
41
+ try {
42
+ const resolved = await getResolved(req);
43
+ if (!resolved?.subject && loginHref) {
44
+ res.status(401).send(this.getLoginHtmlRedirect(loginHref));
45
+ return;
46
+ }
47
+ this.require(resolved, reqPermissions, { andComparison, ctx: getCtx?.(req) });
48
+ return next();
49
+ }
50
+ catch (err) {
51
+ return next(err);
52
+ }
53
+ };
54
+ }
55
+ /**
56
+ * Runs `authenticate`, then `getPermissions` and `isAlwaysAuthorized` if there's a subject.
57
+ * Ignores `authEnabled`.
58
+ */
59
+ async resolve(req) {
60
+ const { authenticate, getPermissions, isAlwaysAuthorized } = this.cfg;
61
+ const subject = await authenticate(req);
62
+ if (!subject)
63
+ return {};
64
+ const permissions = await getPermissions(subject, req);
65
+ const alwaysAuthorized = isAlwaysAuthorized
66
+ ? await isAlwaysAuthorized(subject, permissions, req)
67
+ : false;
68
+ return { subject, permissions, alwaysAuthorized };
69
+ }
70
+ /**
71
+ * Throws 401 if there's no subject, 403 if not granted.
72
+ * Returns the granted permissions (all of the subject's if alwaysAuthorized),
73
+ * or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled.
74
+ */
75
+ require(resolved, reqPermissions = [], opt = {}) {
76
+ if (!this.cfg.authEnabled)
77
+ return { subject: 'authDisabled', permissions: [] };
78
+ this.assertAuthenticated(resolved);
79
+ const { subject, permissions, alwaysAuthorized } = resolved;
80
+ const grantedPermissions = this.checkSubject(resolved, reqPermissions, opt);
81
+ this.assertGranted(subject, grantedPermissions, reqPermissions);
82
+ if (alwaysAuthorized) {
83
+ return { subject, permissions: permissions?.slice() || [] };
84
+ }
85
+ return { subject, permissions: grantedPermissions.slice() };
86
+ }
87
+ checkSubject(resolved, reqPermissions, opt) {
88
+ const { subject, permissions, alwaysAuthorized = false } = resolved;
89
+ const { andComparison = true, ctx } = opt;
90
+ const grantedPermissions = alwaysAuthorized
91
+ ? reqPermissions
92
+ : this.getGrantedPermissions(permissions, reqPermissions, andComparison);
93
+ this.cfg.onCheck?.({
94
+ subject,
95
+ granted: !!grantedPermissions,
96
+ alwaysAuthorized,
97
+ reqPermissions,
98
+ checkedPermissions: grantedPermissions || reqPermissions,
99
+ }, ctx);
100
+ return grantedPermissions;
101
+ }
102
+ assertAuthenticated(resolved) {
103
+ _assert(resolved?.subject, 'authentication required', this.authRequiredData());
104
+ }
105
+ authRequiredData() {
106
+ return {
107
+ authRequired: true,
108
+ backendResponseStatusCode: 401,
109
+ userFriendly: true,
110
+ };
111
+ }
112
+ assertGranted(subject, grantedPermissions, reqPermissions) {
113
+ _assert(grantedPermissions, `Permissions required: [${reqPermissions.join(', ')}]`, {
114
+ permissionsRequired: reqPermissions,
115
+ subject,
116
+ backendResponseStatusCode: 403,
117
+ userFriendly: true,
118
+ });
119
+ }
120
+ /**
121
+ * undefined - not granted.
122
+ */
123
+ getGrantedPermissions(permissions, reqPermissions, andComparison) {
124
+ if (!permissions)
125
+ return;
126
+ const grantedPermissions = reqPermissions.filter(p => permissions.includes(p));
127
+ const granted = andComparison
128
+ ? grantedPermissions.length === reqPermissions.length
129
+ : grantedPermissions.length > 0;
130
+ return granted ? grantedPermissions : undefined;
131
+ }
132
+ getLoginHref(cfg) {
133
+ return `${cfg.loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}`;
134
+ }
135
+ getLoginHtmlRedirect(href) {
136
+ return `
137
+ <html>
138
+ <body>401 Authentication Required
139
+ <script>
140
+ document.write(\`: <a href="${href}" id="loginLink">Login</a>\`)
141
+ document.getElementById('loginLink').click()
142
+ </script>
143
+ </body>
144
+ </html>
145
+ `;
146
+ }
147
+ }
@@ -0,0 +1 @@
1
+ export * from './auth.service.js';
@@ -0,0 +1 @@
1
+ export * from './auth.service.js';
@@ -14,6 +14,7 @@ export function basicAuthMiddleware(cfg) {
14
14
  res
15
15
  .set('WWW-Authenticate', `Basic${realm ? ` realm="${realm}"` : ''}`)
16
16
  .status(401)
17
+ .type('text/plain')
17
18
  .send('Unauthorized');
18
19
  };
19
20
  }
@@ -9,5 +9,19 @@ import type { BackendRequest } from './server.model.js';
9
9
  *
10
10
  * If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
11
11
  * it will be stripped from the beginning of the path.
12
+ *
13
+ * The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
14
+ * (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
15
+ * `req.route.path` is the server-defined route pattern, so it's used as-is.
16
+ *
17
+ * Use `getRequestEndpointUnsafe` if the exact path is needed.
12
18
  */
13
19
  export declare function getRequestEndpoint(req: BackendRequest, stripPrefix?: string): string;
20
+ /**
21
+ * Same as `getRequestEndpoint`, but without sanitization:
22
+ * for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
23
+ *
24
+ * Use at your own risk: the result must be escaped before it's embedded in html
25
+ * (or any other context where it could be interpreted).
26
+ */
27
+ export declare function getRequestEndpointUnsafe(req: BackendRequest, stripPrefix?: string): string;
@@ -8,9 +8,28 @@
8
8
  *
9
9
  * If stripPrefix (e.g `/api/v2`) is provided, and the path starts with it (like path.startsWith(stripPrefix)),
10
10
  * it will be stripped from the beginning of the path.
11
+ *
12
+ * The result is injection-safe: `req.baseUrl` and `req.path` come from the request as-is
13
+ * (not even url-decoded), so every character outside of a conservative allowlist is replaced with `_`.
14
+ * `req.route.path` is the server-defined route pattern, so it's used as-is.
15
+ *
16
+ * Use `getRequestEndpointUnsafe` if the exact path is needed.
11
17
  */
12
18
  export function getRequestEndpoint(req, stripPrefix) {
13
- let path = (req.baseUrl + (req.route?.path || req.path)).toLowerCase();
19
+ return buildRequestEndpoint(req, stripPrefix, sanitizePath);
20
+ }
21
+ /**
22
+ * Same as `getRequestEndpoint`, but without sanitization:
23
+ * for requests that didn't match a route (e.g 404s) the path is returned exactly as the client sent it.
24
+ *
25
+ * Use at your own risk: the result must be escaped before it's embedded in html
26
+ * (or any other context where it could be interpreted).
27
+ */
28
+ export function getRequestEndpointUnsafe(req, stripPrefix) {
29
+ return buildRequestEndpoint(req, stripPrefix, identity);
30
+ }
31
+ function buildRequestEndpoint(req, stripPrefix, sanitize) {
32
+ let path = (sanitize(req.baseUrl) + (req.route?.path || sanitize(req.path))).toLowerCase();
14
33
  if (path.length > 1 && path.endsWith('/')) {
15
34
  path = path.slice(0, path.length - 1);
16
35
  }
@@ -19,3 +38,11 @@ export function getRequestEndpoint(req, stripPrefix) {
19
38
  }
20
39
  return [req.method, path].join(' ');
21
40
  }
41
+ // Letters, digits and the url punctuation that is inert in html, js, json and logs.
42
+ // Everything else (< > & " ' ( ) ` \ control chars, etc) is replaced, not encoded:
43
+ // the exact bytes are not worth preserving here, `req.url` still has them.
44
+ const UNSAFE_PATH_CHAR = /[^\w\-./:@%!$*+,;=~]/gu;
45
+ function sanitizePath(path) {
46
+ return path.replace(UNSAFE_PATH_CHAR, '_');
47
+ }
48
+ const identity = (path) => path;