@naturalcycles/backend-lib 9.72.0 → 9.73.1

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,21 +1,6 @@
1
+ import type { AnyObject } from '@naturalcycles/js-lib/types';
1
2
  import type { Auth } from 'firebase-admin/auth';
2
3
  import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js';
3
- export interface AdminServiceCfg {
4
- /**
5
- * @default 'admin_token'
6
- */
7
- adminTokenKey?: string;
8
- /**
9
- * If false - disables auth completely (useful for debugging locally, but never in production).
10
- *
11
- * @default true
12
- */
13
- authEnabled?: boolean;
14
- }
15
- export interface AdminInfo {
16
- email: string;
17
- permissions: string[];
18
- }
19
4
  /**
20
5
  * Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
21
6
  */
@@ -24,35 +9,47 @@ export declare class BaseAdminService {
24
9
  constructor(loadFirebaseAuth: () => Promise<Auth>, cfg: AdminServiceCfg);
25
10
  cfg: Required<AdminServiceCfg>;
26
11
  adminInfoDisabled(): AdminInfo;
12
+ isAdmin(req: BackendRequest | undefined): Promise<boolean>;
13
+ getAdminInfo(req: BackendRequest): Promise<AdminInfo | undefined>;
14
+ hasPermission(req: BackendRequest, reqPermission: string, meta?: AnyObject): Promise<boolean>;
15
+ requirePermission(req: BackendRequest, reqPermission: string, meta?: AnyObject): Promise<AdminInfo>;
27
16
  /**
28
- * To be extended.
29
- *
30
- * Returns undefined if it's not an Admin.
31
- * Otherwise returns Set of permissions.
32
- * Empty array means it IS and Admin, but has no permissions (except being an Admin).
17
+ * Returns AdminInfo if it has all required permissions.
18
+ * Otherwise returns undefined
33
19
  */
34
- getEmailPermissions(email?: string): Promise<Set<string> | undefined>;
20
+ hasPermissions(req: BackendRequest, reqPermissions?: string[], meta?: AnyObject): Promise<AdminInfo | undefined>;
21
+ requirePermissions(req: BackendRequest, reqPermissions?: string[], meta?: AnyObject, andComparison?: boolean): Promise<AdminInfo>;
35
22
  /**
36
- * To be extended.
23
+ * Doesn't check permissions, nor `authEnabled`. See `checkPermissions`.
37
24
  */
38
- protected onPermissionCheck(req: BackendRequest, email: string, reqPermissions: string[], required: boolean, granted: boolean, meta?: Record<string, any>): Promise<void>;
39
- getEmailByToken(req: BackendRequest, adminToken?: string): Promise<string | undefined>;
40
- private getFirebaseAuth;
25
+ resolveAdmin(req: BackendRequest): Promise<ResolvedAdmin>;
26
+ /**
27
+ * Doesn't log or throw. See `requireGranted`.
28
+ */
29
+ checkPermissions(admin: ResolvedAdmin, reqPermissions?: string[], opt?: CheckPermissionsOptions): CheckPermissionsResult;
30
+ /**
31
+ * Throws 401 if there's no email, 403 if not granted.
32
+ */
33
+ requireGranted(result: CheckPermissionsResult): AdminInfo;
41
34
  /**
42
35
  * Current implementation is based on req=Request (from Express).
43
36
  * Override if needed.
44
37
  */
45
38
  getAdminToken(req: BackendRequest): string | undefined;
46
- isAdmin(req: BackendRequest | undefined): Promise<boolean>;
47
- getAdminInfo(req: BackendRequest): Promise<AdminInfo | undefined>;
39
+ getEmailByToken(req: BackendRequest, adminToken?: string): Promise<string | undefined>;
40
+ private getFirebaseAuth;
48
41
  /**
49
- * Returns AdminInfo if it has all required permissions.
50
- * Otherwise returns undefined
42
+ * To be extended.
43
+ *
44
+ * Returns undefined if it's not an Admin.
45
+ * Otherwise returns Set of permissions.
46
+ * Empty array means it IS and Admin, but has no permissions (except being an Admin).
47
+ */
48
+ getEmailPermissions(email?: string): Promise<Set<string> | undefined>;
49
+ /**
50
+ * To be extended.
51
51
  */
52
- hasPermissions(req: BackendRequest, reqPermissions?: string[], meta?: Record<string, any>): Promise<AdminInfo | undefined>;
53
- requirePermissions(req: BackendRequest, reqPermissions?: string[], meta?: Record<string, any>, andComparison?: boolean): Promise<AdminInfo>;
54
- hasPermission(req: BackendRequest, reqPermission: string, meta?: Record<string, any>): Promise<boolean>;
55
- requirePermission(req: BackendRequest, reqPermission: string, meta?: Record<string, any>): Promise<AdminInfo>;
52
+ protected onPermissionCheck(req: BackendRequest, email: string, reqPermissions: string[], required: boolean, granted: boolean, meta?: AnyObject): Promise<void>;
56
53
  /**
57
54
  * Install it on POST /admin/login url
58
55
  *
@@ -64,3 +61,42 @@ export declare class BaseAdminService {
64
61
  */
65
62
  getFirebaseAuthLoginHandler(): BackendRequestHandler;
66
63
  }
