@naturalcycles/backend-lib 9.74.0 → 9.75.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/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/secureHeaderMiddleware.ts +5 -0
- package/src/auth/auth.service.ts +303 -0
- package/src/auth/index.ts +1 -0
|
@@ -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
|
+
});
|
package/dist/admin/index.d.ts
CHANGED
package/dist/admin/index.js
CHANGED
|
@@ -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';
|
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.75.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
|
@@ -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)
|
|
@@ -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'
|