64
+ export interface AdminServiceCfg {
65
+ /**
66
+ * @default 'admin_token'
67
+ */
68
+ adminTokenKey?: string;
69
+ /**
70
+ * If false - disables auth completely (useful for debugging locally, but never in production).
71
+ *
72
+ * @default true
73
+ */
74
+ authEnabled?: boolean;
75
+ }
76
+ export interface AdminInfo {
77
+ email: string;
78
+ permissions: string[];
79
+ }
80
+ export interface ResolvedAdmin {
81
+ /** undefined - no valid admin token */
82
+ email?: string;
83
+ /** undefined - not an Admin */
84
+ permissions?: Set<string>;
85
+ }
86
+ export interface CheckPermissionsOptions {
87
+ /**
88
+ * false - one granted permission is enough.
89
+ *
90
+ * @default true
91
+ */
92
+ andComparison?: boolean;
93
+ }
94
+ export interface CheckPermissionsResult {
95
+ /** undefined - no valid admin token */
96
+ email?: string;
97
+ isAdmin: boolean;
98
+ granted: boolean;
99
+ reqPermissions: string[];
100
+ grantedPermissions: string[];
101
+ authDisabled: boolean;
102
+ }
@@ -1,11 +1,7 @@
1
1
  import { __decorate } from "tslib";
2
2
  import { _Memo } from '@naturalcycles/js-lib/decorators';
3
- import { _assert, AppError } from '@naturalcycles/js-lib/error';
3
+ import { _assert } from '@naturalcycles/js-lib/error';
4
4
  import { dimGrey, green, red } from '@naturalcycles/nodejs-lib/colors';
5
- const adminInfoDisabled = () => ({
6
- email: 'authDisabled',
7
- permissions: [],
8
- });
9
5
  /**
10
6
  * Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
11
7
  */
@@ -26,64 +22,11 @@ export class BaseAdminService {
26
22
  permissions: [],
27
23
  };
28
24
  }
29
- /**
30
- * To be extended.
31
- *
32
- * Returns undefined if it's not an Admin.
33
- * Otherwise returns Set of permissions.
34
- * Empty array means it IS and Admin, but has no permissions (except being an Admin).
35
- */
36
- async getEmailPermissions(email) {
37
- if (!email)
38
- return;
39
- console.log(`getEmailPermissions (${dimGrey(email)}) returning undefined (please override the implementation)`);
40
- }
41
- /**
42
- * To be extended.
43
- */
44
- // oxlint-disable-next-line max-params
45
- async onPermissionCheck(req, email, reqPermissions, required, granted, meta = {}) {
46
- req.log(`${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey(reqPermissions.join(', '))}]: ${granted ? green('GRANTED') : red('DENIED')}`, meta);
47
- }
48
- async getEmailByToken(req, adminToken) {
49
- if (!adminToken)
50
- return;
51
- try {
52
- const auth = await this.getFirebaseAuth();
53
- const decodedToken = await auth.verifyIdToken(adminToken);
54
- const email = decodedToken?.email;
55
- req.log(`admin email: ${dimGrey(email)}`);
56
- return email;
57
- }
58
- catch (err) {
59
- // example:
60
- // FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired).
61
- if (
62
- // err instanceof FirebaseAuthError && err.hasCode('id-token-expired')
63
- err?.code?.includes('id-token-expired')) {
64
- return; // skip logging, expected error
65
- }
66
- req.error(`getEmailByToken error:`, err);
67
- }
68
- }
69
- async getFirebaseAuth() {
70
- return await this.loadFirebaseAuth();
71
- }
72
- /**
73
- * Current implementation is based on req=Request (from Express).
74
- * Override if needed.
75
- */
76
- getAdminToken(req) {
77
- return (req.cookies?.[this.cfg.adminTokenKey] ||
78
- req.header(this.cfg.adminTokenKey) ||
79
- req.header('x-admin-token'));
80
- }
81
25
  async isAdmin(req) {
82
26
  if (!req)
83
27
  return false;
84
- const adminToken = this.getAdminToken(req);
85
- const email = await this.getEmailByToken(req, adminToken);
86
- return !!(await this.getEmailPermissions(email));
28
+ const { permissions } = await this.resolveAdmin(req);
29
+ return !!permissions;
87
30
  }
88
31
  async getAdminInfo(req) {
89
32
  return await this.hasPermissions(req);
@@ -92,6 +35,14 @@ export class BaseAdminService {
92
35
  // async reqAdmin (req: Request): Promise<void> {
93
36
  // await this.reqPermissions(req)
94
37
  // }
38
+ // convenience method
39
+ async hasPermission(req, reqPermission, meta) {
40
+ const adminInfo = await this.hasPermissions(req, [reqPermission], meta);
41
+ return !!adminInfo;
42
+ }
43
+ async requirePermission(req, reqPermission, meta) {
44
+ return await this.requirePermissions(req, [reqPermission], meta);
45
+ }
95
46
  /**
96
47
  * Returns AdminInfo if it has all required permissions.
97
48
  * Otherwise returns undefined
@@ -99,70 +50,148 @@ export class BaseAdminService {
99
50
  async hasPermissions(req, reqPermissions = [], meta = {}) {
100
51
  if (!this.cfg.authEnabled)
101
52
  return adminInfoDisabled();
102
- const adminToken = this.getAdminToken(req);
103
- const email = await this.getEmailByToken(req, adminToken);
104
- const hasPermissions = await this.getEmailPermissions(email);
105
- if (!hasPermissions)
53
+ const admin = await this.resolveAdmin(req);
54
+ const { email, permissions } = admin;
55
+ if (!email || !permissions)
106
56
  return;
107
- const granted = reqPermissions.every(p => hasPermissions.has(p));
108
- void this.onPermissionCheck(req, email, reqPermissions, false, granted, meta);
109
- if (!granted)
57
+ const result = this.checkPermissions(admin, reqPermissions);
58
+ void this.onPermissionCheck(req, email, reqPermissions, false, result.granted, meta);
59
+ if (!result.granted)
110
60
  return;
111
61
  return {
112
- email: email,
113
- permissions: Array.from(hasPermissions),
62
+ email,
63
+ permissions: Array.from(permissions),
114
64
  };
115
65
  }
116
66
  async requirePermissions(req, reqPermissions = [], meta = {}, andComparison = true) {
117
67
  if (!this.cfg.authEnabled)
118
68
  return adminInfoDisabled();
69
+ const admin = await this.resolveAdmin(req);
70
+ const result = this.checkPermissions(admin, reqPermissions, { andComparison });
71
+ if (result.email) {
72
+ // Log only the granted permissions (differs from reqPermissions only on OR-comparison)
73
+ const checkedPermissions = result.granted ? result.grantedPermissions : reqPermissions;
74
+ void this.onPermissionCheck(req, result.email, checkedPermissions, true, result.granted, meta);
75
+ }
76
+ return this.requireGranted(result);
77
+ }
78
+ /**
79
+ * Doesn't check permissions, nor `authEnabled`. See `checkPermissions`.
80
+ */
81
+ async resolveAdmin(req) {
119
82
  const adminToken = this.getAdminToken(req);
120
83
  const email = await this.getEmailByToken(req, adminToken);
121
- if (!email) {
122
- throw new AppError('adminToken required', {
123
- adminAuthRequired: true,
124
- backendResponseStatusCode: 401,
125
- userFriendly: true,
126
- });
84
+ if (!email)
85
+ return {};
86
+ const permissions = await this.getEmailPermissions(email);
87
+ return { email, permissions };
88
+ }
89
+ /**
90
+ * Doesn't log or throw. See `requireGranted`.
91
+ */
92
+ checkPermissions(admin, reqPermissions = [], opt = {}) {
93
+ const { andComparison = true } = opt;
94
+ const { email, permissions: hasPermissions } = admin;
95
+ const isAdmin = !!hasPermissions;
96
+ if (!this.cfg.authEnabled) {
97
+ return {
98
+ email,
99
+ isAdmin,
100
+ granted: true,
101
+ reqPermissions,
102
+ grantedPermissions: [],
103
+ authDisabled: true,
104
+ };
127
105
  }
128
- const hasPermissions = await this.getEmailPermissions(email);
129
106
  const grantedPermissions = hasPermissions
130
107
  ? reqPermissions.filter(p => hasPermissions.has(p))
131
108
  : [];
132
- let granted;
133
- if (andComparison) {
134
- granted = !!hasPermissions && grantedPermissions.length === reqPermissions.length; // All permissions granted
135
- void this.onPermissionCheck(req, email, reqPermissions, true, granted, meta);
136
- }
137
- else {
138
- granted = !!hasPermissions && grantedPermissions.length > 0;
139
- if (granted) {
140
- // Require the permission(s), but only the ones the user was actually granted. 1+ is required
141
- void this.onPermissionCheck(req, email, grantedPermissions, true, granted, meta);
142
- }
143
- else {
144
- void this.onPermissionCheck(req, email, reqPermissions, true, granted, meta);
145
- }
146
- }
147
- if (!granted) {
148
- throw new AppError(`Admin permissions required: [${reqPermissions.join(', ')}]`, {
149
- adminPermissionsRequired: reqPermissions,
150
- email,
151
- backendResponseStatusCode: 403,
152
- userFriendly: true,
153
- });
154
- }
109
+ const granted = andComparison
110
+ ? isAdmin && grantedPermissions.length === reqPermissions.length // All permissions granted
111
+ : isAdmin && grantedPermissions.length > 0; // 1+ is required
112
+ return {
113
+ email,
114
+ isAdmin,
115
+ granted,
116
+ reqPermissions,
117
+ grantedPermissions,
118
+ authDisabled: false,
119
+ };
120
+ }
121
+ /**
122
+ * Throws 401 if there's no email, 403 if not granted.
123
+ */
124
+ requireGranted(result) {
125
+ if (result.authDisabled)
126
+ return adminInfoDisabled();
127
+ const { email, granted, reqPermissions, grantedPermissions } = result;
128
+ _assert(email, 'adminToken required', {
129
+ adminAuthRequired: true,
130
+ backendResponseStatusCode: 401,
131
+ userFriendly: true,
132
+ });
133
+ _assert(granted, `Admin permissions required: [${reqPermissions.join(', ')}]`, {
134
+ adminPermissionsRequired: reqPermissions,
135
+ email,
136
+ backendResponseStatusCode: 403,
137
+ userFriendly: true,
138
+ });
155
139
  return {
156
140
  email,
157
141
  permissions: grantedPermissions.slice(),
158
142
  };
159
143
  }
160
- // convenience method
161
- async hasPermission(req, reqPermission, meta) {
162
- return !!(await this.hasPermissions(req, [reqPermission], meta));
144
+ /**
145
+ * Current implementation is based on req=Request (from Express).
146
+ * Override if needed.
147
+ */
148
+ getAdminToken(req) {
149
+ return (req.cookies?.[this.cfg.adminTokenKey] ||
150
+ req.header(this.cfg.adminTokenKey) ||
151
+ req.header('x-admin-token'));
163
152
  }
164
- async requirePermission(req, reqPermission, meta) {
165
- return await this.requirePermissions(req, [reqPermission], meta);
153
+ async getEmailByToken(req, adminToken) {
154
+ if (!adminToken)
155
+ return;
156
+ try {
157
+ const auth = await this.getFirebaseAuth();
158
+ const decodedToken = await auth.verifyIdToken(adminToken);
159
+ const email = decodedToken?.email;
160
+ req.log(`admin email: ${dimGrey(email)}`);
161
+ return email;
162
+ }
163
+ catch (err) {
164
+ // example:
165
+ // FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired).
166
+ if (
167
+ // err instanceof FirebaseAuthError && err.hasCode('id-token-expired')
168
+ err?.code?.includes('id-token-expired')) {
169
+ return; // skip logging, expected error
170
+ }
171
+ req.error(`getEmailByToken error:`, err);
172
+ }
173
+ }
174
+ async getFirebaseAuth() {
175
+ return await this.loadFirebaseAuth();
176
+ }
177
+ /**
178
+ * To be extended.
179
+ *
180
+ * Returns undefined if it's not an Admin.
181
+ * Otherwise returns Set of permissions.
182
+ * Empty array means it IS and Admin, but has no permissions (except being an Admin).
183
+ */
184
+ async getEmailPermissions(email) {
185
+ if (!email)
186
+ return;
187
+ console.log(`getEmailPermissions (${dimGrey(email)}) returning undefined (please override the implementation)`);
188
+ }
189
+ /**
190
+ * To be extended.
191
+ */
192
+ // oxlint-disable-next-line max-params
193
+ async onPermissionCheck(req, email, reqPermissions, required, granted, meta = {}) {
194
+ req.log(`${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey(reqPermissions.join(', '))}]: ${granted ? green('GRANTED') : red('DENIED')}`, meta);
166
195
  }
167
196
  /**
168
197
  * Install it on POST /admin/login url
@@ -202,3 +231,7 @@ export class BaseAdminService {
202
231
  __decorate([
203
232
  _Memo()
204
233
  ], BaseAdminService.prototype, "getFirebaseAuth", null);
234
+ const adminInfoDisabled = () => ({
235
+ email: 'authDisabled',
236
+ permissions: [],
237
+ });
@@ -2,7 +2,6 @@ import { asyncLocalStorageMiddleware } from '../server/asyncLocalStorageMiddlewa
2
2
  import { compressionMiddleware } from '../server/compressionMiddleware.js';
3
3
  import { genericErrorMiddleware } from '../server/genericErrorMiddleware.js';
4
4
  import { logMiddleware } from '../server/logMiddleware.js';
5
- import { methodOverrideMiddleware } from '../server/methodOverrideMiddleware.js';
6
5
  import { notFoundMiddleware } from '../server/notFoundMiddleware.js';
7
6
  import { requestTimeoutMiddleware } from '../server/requestTimeoutMiddleware.js';
8
7
  import { simpleRequestLoggerMiddleware } from '../server/simpleRequestLoggerMiddleware.js';
@@ -24,7 +23,6 @@ export async function createDefaultApp(cfg) {
24
23
  if (!isTest) {
25
24
  app.use(asyncLocalStorageMiddleware());
26
25
  }
27
- app.use(methodOverrideMiddleware());
28
26
  app.use(requestTimeoutMiddleware());
29
27
  // app.use(serverStatsMiddleware()) // disabled by default
30
28
  // app.use(bodyParserTimeout()) // removed by default
package/dist/index.d.ts CHANGED
@@ -7,7 +7,6 @@ export * from './server/compressionMiddleware.js';
7
7
  export * from './server/eventLoop.util.js';
8
8
  export * from './server/genericErrorMiddleware.js';
9
9
  export * from './server/logMiddleware.js';
10
- export * from './server/methodOverrideMiddleware.js';
11
10
  export * from './server/notFoundMiddleware.js';
12
11
  export * from './server/okMiddleware.js';
13
12
  export * from './server/request.log.util.js';
package/dist/index.js CHANGED
@@ -7,7 +7,6 @@ export * from './server/compressionMiddleware.js';
7
7
  export * from './server/eventLoop.util.js';
8
8
  export * from './server/genericErrorMiddleware.js';
9
9
  export * from './server/logMiddleware.js';
10
- export * from './server/methodOverrideMiddleware.js';
11
10
  export * from './server/notFoundMiddleware.js';
12
11
  export * from './server/okMiddleware.js';
13
12
  export * from './server/request.log.util.js';
@@ -1,6 +1,9 @@
1
1
  import { getRequestEndpoint } from './request.util.js';
2
2
  export function notFoundMiddleware() {
3
3
  return (req, res) => {
4
- res.status(404).send(`404 Not Found: ${getRequestEndpoint(req)}`);
4
+ res
5
+ .status(404)
6
+ .type('text/plain')
7
+ .send(`404 Not Found: ${getRequestEndpoint(req)}`);
5
8
  };
6
9
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.72.0",
4
+ "version": "9.73.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -1,33 +1,10 @@
1
1
  import { _Memo } from '@naturalcycles/js-lib/decorators'
2
- import { _assert, AppError } from '@naturalcycles/js-lib/error'
2
+ import { _assert } from '@naturalcycles/js-lib/error'
3
+ import type { AnyObject } from '@naturalcycles/js-lib/types'
3
4
  import { dimGrey, green, red } from '@naturalcycles/nodejs-lib/colors'
4
5
  import type { Auth } from 'firebase-admin/auth'
5
6
  import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js'
6
7
 
7
- export interface AdminServiceCfg {
8
- /**
9
- * @default 'admin_token'
10
- */
11
- adminTokenKey?: string
12
-
13
- /**
14
- * If false - disables auth completely (useful for debugging locally, but never in production).
15
- *
16
- * @default true
17
- */
18
- authEnabled?: boolean
19
- }
20
-
21
- export interface AdminInfo {
22
- email: string
23
- permissions: string[]
24
- }
25
-
26
- const adminInfoDisabled = (): AdminInfo => ({
27
- email: 'authDisabled',
28
- permissions: [],
29
- })
30
-
31
8
  /**
32
9
  * Base implementation based on Firebase Auth tokens passed as 'admin_token' cookie.
33
10
  */
@@ -52,87 +29,10 @@ export class BaseAdminService {
52
29
  }
53
30
  }
54
31
 
55
- /**
56
- * To be extended.
57
- *
58
- * Returns undefined if it's not an Admin.
59
- * Otherwise returns Set of permissions.
60
- * Empty array means it IS and Admin, but has no permissions (except being an Admin).
61
- */
62
- async getEmailPermissions(email?: string): Promise<Set<string> | undefined> {
63
- if (!email) return
64
- console.log(
65
- `getEmailPermissions (${dimGrey(
66
- email,
67
- )}) returning undefined (please override the implementation)`,
68
- )
69
- }
70
-
71
- /**
72
- * To be extended.
73
- */
74
- // oxlint-disable-next-line max-params
75
- protected async onPermissionCheck(
76
- req: BackendRequest,
77
- email: string,
78
- reqPermissions: string[],
79
- required: boolean,
80
- granted: boolean,
81
- meta: Record<string, any> = {},
82
- ): Promise<void> {
83
- req.log(
84
- `${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey(
85
- reqPermissions.join(', '),
86
- )}]: ${granted ? green('GRANTED') : red('DENIED')}`,
87
- meta,
88
- )
89
- }
90
-
91
- async getEmailByToken(req: BackendRequest, adminToken?: string): Promise<string | undefined> {
92
- if (!adminToken) return
93
-
94
- try {
95
- const auth = await this.getFirebaseAuth()
96
- const decodedToken = await auth.verifyIdToken(adminToken)
97
- const email = decodedToken?.email
98
- req.log(`admin email: ${dimGrey(email)}`)
99
- return email
100
- } catch (err) {
101
- // example:
102
- // FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired).
103
- if (
104
- // err instanceof FirebaseAuthError && err.hasCode('id-token-expired')
105
- (err as any)?.code?.includes('id-token-expired')
106
- ) {
107
- return // skip logging, expected error
108
- }
109
-
110
- req.error(`getEmailByToken error:`, err)
111
- }
112
- }
113
-
114
- @_Memo()
115
- private async getFirebaseAuth(): Promise<Auth> {
116
- return await this.loadFirebaseAuth()
117
- }
118
-
119
- /**
120
- * Current implementation is based on req=Request (from Express).
121
- * Override if needed.
122
- */
123
- getAdminToken(req: BackendRequest): string | undefined {
124
- return (
125
- req.cookies?.[this.cfg.adminTokenKey] ||
126
- req.header(this.cfg.adminTokenKey) ||
127
- req.header('x-admin-token')
128
- )
129
- }
130
-
131
32
  async isAdmin(req: BackendRequest | undefined): Promise<boolean> {
132
33
  if (!req) return false
133
- const adminToken = this.getAdminToken(req)
134
- const email = await this.getEmailByToken(req, adminToken)
135
- return !!(await this.getEmailPermissions(email))
34
+ const { permissions } = await this.resolveAdmin(req)
35
+ return !!permissions
136
36
  }
137
37
 
138
38
  async getAdminInfo(req: BackendRequest): Promise<AdminInfo | undefined> {
@@ -144,6 +44,24 @@ export class BaseAdminService {
144
44
  // await this.reqPermissions(req)
145
45
  // }
146
46
 
47
+ // convenience method
48
+ async hasPermission(
49
+ req: BackendRequest,
50
+ reqPermission: string,
51
+ meta?: AnyObject,
52
+ ): Promise<boolean> {
53
+ const adminInfo = await this.hasPermissions(req, [reqPermission], meta)
54
+ return !!adminInfo
55
+ }
56
+
57
+ async requirePermission(
58
+ req: BackendRequest,
59
+ reqPermission: string,
60
+ meta?: AnyObject,
61
+ ): Promise<AdminInfo> {
62
+ return await this.requirePermissions(req, [reqPermission], meta)
63
+ }
64
+
147
65
  /**
148
66
  * Returns AdminInfo if it has all required permissions.
149
67
  * Otherwise returns undefined
@@ -151,73 +69,119 @@ export class BaseAdminService {
151
69
  async hasPermissions(
152
70
  req: BackendRequest,
153
71
  reqPermissions: string[] = [],
154
- meta: Record<string, any> = {},
72
+ meta: AnyObject = {},
155
73
  ): Promise<AdminInfo | undefined> {
156
74
  if (!this.cfg.authEnabled) return adminInfoDisabled()
157
75
 
158
- const adminToken = this.getAdminToken(req)
159
- const email = await this.getEmailByToken(req, adminToken)
160
- const hasPermissions = await this.getEmailPermissions(email)
161
- if (!hasPermissions) return
76
+ const admin = await this.resolveAdmin(req)
77
+ const { email, permissions } = admin
78
+ if (!email || !permissions) return
162
79
 
163
- const granted = reqPermissions.every(p => hasPermissions.has(p))
80
+ const result = this.checkPermissions(admin, reqPermissions)
164
81
 
165
- void this.onPermissionCheck(req, email!, reqPermissions, false, granted, meta)
82
+ void this.onPermissionCheck(req, email, reqPermissions, false, result.granted, meta)
166
83
 
167
- if (!granted) return
84
+ if (!result.granted) return
168
85
 
169
86
  return {
170
- email: email!,
171
- permissions: Array.from(hasPermissions),
87
+ email,
88
+ permissions: Array.from(permissions),
172
89
  }
173
90
  }
174
91
 
175
92
  async requirePermissions(
176
93
  req: BackendRequest,
177
94
  reqPermissions: string[] = [],
178
- meta: Record<string, any> = {},
95
+ meta: AnyObject = {},
179
96
  andComparison = true,
180
97
  ): Promise<AdminInfo> {
181
98
  if (!this.cfg.authEnabled) return adminInfoDisabled()
182
99
 
100
+ const admin = await this.resolveAdmin(req)
101
+ const result = this.checkPermissions(admin, reqPermissions, { andComparison })
102
+
103
+ if (result.email) {
104
+ // Log only the granted permissions (differs from reqPermissions only on OR-comparison)
105
+ const checkedPermissions = result.granted ? result.grantedPermissions : reqPermissions
106
+ void this.onPermissionCheck(req, result.email, checkedPermissions, true, result.granted, meta)
107
+ }
108
+
109
+ return this.requireGranted(result)
110
+ }
111
+
112
+ /**
113
+ * Doesn't check permissions, nor `authEnabled`. See `checkPermissions`.
114
+ */
115
+ async resolveAdmin(req: BackendRequest): Promise<ResolvedAdmin> {
183
116
  const adminToken = this.getAdminToken(req)
184
117
  const email = await this.getEmailByToken(req, adminToken)
118
+ if (!email) return {}
185
119
 
186
- if (!email) {
187
- throw new AppError('adminToken required', {
188
- adminAuthRequired: true,
189
- backendResponseStatusCode: 401,
190
- userFriendly: true,
191
- })
120
+ const permissions = await this.getEmailPermissions(email)
121
+ return { email, permissions }
122
+ }
123
+
124
+ /**
125
+ * Doesn't log or throw. See `requireGranted`.
126
+ */
127
+ checkPermissions(
128
+ admin: ResolvedAdmin,
129
+ reqPermissions: string[] = [],
130
+ opt: CheckPermissionsOptions = {},
131
+ ): CheckPermissionsResult {
132
+ const { andComparison = true } = opt
133
+ const { email, permissions: hasPermissions } = admin
134
+ const isAdmin = !!hasPermissions
135
+
136
+ if (!this.cfg.authEnabled) {
137
+ return {
138
+ email,
139
+ isAdmin,
140
+ granted: true,
141
+ reqPermissions,
142
+ grantedPermissions: [],
143
+ authDisabled: true,
144
+ }
192
145
  }
193
146
 
194
- const hasPermissions = await this.getEmailPermissions(email)
195
147
  const grantedPermissions = hasPermissions
196
148
  ? reqPermissions.filter(p => hasPermissions.has(p))
197
149
  : []
198
150
 
199
- let granted: boolean
200
- if (andComparison) {
201
- granted = !!hasPermissions && grantedPermissions.length === reqPermissions.length // All permissions granted
202
- void this.onPermissionCheck(req, email, reqPermissions, true, granted, meta)
203
- } else {
204
- granted = !!hasPermissions && grantedPermissions.length > 0
205
- if (granted) {
206
- // Require the permission(s), but only the ones the user was actually granted. 1+ is required
207
- void this.onPermissionCheck(req, email, grantedPermissions, true, granted, meta)
208
- } else {
209
- void this.onPermissionCheck(req, email, reqPermissions, true, granted, meta)
210
- }
211
- }
151
+ const granted = andComparison
152
+ ? isAdmin && grantedPermissions.length === reqPermissions.length // All permissions granted
153
+ : isAdmin && grantedPermissions.length > 0 // 1+ is required
212
154
 
213
- if (!granted) {
214
- throw new AppError(`Admin permissions required: [${reqPermissions.join(', ')}]`, {
215
- adminPermissionsRequired: reqPermissions,
216
- email,
217
- backendResponseStatusCode: 403,
218
- userFriendly: true,
219
- })
155
+ return {
156
+ email,
157
+ isAdmin,
158
+ granted,
159
+ reqPermissions,
160
+ grantedPermissions,
161
+ authDisabled: false,
220
162
  }
163
+ }
164
+
165
+ /**
166
+ * Throws 401 if there's no email, 403 if not granted.
167
+ */
168
+ requireGranted(result: CheckPermissionsResult): AdminInfo {
169
+ if (result.authDisabled) return adminInfoDisabled()
170
+
171
+ const { email, granted, reqPermissions, grantedPermissions } = result
172
+
173
+ _assert(email, 'adminToken required', {
174
+ adminAuthRequired: true,
175
+ backendResponseStatusCode: 401,
176
+ userFriendly: true,
177
+ })
178
+
179
+ _assert(granted, `Admin permissions required: [${reqPermissions.join(', ')}]`, {
180
+ adminPermissionsRequired: reqPermissions,
181
+ email,
182
+ backendResponseStatusCode: 403,
183
+ userFriendly: true,
184
+ })
221
185
 
222
186
  return {
223
187
  email,
@@ -225,21 +189,80 @@ export class BaseAdminService {
225
189
  }
226
190
  }
227
191
 
228
- // convenience method
229
- async hasPermission(
230
- req: BackendRequest,
231
- reqPermission: string,
232
- meta?: Record<string, any>,
233
- ): Promise<boolean> {
234
- return !!(await this.hasPermissions(req, [reqPermission], meta))
192
+ /**
193
+ * Current implementation is based on req=Request (from Express).
194
+ * Override if needed.
195
+ */
196
+ getAdminToken(req: BackendRequest): string | undefined {
197
+ return (
198
+ req.cookies?.[this.cfg.adminTokenKey] ||
199
+ req.header(this.cfg.adminTokenKey) ||
200
+ req.header('x-admin-token')
201
+ )
235
202
  }
236
203
 
237
- async requirePermission(
204
+ async getEmailByToken(req: BackendRequest, adminToken?: string): Promise<string | undefined> {
205
+ if (!adminToken) return
206
+
207
+ try {
208
+ const auth = await this.getFirebaseAuth()
209
+ const decodedToken = await auth.verifyIdToken(adminToken)
210
+ const email = decodedToken?.email
211
+ req.log(`admin email: ${dimGrey(email)}`)
212
+ return email
213
+ } catch (err) {
214
+ // example:
215
+ // FirebaseAuthError: Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired).
216
+ if (
217
+ // err instanceof FirebaseAuthError && err.hasCode('id-token-expired')
218
+ (err as any)?.code?.includes('id-token-expired')
219
+ ) {
220
+ return // skip logging, expected error
221
+ }
222
+
223
+ req.error(`getEmailByToken error:`, err)
224
+ }
225
+ }
226
+
227
+ @_Memo()
228
+ private async getFirebaseAuth(): Promise<Auth> {
229
+ return await this.loadFirebaseAuth()
230
+ }
231
+
232
+ /**
233
+ * To be extended.
234
+ *
235
+ * Returns undefined if it's not an Admin.
236
+ * Otherwise returns Set of permissions.
237
+ * Empty array means it IS and Admin, but has no permissions (except being an Admin).
238
+ */
239
+ async getEmailPermissions(email?: string): Promise<Set<string> | undefined> {
240
+ if (!email) return
241
+ console.log(
242
+ `getEmailPermissions (${dimGrey(
243
+ email,
244
+ )}) returning undefined (please override the implementation)`,
245
+ )
246
+ }
247
+
248
+ /**
249
+ * To be extended.
250
+ */
251
+ // oxlint-disable-next-line max-params
252
+ protected async onPermissionCheck(
238
253
  req: BackendRequest,
239
- reqPermission: string,
240
- meta?: Record<string, any>,
241
- ): Promise<AdminInfo> {
242
- return await this.requirePermissions(req, [reqPermission], meta)
254
+ email: string,
255
+ reqPermissions: string[],
256
+ required: boolean,
257
+ granted: boolean,
258
+ meta: AnyObject = {},
259
+ ): Promise<void> {
260
+ req.log(
261
+ `${dimGrey(email)} ${required ? 'required' : 'optional'} permissions check [${dimGrey(
262
+ reqPermissions.join(', '),
263
+ )}]: ${granted ? green('GRANTED') : red('DENIED')}`,
264
+ meta,
265
+ )
243
266
  }
244
267
 
245
268
  /**
@@ -280,3 +303,53 @@ export class BaseAdminService {
280
303
  }
281
304
  }
282
305
  }
306
+
307
+ const adminInfoDisabled = (): AdminInfo => ({
308
+ email: 'authDisabled',
309
+ permissions: [],
310
+ })
311
+
312
+ export interface AdminServiceCfg {
313
+ /**
314
+ * @default 'admin_token'
315
+ */
316
+ adminTokenKey?: string
317
+
318
+ /**
319
+ * If false - disables auth completely (useful for debugging locally, but never in production).
320
+ *
321
+ * @default true
322
+ */
323
+ authEnabled?: boolean
324
+ }
325
+
326
+ export interface AdminInfo {
327
+ email: string
328
+ permissions: string[]
329
+ }
330
+
331
+ export interface ResolvedAdmin {
332
+ /** undefined - no valid admin token */
333
+ email?: string
334
+ /** undefined - not an Admin */
335
+ permissions?: Set<string>
336
+ }
337
+
338
+ export interface CheckPermissionsOptions {
339
+ /**
340
+ * false - one granted permission is enough.
341
+ *
342
+ * @default true
343
+ */
344
+ andComparison?: boolean
345
+ }
346
+
347
+ export interface CheckPermissionsResult {
348
+ /** undefined - no valid admin token */
349
+ email?: string
350
+ isAdmin: boolean
351
+ granted: boolean
352
+ reqPermissions: string[]
353
+ grantedPermissions: string[]
354
+ authDisabled: boolean
355
+ }
@@ -6,7 +6,6 @@ import { compressionMiddleware } from '../server/compressionMiddleware.js'
6
6
  import { genericErrorMiddleware } from '../server/genericErrorMiddleware.js'
7
7
  import type { GenericErrorMiddlewareCfg } from '../server/genericErrorMiddleware.js'
8
8
  import { logMiddleware } from '../server/logMiddleware.js'
9
- import { methodOverrideMiddleware } from '../server/methodOverrideMiddleware.js'
10
9
  import { notFoundMiddleware } from '../server/notFoundMiddleware.js'
11
10
  import { requestTimeoutMiddleware } from '../server/requestTimeoutMiddleware.js'
12
11
  import type {
@@ -42,7 +41,6 @@ export async function createDefaultApp(cfg: DefaultAppCfg): Promise<BackendAppli
42
41
  app.use(asyncLocalStorageMiddleware())
43
42
  }
44
43
 
45
- app.use(methodOverrideMiddleware())
46
44
  app.use(requestTimeoutMiddleware())
47
45
  // app.use(serverStatsMiddleware()) // disabled by default
48
46
  // app.use(bodyParserTimeout()) // removed by default
package/src/index.ts CHANGED
@@ -7,7 +7,6 @@ export * from './server/compressionMiddleware.js'
7
7
  export * from './server/eventLoop.util.js'
8
8
  export * from './server/genericErrorMiddleware.js'
9
9
  export * from './server/logMiddleware.js'
10
- export * from './server/methodOverrideMiddleware.js'
11
10
  export * from './server/notFoundMiddleware.js'
12
11
  export * from './server/okMiddleware.js'
13
12
  export * from './server/request.log.util.js'
@@ -3,6 +3,9 @@ import type { BackendRequestHandler } from './server.model.js'
3
3
 
4
4
  export function notFoundMiddleware(): BackendRequestHandler {
5
5
  return (req, res) => {
6
- res.status(404).send(`404 Not Found: ${getRequestEndpoint(req)}`)
6
+ res
7
+ .status(404)
8
+ .type('text/plain')
9
+ .send(`404 Not Found: ${getRequestEndpoint(req)}`)
7
10
  }
8
11
  }
@@ -1,8 +0,0 @@
1
- import type { BackendRequestHandler } from './server.model.js';
2
- export interface MethodOverrideMiddlewareCfg {
3
- /**
4
- * @default _method
5
- */
6
- methodKey?: string;
7
- }
8
- export declare function methodOverrideMiddleware(cfg?: MethodOverrideMiddlewareCfg): BackendRequestHandler;
@@ -1,13 +0,0 @@
1
- export function methodOverrideMiddleware(cfg = {}) {
2
- const { methodKey } = {
3
- methodKey: '_method',
4
- ...cfg,
5
- };
6
- return (req, _res, next) => {
7
- if (req.query[methodKey]) {
8
- req.method = req.query[methodKey];
9
- // delete req.query[methodKey]
10
- }
11
- next();
12
- };
13
- }
@@ -1,26 +0,0 @@
1
- import type { BackendRequestHandler } from './server.model.js'
2
-
3
- export interface MethodOverrideMiddlewareCfg {
4
- /**
5
- * @default _method
6
- */
7
- methodKey?: string
8
- }
9
-
10
- export function methodOverrideMiddleware(
11
- cfg: MethodOverrideMiddlewareCfg = {},
12
- ): BackendRequestHandler {
13
- const { methodKey } = {
14
- methodKey: '_method',
15
- ...cfg,
16
- }
17
-
18
- return (req, _res, next) => {
19
- if (req.query[methodKey]) {
20
- req.method = req.query[methodKey] as string
21
- // delete req.query[methodKey]
22
- }
23
-
24
- next()
25
- }
26
- }