@soapjs/soap-auth 0.3.1 → 0.3.3
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/build/__tests__/soap-auth.test.d.ts +1 -0
- package/build/__tests__/soap-auth.test.js +42 -0
- package/build/errors.d.ts +14 -3
- package/build/errors.js +29 -8
- package/build/index.d.ts +1 -1
- package/build/index.js +1 -1
- package/build/services/__tests__/account-lock.service.test.d.ts +1 -0
- package/build/services/__tests__/account-lock.service.test.js +55 -0
- package/build/services/__tests__/auth-throttle.service.test.d.ts +1 -0
- package/build/services/__tests__/auth-throttle.service.test.js +48 -0
- package/build/services/__tests__/jwks.service.test.d.ts +1 -0
- package/build/services/__tests__/jwks.service.test.js +39 -0
- package/build/services/__tests__/mfa.service.test.d.ts +1 -0
- package/build/services/__tests__/mfa.service.test.js +66 -0
- package/build/services/__tests__/password.service.test.d.ts +1 -0
- package/build/services/__tests__/password.service.test.js +66 -0
- package/build/services/__tests__/pkce.service.test.d.ts +1 -0
- package/build/services/__tests__/pkce.service.test.js +77 -0
- package/build/services/__tests__/rate-limit.service.test.d.ts +1 -0
- package/build/services/__tests__/rate-limit.service.test.js +37 -0
- package/build/services/__tests__/role.service.test.d.ts +1 -0
- package/build/services/__tests__/role.service.test.js +31 -0
- package/build/services/account-lock.service.d.ts +12 -0
- package/build/services/account-lock.service.js +39 -0
- package/build/services/auth-throttle.service.d.ts +10 -0
- package/build/services/auth-throttle.service.js +43 -0
- package/build/services/index.d.ts +8 -0
- package/build/{factories → services}/index.js +8 -3
- package/build/services/jwks.service.d.ts +7 -0
- package/build/services/jwks.service.js +41 -0
- package/build/services/mfa.service.d.ts +12 -0
- package/build/services/mfa.service.js +74 -0
- package/build/services/password.service.d.ts +14 -0
- package/build/services/password.service.js +78 -0
- package/build/services/pkce.service.d.ts +14 -0
- package/build/services/pkce.service.js +81 -0
- package/build/services/rate-limit.service.d.ts +9 -0
- package/build/services/rate-limit.service.js +26 -0
- package/build/services/role.service.d.ts +9 -0
- package/build/services/role.service.js +26 -0
- package/build/session/__tests__/file.session-store.test.d.ts +1 -0
- package/build/session/__tests__/file.session-store.test.js +117 -0
- package/build/session/__tests__/memory.session-store.test.d.ts +1 -0
- package/build/session/__tests__/memory.session-store.test.js +77 -0
- package/build/session/__tests__/session-handler.test.d.ts +1 -0
- package/build/session/__tests__/session-handler.test.js +337 -0
- package/build/session/file.session-store.d.ts +1 -0
- package/build/session/file.session-store.js +7 -0
- package/build/session/memory.session-store.d.ts +4 -1
- package/build/session/memory.session-store.js +11 -5
- package/build/session/session-handler.d.ts +12 -7
- package/build/session/session-handler.js +46 -13
- package/build/session/session.errors.d.ts +6 -0
- package/build/session/session.errors.js +15 -0
- package/build/soap-auth.d.ts +9 -8
- package/build/soap-auth.js +42 -29
- package/build/strategies/__tests__/base-auth.strategy.test.d.ts +14 -0
- package/build/strategies/__tests__/base-auth.strategy.test.js +137 -0
- package/build/strategies/__tests__/credential-auth.strategy.test.d.ts +14 -0
- package/build/strategies/__tests__/credential-auth.strategy.test.js +265 -0
- package/build/strategies/__tests__/token-auth.strategy.test.d.ts +28 -0
- package/build/strategies/__tests__/token-auth.strategy.test.js +298 -0
- package/build/strategies/api-key/__tests__/api-key.strategy.test.d.ts +1 -0
- package/build/strategies/api-key/__tests__/api-key.strategy.test.js +103 -0
- package/build/strategies/api-key/api-key.strategy.d.ts +5 -2
- package/build/strategies/api-key/api-key.strategy.js +43 -35
- package/build/strategies/api-key/api-key.tools.d.ts +2 -0
- package/build/strategies/api-key/api-key.tools.js +39 -0
- package/build/strategies/api-key/api-key.types.d.ts +10 -2
- package/build/strategies/base-auth.strategy.d.ts +11 -5
- package/build/strategies/base-auth.strategy.js +45 -52
- package/build/strategies/basic/__tests__/basic.strategy.test.d.ts +1 -0
- package/build/strategies/basic/__tests__/basic.strategy.test.js +104 -0
- package/build/strategies/basic/basic.strategy.d.ts +5 -7
- package/build/strategies/basic/basic.strategy.js +6 -6
- package/build/strategies/basic/basic.tools.d.ts +2 -0
- package/build/strategies/basic/basic.tools.js +44 -0
- package/build/strategies/credential-auth.strategy.d.ts +7 -17
- package/build/strategies/credential-auth.strategy.js +116 -181
- package/build/strategies/jwt/__tests__/jwt.strategy.test.d.ts +1 -0
- package/build/strategies/jwt/__tests__/jwt.strategy.test.js +156 -0
- package/build/strategies/jwt/__tests__/jwt.tools.test.d.ts +1 -0
- package/build/strategies/jwt/__tests__/jwt.tools.test.js +98 -0
- package/build/strategies/jwt/jwt.strategy.d.ts +13 -14
- package/build/strategies/jwt/jwt.strategy.js +57 -44
- package/build/strategies/jwt/jwt.tools.d.ts +20 -7
- package/build/strategies/jwt/jwt.tools.js +180 -81
- package/build/strategies/local/__tests__/local.strategy.test.d.ts +1 -0
- package/build/strategies/local/__tests__/local.strategy.test.js +115 -0
- package/build/strategies/local/local.strategy.d.ts +4 -3
- package/build/strategies/local/local.strategy.js +7 -6
- package/build/strategies/local/local.tools.d.ts +2 -0
- package/build/strategies/local/local.tools.js +44 -0
- package/build/strategies/oauth2/hybrid.oauth2.strategy.d.ts +5 -0
- package/build/strategies/oauth2/hybrid.oauth2.strategy.js +92 -0
- package/build/strategies/oauth2/oauth2.errors.d.ts +12 -0
- package/build/strategies/oauth2/oauth2.errors.js +24 -0
- package/build/strategies/oauth2/oauth2.strategy.d.ts +25 -15
- package/build/strategies/oauth2/oauth2.strategy.js +131 -141
- package/build/strategies/oauth2/oauth2.tools.d.ts +7 -2
- package/build/strategies/oauth2/oauth2.tools.js +119 -14
- package/build/strategies/oauth2/oauth2.types.d.ts +32 -1
- package/build/strategies/token-auth.strategy.d.ts +14 -8
- package/build/strategies/token-auth.strategy.js +162 -38
- package/build/tools/index.d.ts +0 -2
- package/build/tools/index.js +0 -2
- package/build/tools/tools.d.ts +2 -1
- package/build/tools/tools.js +9 -12
- package/build/types.d.ts +88 -57
- package/package.json +1 -1
- package/build/factories/auth-strategy.factory.d.ts +0 -9
- package/build/factories/auth-strategy.factory.js +0 -16
- package/build/factories/http-auth-strategy.factory.d.ts +0 -5
- package/build/factories/http-auth-strategy.factory.js +0 -41
- package/build/factories/index.d.ts +0 -3
- package/build/factories/socket-auth-strategy.factory.d.ts +0 -5
- package/build/factories/socket-auth-strategy.factory.js +0 -27
- package/build/tools/session.tools.d.ts +0 -6
- package/build/tools/session.tools.js +0 -15
- package/build/tools/token.tools.d.ts +0 -7
- package/build/tools/token.tools.js +0 -32
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const globals_1 = require("@jest/globals");
|
|
4
|
+
const soap_auth_1 = require("../soap-auth");
|
|
5
|
+
describe("SoapAuth", () => {
|
|
6
|
+
let soapAuth;
|
|
7
|
+
const mockLogger = { error: globals_1.jest.fn(), info: globals_1.jest.fn() };
|
|
8
|
+
const mockStrategy = {
|
|
9
|
+
authenticate: globals_1.jest.fn(),
|
|
10
|
+
init: globals_1.jest.fn(),
|
|
11
|
+
logout: globals_1.jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
globals_1.jest.clearAllMocks();
|
|
15
|
+
soapAuth = new soap_auth_1.SoapAuth({ logger: mockLogger });
|
|
16
|
+
});
|
|
17
|
+
test("addStrategy should add a valid strategy", () => {
|
|
18
|
+
expect(() => soapAuth.addStrategy(mockStrategy, "jwt", "http")).not.toThrow();
|
|
19
|
+
expect(soapAuth.hasStrategy("jwt", "http")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
test("addStrategy should throw error if strategy is invalid", () => {
|
|
22
|
+
expect(() => soapAuth.addStrategy({}, "invalid", "http")).toThrow("Invalid authentication strategy: does not implement required methods.");
|
|
23
|
+
});
|
|
24
|
+
test("removeStrategy should remove an existing strategy", () => {
|
|
25
|
+
soapAuth.addStrategy(mockStrategy, "jwt", "http");
|
|
26
|
+
expect(soapAuth.hasStrategy("jwt", "http")).toBe(true);
|
|
27
|
+
soapAuth.removeStrategy("jwt", "http");
|
|
28
|
+
expect(soapAuth.hasStrategy("jwt", "http")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
test("getStrategy should return an existing strategy", () => {
|
|
31
|
+
soapAuth.addStrategy(mockStrategy, "jwt", "http");
|
|
32
|
+
expect(soapAuth.getStrategy("jwt", "http")).toBe(mockStrategy);
|
|
33
|
+
});
|
|
34
|
+
test("getStrategy should throw an error if strategy does not exist", () => {
|
|
35
|
+
expect(() => soapAuth.getStrategy("nonexistent", "http")).toThrow('Authentication strategy "nonexistent" not found.');
|
|
36
|
+
});
|
|
37
|
+
test("listStrategies should return all registered strategy names", () => {
|
|
38
|
+
soapAuth.addStrategy(mockStrategy, "jwt", "http");
|
|
39
|
+
soapAuth.addStrategy(mockStrategy, "oauth", "http");
|
|
40
|
+
expect(soapAuth.listStrategies("http")).toEqual(["jwt", "oauth"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/build/errors.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export declare class MissingConfigError extends Error {
|
|
2
|
+
constructor(config: string);
|
|
3
|
+
}
|
|
1
4
|
export declare class UnauthorizedRoleError extends Error {
|
|
2
5
|
constructor();
|
|
3
6
|
}
|
|
@@ -34,6 +37,9 @@ export declare class InvalidTokenError extends Error {
|
|
|
34
37
|
readonly tokenType: "Access" | "Refresh";
|
|
35
38
|
constructor(tokenType?: "Access" | "Refresh");
|
|
36
39
|
}
|
|
40
|
+
export declare class TokenRotationLimitReachedError extends Error {
|
|
41
|
+
constructor();
|
|
42
|
+
}
|
|
37
43
|
export declare class UndefinedTokenSecretError extends Error {
|
|
38
44
|
readonly tokenType: "Access" | "Refresh";
|
|
39
45
|
constructor(tokenType?: "Access" | "Refresh");
|
|
@@ -47,7 +53,12 @@ export declare class ExpiredTokenError extends Error {
|
|
|
47
53
|
readonly tokenType: "Access" | "Refresh";
|
|
48
54
|
constructor(tokenType?: "Access" | "Refresh");
|
|
49
55
|
}
|
|
50
|
-
export declare class
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
export declare class ExpiredResetTokenError extends Error {
|
|
57
|
+
constructor();
|
|
58
|
+
}
|
|
59
|
+
export declare class SessionIdNotFoundInContextError extends Error {
|
|
60
|
+
constructor();
|
|
61
|
+
}
|
|
62
|
+
export declare class ExpiredPasswordError extends Error {
|
|
63
|
+
constructor();
|
|
53
64
|
}
|
package/build/errors.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.ExpiredPasswordError = exports.SessionIdNotFoundInContextError = exports.ExpiredResetTokenError = exports.ExpiredTokenError = exports.UndefinedTokenHandlerError = exports.UndefinedTokenSecretError = exports.TokenRotationLimitReachedError = exports.InvalidTokenError = exports.UndefinedTokenError = exports.EmptyPayloadError = exports.MissingTokenError = exports.InvalidCredentialsError = exports.UserNotFoundError = exports.MissingCredentialsError = exports.AccountLockedError = exports.RateLimitExceededError = exports.MissingAuthorizationCodeError = exports.UnauthorizedRoleError = exports.MissingConfigError = void 0;
|
|
4
|
+
class MissingConfigError extends Error {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
super(`"${config}" not configured.`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
exports.MissingConfigError = MissingConfigError;
|
|
4
10
|
class UnauthorizedRoleError extends Error {
|
|
5
11
|
constructor() {
|
|
6
12
|
super("User does not have the required role.");
|
|
@@ -80,6 +86,12 @@ class InvalidTokenError extends Error {
|
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
exports.InvalidTokenError = InvalidTokenError;
|
|
89
|
+
class TokenRotationLimitReachedError extends Error {
|
|
90
|
+
constructor() {
|
|
91
|
+
super(`Token rotation limit reached. Please re-authenticate.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
exports.TokenRotationLimitReachedError = TokenRotationLimitReachedError;
|
|
83
95
|
class UndefinedTokenSecretError extends Error {
|
|
84
96
|
tokenType;
|
|
85
97
|
constructor(tokenType = "Access") {
|
|
@@ -106,12 +118,21 @@ class ExpiredTokenError extends Error {
|
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
exports.ExpiredTokenError = ExpiredTokenError;
|
|
109
|
-
class
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
class ExpiredResetTokenError extends Error {
|
|
122
|
+
constructor() {
|
|
123
|
+
super("Invalid or expired reset token.");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.ExpiredResetTokenError = ExpiredResetTokenError;
|
|
127
|
+
class SessionIdNotFoundInContextError extends Error {
|
|
128
|
+
constructor() {
|
|
129
|
+
super("Session ID is missing in the context.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.SessionIdNotFoundInContextError = SessionIdNotFoundInContextError;
|
|
133
|
+
class ExpiredPasswordError extends Error {
|
|
134
|
+
constructor() {
|
|
135
|
+
super("Password expired, please reset your password.");
|
|
115
136
|
}
|
|
116
137
|
}
|
|
117
|
-
exports.
|
|
138
|
+
exports.ExpiredPasswordError = ExpiredPasswordError;
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
|
@@ -14,8 +14,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
__exportStar(require("./factories"), exports);
|
|
18
17
|
__exportStar(require("./session"), exports);
|
|
18
|
+
__exportStar(require("./services"), exports);
|
|
19
19
|
__exportStar(require("./strategies"), exports);
|
|
20
20
|
__exportStar(require("./tools"), exports);
|
|
21
21
|
__exportStar(require("./errors"), exports);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../../errors");
|
|
4
|
+
const account_lock_service_1 = require("../account-lock.service");
|
|
5
|
+
const mockConfig = {
|
|
6
|
+
isAccountLocked: jest.fn(),
|
|
7
|
+
lockAccount: jest.fn(),
|
|
8
|
+
hasAccountLockExpired: jest.fn(),
|
|
9
|
+
removeAccountLock: jest.fn(),
|
|
10
|
+
logFailedAttempt: jest.fn(),
|
|
11
|
+
notifyOnLockout: jest.fn(),
|
|
12
|
+
};
|
|
13
|
+
const mockLogger = {
|
|
14
|
+
error: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
describe("AccountLockService", () => {
|
|
17
|
+
let service;
|
|
18
|
+
const mockAccount = { id: "123" };
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
service = new account_lock_service_1.AccountLockService(mockConfig, mockLogger);
|
|
22
|
+
});
|
|
23
|
+
it("isAccountLocked throws error if account is locked", async () => {
|
|
24
|
+
mockConfig.isAccountLocked.mockResolvedValue(true);
|
|
25
|
+
await expect(service.isAccountLocked(mockAccount)).rejects.toThrow(errors_1.AccountLockedError);
|
|
26
|
+
});
|
|
27
|
+
it("isAccountLocked returns false if account is not locked", async () => {
|
|
28
|
+
mockConfig.isAccountLocked.mockResolvedValue(false);
|
|
29
|
+
await expect(service.isAccountLocked(mockAccount)).resolves.toBe(false);
|
|
30
|
+
});
|
|
31
|
+
it("lockAccount locks the account and notifies if enabled", async () => {
|
|
32
|
+
mockConfig.notifyOnLockout = jest.fn();
|
|
33
|
+
await service.lockAccount(mockAccount);
|
|
34
|
+
expect(mockConfig.lockAccount).toHaveBeenCalledWith(mockAccount);
|
|
35
|
+
expect(mockConfig.notifyOnLockout).toHaveBeenCalledWith(mockAccount);
|
|
36
|
+
});
|
|
37
|
+
it("lockAccount does not notify if notifyOnLockout is not defined", async () => {
|
|
38
|
+
mockConfig.notifyOnLockout = undefined;
|
|
39
|
+
await service.lockAccount(mockAccount);
|
|
40
|
+
expect(mockConfig.lockAccount).toHaveBeenCalledWith(mockAccount);
|
|
41
|
+
});
|
|
42
|
+
it("hasAccountLockExpired returns expected value", async () => {
|
|
43
|
+
mockConfig.hasAccountLockExpired.mockResolvedValue(true);
|
|
44
|
+
await expect(service.hasAccountLockExpired(mockAccount)).resolves.toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it("removeAccountLock calls the correct method", async () => {
|
|
47
|
+
await service.removeAccountLock(mockAccount);
|
|
48
|
+
expect(mockConfig.removeAccountLock).toHaveBeenCalledWith(mockAccount);
|
|
49
|
+
});
|
|
50
|
+
it("logFailedAttempt logs action and handles error if logging fails", async () => {
|
|
51
|
+
mockConfig.logFailedAttempt.mockRejectedValue(new Error("Logging failed"));
|
|
52
|
+
await service.logFailedAttempt("LOGIN_ATTEMPT", mockAccount, {});
|
|
53
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../../errors");
|
|
4
|
+
const auth_throttle_service_1 = require("../auth-throttle.service");
|
|
5
|
+
const mockConfig = {
|
|
6
|
+
getFailedAttempts: jest.fn(),
|
|
7
|
+
incrementFailedAttempts: jest.fn(),
|
|
8
|
+
resetFailedAttempts: jest.fn(),
|
|
9
|
+
maxFailedAttempts: 3,
|
|
10
|
+
};
|
|
11
|
+
const mockLogger = {
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
};
|
|
15
|
+
describe("AuthThrottleService", () => {
|
|
16
|
+
let service;
|
|
17
|
+
const mockIdentifier = "user123";
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
service = new auth_throttle_service_1.AuthThrottleService(mockConfig, mockLogger);
|
|
21
|
+
});
|
|
22
|
+
it("checkFailedAttempts throws error if max failed attempts reached", async () => {
|
|
23
|
+
mockConfig.getFailedAttempts.mockResolvedValue(3);
|
|
24
|
+
await expect(service.checkFailedAttempts(mockIdentifier)).rejects.toThrow(errors_1.AccountLockedError);
|
|
25
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(`User ${mockIdentifier} is temporarily locked out.`);
|
|
26
|
+
});
|
|
27
|
+
it("checkFailedAttempts does not throw error if failed attempts are below threshold", async () => {
|
|
28
|
+
mockConfig.getFailedAttempts.mockResolvedValue(2);
|
|
29
|
+
await expect(service.checkFailedAttempts(mockIdentifier)).resolves.not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
it("checkFailedAttempts logs error if an exception occurs", async () => {
|
|
32
|
+
mockConfig.getFailedAttempts.mockRejectedValue(new Error("DB error"));
|
|
33
|
+
await service.checkFailedAttempts(mockIdentifier);
|
|
34
|
+
expect(mockLogger.error).toHaveBeenCalledWith("Check failed attempts:", expect.any(Error));
|
|
35
|
+
});
|
|
36
|
+
it("incrementFailedAttempts increments counter and throws if max reached", async () => {
|
|
37
|
+
mockConfig.getFailedAttempts.mockResolvedValue(3);
|
|
38
|
+
await expect(service.incrementFailedAttempts(mockIdentifier)).rejects.toThrow(errors_1.AccountLockedError);
|
|
39
|
+
});
|
|
40
|
+
it("incrementFailedAttempts increments counter without throwing if below threshold", async () => {
|
|
41
|
+
mockConfig.getFailedAttempts.mockResolvedValue(2);
|
|
42
|
+
await expect(service.incrementFailedAttempts(mockIdentifier)).resolves.not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
it("resetFailedAttempts calls the correct method", async () => {
|
|
45
|
+
await service.resetFailedAttempts(mockIdentifier);
|
|
46
|
+
expect(mockConfig.resetFailedAttempts).toHaveBeenCalledWith(mockIdentifier);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
7
|
+
const jwks_service_1 = require("../jwks.service");
|
|
8
|
+
const oauth2_errors_1 = require("../../strategies/oauth2/oauth2.errors");
|
|
9
|
+
describe("JwtService", () => {
|
|
10
|
+
let service;
|
|
11
|
+
let mockConfig;
|
|
12
|
+
const mockIdToken = "mock.id.token";
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
mockConfig = {
|
|
16
|
+
jwks: {
|
|
17
|
+
jwksUri: "https://mock-jwks-uri.com",
|
|
18
|
+
algorithms: ["RS256"],
|
|
19
|
+
issuer: "mock-issuer",
|
|
20
|
+
audience: "mock-client-id",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
service = new jwks_service_1.JwtService(mockConfig);
|
|
24
|
+
});
|
|
25
|
+
it("verify throws error if ID token structure is invalid", async () => {
|
|
26
|
+
jest.spyOn(jsonwebtoken_1.default, "decode").mockReturnValue(null);
|
|
27
|
+
await expect(service.verify(mockIdToken)).rejects.toThrow("Invalid ID Token structure.");
|
|
28
|
+
});
|
|
29
|
+
it("verify throws error if ID token is expired", async () => {
|
|
30
|
+
jest.spyOn(jsonwebtoken_1.default, "decode").mockReturnValue({ header: { kid: "mock-kid" } });
|
|
31
|
+
jest
|
|
32
|
+
.spyOn(service.client, "getSigningKey")
|
|
33
|
+
.mockResolvedValue({ getPublicKey: () => "mock-public-key" });
|
|
34
|
+
jest
|
|
35
|
+
.spyOn(jsonwebtoken_1.default, "verify")
|
|
36
|
+
.mockReturnValue({ exp: Math.floor(Date.now() / 1000) - 10 });
|
|
37
|
+
await expect(service.verify(mockIdToken)).rejects.toThrow(oauth2_errors_1.InvalidIdTokenError);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mfa_service_1 = require("../mfa.service");
|
|
4
|
+
const mockConfig = {
|
|
5
|
+
isMfaRequired: jest.fn(),
|
|
6
|
+
extractMfaCode: jest.fn(),
|
|
7
|
+
sendMfaCode: jest.fn(),
|
|
8
|
+
validateMfaCode: jest.fn(),
|
|
9
|
+
maxMfaAttempts: 3,
|
|
10
|
+
getMfaAttempts: jest.fn(),
|
|
11
|
+
incrementMfaAttempts: jest.fn(),
|
|
12
|
+
resetMfaAttempts: jest.fn(),
|
|
13
|
+
lockMfaOnFailure: jest.fn(),
|
|
14
|
+
};
|
|
15
|
+
const mockLogger = {
|
|
16
|
+
info: jest.fn(),
|
|
17
|
+
warn: jest.fn(),
|
|
18
|
+
error: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
describe("MfaService", () => {
|
|
21
|
+
let service;
|
|
22
|
+
const mockUser = { id: "user123" };
|
|
23
|
+
const mockContext = { token: "mock-token" };
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
service = new mfa_service_1.MfaService(mockConfig, mockLogger);
|
|
27
|
+
});
|
|
28
|
+
it("checkMfa sends code if required and no code provided", async () => {
|
|
29
|
+
mockConfig.isMfaRequired.mockReturnValue(true);
|
|
30
|
+
mockConfig.extractMfaCode.mockReturnValue(null);
|
|
31
|
+
await expect(service.checkMfa(mockUser, mockContext)).rejects.toThrow("2FA required. A verification code has been sent.");
|
|
32
|
+
expect(mockConfig.sendMfaCode).toHaveBeenCalledWith(mockUser, mockContext);
|
|
33
|
+
});
|
|
34
|
+
it("checkMfa locks account after too many failed attempts", async () => {
|
|
35
|
+
mockConfig.isMfaRequired.mockReturnValue(true);
|
|
36
|
+
mockConfig.extractMfaCode.mockReturnValue("wrong-code");
|
|
37
|
+
mockConfig.getMfaAttempts.mockResolvedValue(3);
|
|
38
|
+
await expect(service.checkMfa(mockUser, mockContext)).rejects.toThrow("Your account has been temporarily locked due to too many failed 2FA attempts.");
|
|
39
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(`User ${mockUser} exceeded maximum MFA attempts.`);
|
|
40
|
+
expect(mockConfig.lockMfaOnFailure).toHaveBeenCalledWith(mockUser);
|
|
41
|
+
});
|
|
42
|
+
it("checkMfa rejects invalid MFA codes", async () => {
|
|
43
|
+
mockConfig.isMfaRequired.mockReturnValue(true);
|
|
44
|
+
mockConfig.extractMfaCode.mockReturnValue("invalid-code");
|
|
45
|
+
mockConfig.getMfaAttempts.mockResolvedValue(1);
|
|
46
|
+
mockConfig.validateMfaCode.mockResolvedValue(false);
|
|
47
|
+
await expect(service.checkMfa(mockUser, mockContext)).rejects.toThrow("Invalid 2FA code provided.");
|
|
48
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(`Invalid MFA code attempt for user: ${mockUser}`);
|
|
49
|
+
expect(mockConfig.incrementMfaAttempts).toHaveBeenCalledWith(mockUser);
|
|
50
|
+
});
|
|
51
|
+
it("checkMfa resets attempts on successful MFA validation", async () => {
|
|
52
|
+
mockConfig.isMfaRequired.mockReturnValue(true);
|
|
53
|
+
mockConfig.extractMfaCode.mockReturnValue("valid-code");
|
|
54
|
+
mockConfig.validateMfaCode.mockResolvedValue(true);
|
|
55
|
+
await expect(service.checkMfa(mockUser, mockContext)).resolves.not.toThrow();
|
|
56
|
+
expect(mockConfig.resetMfaAttempts).toHaveBeenCalledWith(mockUser);
|
|
57
|
+
expect(mockLogger.info).toHaveBeenCalledWith(`2FA successfully validated for user: ${mockUser}`);
|
|
58
|
+
});
|
|
59
|
+
it("lockMfaOnFailure logs error if an exception occurs", async () => {
|
|
60
|
+
mockConfig.lockMfaOnFailure.mockImplementation(() => {
|
|
61
|
+
throw new Error("Lock failed");
|
|
62
|
+
});
|
|
63
|
+
service.lockMfaOnFailure(mockUser);
|
|
64
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.any(Error));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const password_service_1 = require("../password.service");
|
|
4
|
+
const soap_1 = require("@soapjs/soap");
|
|
5
|
+
const globals_1 = require("@jest/globals");
|
|
6
|
+
describe("PasswordService", () => {
|
|
7
|
+
let service;
|
|
8
|
+
let mockConfig;
|
|
9
|
+
const mockLogger = { error: globals_1.jest.fn() };
|
|
10
|
+
const mockIdentifier = "user123";
|
|
11
|
+
const mockPassword = "SecurePass123!";
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
globals_1.jest.clearAllMocks();
|
|
14
|
+
mockConfig = {
|
|
15
|
+
validatePassword: globals_1.jest.fn(),
|
|
16
|
+
getLastPasswordChange: globals_1.jest.fn(),
|
|
17
|
+
generateResetToken: globals_1.jest.fn(),
|
|
18
|
+
sendResetEmail: globals_1.jest.fn(),
|
|
19
|
+
validateResetToken: globals_1.jest.fn(),
|
|
20
|
+
updatePassword: globals_1.jest.fn(),
|
|
21
|
+
passwordExpirationDays: 30,
|
|
22
|
+
};
|
|
23
|
+
service = new password_service_1.PasswordService(mockConfig, mockLogger);
|
|
24
|
+
});
|
|
25
|
+
it("validatePassword calls config method", async () => {
|
|
26
|
+
mockConfig.validatePassword.mockReturnValue(true);
|
|
27
|
+
await expect(service.validatePassword(mockPassword)).resolves.toBe(true);
|
|
28
|
+
expect(mockConfig.validatePassword).toHaveBeenCalledWith(mockPassword);
|
|
29
|
+
});
|
|
30
|
+
it("getLastPasswordChange calls config method", async () => {
|
|
31
|
+
const mockDate = new Date();
|
|
32
|
+
mockConfig.getLastPasswordChange.mockReturnValue(mockDate);
|
|
33
|
+
await expect(service.getLastPasswordChange(mockIdentifier)).resolves.toBe(mockDate);
|
|
34
|
+
expect(mockConfig.getLastPasswordChange).toHaveBeenCalledWith(mockIdentifier);
|
|
35
|
+
});
|
|
36
|
+
it("generateResetToken calls config method", async () => {
|
|
37
|
+
mockConfig.generateResetToken.mockResolvedValue("mock-token");
|
|
38
|
+
await expect(service.generateResetToken(mockIdentifier)).resolves.toBe("mock-token");
|
|
39
|
+
});
|
|
40
|
+
it("sendResetEmail calls config method", async () => {
|
|
41
|
+
await expect(service.sendResetEmail(mockIdentifier, "mock-token")).resolves.not.toThrow();
|
|
42
|
+
expect(mockConfig.sendResetEmail).toHaveBeenCalledWith(mockIdentifier, "mock-token");
|
|
43
|
+
});
|
|
44
|
+
it("validateResetToken calls config method", async () => {
|
|
45
|
+
mockConfig.validateResetToken.mockResolvedValue(true);
|
|
46
|
+
await expect(service.validateResetToken("mock-token")).resolves.toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it("updatePassword calls config method", async () => {
|
|
49
|
+
await expect(service.updatePassword(mockIdentifier, mockPassword)).resolves.not.toThrow();
|
|
50
|
+
expect(mockConfig.updatePassword).toHaveBeenCalledWith(mockIdentifier, mockPassword);
|
|
51
|
+
});
|
|
52
|
+
it("isPasswordChangeRequired returns true if password is expired", async () => {
|
|
53
|
+
const pastDate = new Date(Date.now() - 31 * 86400000);
|
|
54
|
+
mockConfig.getLastPasswordChange.mockResolvedValue(pastDate);
|
|
55
|
+
await expect(service.isPasswordChangeRequired(mockIdentifier)).resolves.toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it("isPasswordChangeRequired returns false if password is within expiration period", async () => {
|
|
58
|
+
const recentDate = new Date(Date.now() - 15 * 86400000);
|
|
59
|
+
mockConfig.getLastPasswordChange.mockResolvedValue(recentDate);
|
|
60
|
+
await expect(service.isPasswordChangeRequired(mockIdentifier)).resolves.toBe(false);
|
|
61
|
+
});
|
|
62
|
+
it("isPasswordChangeRequired throws NotImplementedError if getLastPasswordChange is not defined", async () => {
|
|
63
|
+
service = new password_service_1.PasswordService({ passwordExpirationDays: 30 }, mockLogger);
|
|
64
|
+
await expect(service.isPasswordChangeRequired(mockIdentifier)).rejects.toThrow(soap_1.NotImplementedError);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const pkce_service_1 = require("../pkce.service");
|
|
4
|
+
class InMemoryPKCEPersistence {
|
|
5
|
+
storeMap = new Map();
|
|
6
|
+
async store(key, meta) {
|
|
7
|
+
this.storeMap.set(key, meta || {});
|
|
8
|
+
}
|
|
9
|
+
async read(verifierOrChallenge) {
|
|
10
|
+
return this.storeMap.get(verifierOrChallenge);
|
|
11
|
+
}
|
|
12
|
+
async remove(key) {
|
|
13
|
+
this.storeMap.delete(key);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
describe("PKCEService", () => {
|
|
17
|
+
let service;
|
|
18
|
+
let config;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
config = {
|
|
21
|
+
verifier: {
|
|
22
|
+
expiresIn: 1,
|
|
23
|
+
embed: jest.fn(),
|
|
24
|
+
extract: jest.fn().mockImplementation((ctx) => ctx.verifier),
|
|
25
|
+
persistence: new InMemoryPKCEPersistence(),
|
|
26
|
+
},
|
|
27
|
+
challenge: {
|
|
28
|
+
expiresIn: 1,
|
|
29
|
+
embed: jest.fn(),
|
|
30
|
+
extract: jest.fn().mockImplementation((ctx) => ctx.challenge),
|
|
31
|
+
persistence: new InMemoryPKCEPersistence(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
service = new pkce_service_1.PKCEService(config);
|
|
35
|
+
});
|
|
36
|
+
it("should generate and store a code verifier", async () => {
|
|
37
|
+
const context = { key: "code_verifier_test" };
|
|
38
|
+
const verifier = await service.generateCodeVerifier(context);
|
|
39
|
+
expect(verifier).toBeDefined();
|
|
40
|
+
expect(config.verifier.embed).toHaveBeenCalledWith(context, verifier);
|
|
41
|
+
});
|
|
42
|
+
it("should generate and store a code challenge", async () => {
|
|
43
|
+
const context = { key: "code_challenge_test" };
|
|
44
|
+
const challenge = await service.generateCodeChallenge("test_verifier", context);
|
|
45
|
+
expect(challenge).toBeDefined();
|
|
46
|
+
expect(config.challenge.embed).toHaveBeenCalledWith(context, challenge);
|
|
47
|
+
});
|
|
48
|
+
it("should detect expired code verifier", async () => {
|
|
49
|
+
config.verifier.embed = (ctx, cv) => {
|
|
50
|
+
ctx.verifier = cv;
|
|
51
|
+
};
|
|
52
|
+
const context = { key: "expired_verifier_test" };
|
|
53
|
+
await service.generateCodeVerifier(context);
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
55
|
+
const isExpired = await service.isCodeVerifierExpired(context);
|
|
56
|
+
expect(isExpired).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it("should detect not-expired code verifier", async () => {
|
|
59
|
+
config.verifier.embed = (ctx, cv) => {
|
|
60
|
+
ctx.verifier = cv;
|
|
61
|
+
};
|
|
62
|
+
const context = {
|
|
63
|
+
key: "not_expired_verifier_test",
|
|
64
|
+
};
|
|
65
|
+
await service.generateCodeVerifier(context);
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
67
|
+
const isExpired = await service.isCodeVerifierExpired(context);
|
|
68
|
+
expect(isExpired).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
it("should clear code verifier", async () => {
|
|
71
|
+
const context = {
|
|
72
|
+
key: "clear_verifier_test",
|
|
73
|
+
verifier: "test_verifier_value",
|
|
74
|
+
};
|
|
75
|
+
await service.clearCodeVerifier(context);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../../errors");
|
|
4
|
+
const rate_limit_service_1 = require("../rate-limit.service");
|
|
5
|
+
const mockConfig = {
|
|
6
|
+
incrementRequestCount: jest.fn(),
|
|
7
|
+
checkRateLimit: jest.fn(),
|
|
8
|
+
};
|
|
9
|
+
const mockLogger = {
|
|
10
|
+
error: jest.fn(),
|
|
11
|
+
};
|
|
12
|
+
describe("RateLimitService", () => {
|
|
13
|
+
let service;
|
|
14
|
+
const mockData = { userId: "user123" };
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
service = new rate_limit_service_1.RateLimitService(mockConfig, mockLogger);
|
|
18
|
+
});
|
|
19
|
+
it("incrementRequestCount calls config method and handles errors", async () => {
|
|
20
|
+
mockConfig.incrementRequestCount.mockResolvedValue(undefined);
|
|
21
|
+
await expect(service.incrementRequestCount(mockData)).resolves.not.toThrow();
|
|
22
|
+
expect(mockConfig.incrementRequestCount).toHaveBeenCalledWith(mockData);
|
|
23
|
+
});
|
|
24
|
+
it("incrementRequestCount logs error if an exception occurs", async () => {
|
|
25
|
+
mockConfig.incrementRequestCount.mockRejectedValue(new Error("Increment failed"));
|
|
26
|
+
await expect(service.incrementRequestCount(mockData)).resolves.not.toThrow();
|
|
27
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.any(Error));
|
|
28
|
+
});
|
|
29
|
+
it("checkRateLimit throws RateLimitExceededError if limit is exceeded", async () => {
|
|
30
|
+
mockConfig.checkRateLimit.mockResolvedValue(true);
|
|
31
|
+
await expect(service.checkRateLimit(mockData)).rejects.toThrow(errors_1.RateLimitExceededError);
|
|
32
|
+
});
|
|
33
|
+
it("checkRateLimit does not throw error if limit is not exceeded", async () => {
|
|
34
|
+
mockConfig.checkRateLimit.mockResolvedValue(false);
|
|
35
|
+
await expect(service.checkRateLimit(mockData)).resolves.not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const errors_1 = require("../../errors");
|
|
4
|
+
const role_service_1 = require("../role.service");
|
|
5
|
+
const mockConfig = {
|
|
6
|
+
authorizeByRoles: jest.fn(),
|
|
7
|
+
roles: ["admin", "editor"],
|
|
8
|
+
};
|
|
9
|
+
const mockLogger = {
|
|
10
|
+
error: jest.fn(),
|
|
11
|
+
};
|
|
12
|
+
describe("RoleService", () => {
|
|
13
|
+
let service;
|
|
14
|
+
const mockUser = { id: "user123", role: "viewer" };
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
service = new role_service_1.RoleService(mockConfig, mockLogger);
|
|
18
|
+
});
|
|
19
|
+
it("isAuthorized throws UnauthorizedRoleError if user is not authorized", async () => {
|
|
20
|
+
mockConfig.authorizeByRoles.mockResolvedValue(false);
|
|
21
|
+
await expect(service.isAuthorized(mockUser)).rejects.toThrow(errors_1.UnauthorizedRoleError);
|
|
22
|
+
});
|
|
23
|
+
it("isAuthorized returns true if user is authorized", async () => {
|
|
24
|
+
mockConfig.authorizeByRoles.mockResolvedValue(true);
|
|
25
|
+
await expect(service.isAuthorized(mockUser)).resolves.toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it("isAuthorized bypasses role check if authorizeByRoles is not defined", async () => {
|
|
28
|
+
service = new role_service_1.RoleService({ roles: [] }, mockLogger);
|
|
29
|
+
await expect(service.isAuthorized(mockUser)).resolves.toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as Soap from "@soapjs/soap";
|
|
2
|
+
import { AccountLockConfig } from "../types";
|
|
3
|
+
export declare class AccountLockService<TContext = unknown> {
|
|
4
|
+
private config;
|
|
5
|
+
private logger;
|
|
6
|
+
constructor(config: AccountLockConfig<TContext>, logger: Soap.Logger);
|
|
7
|
+
isAccountLocked(account: any): Promise<boolean>;
|
|
8
|
+
lockAccount(account: any): Promise<void>;
|
|
9
|
+
hasAccountLockExpired(account: any): Promise<boolean>;
|
|
10
|
+
removeAccountLock(account: any): Promise<void>;
|
|
11
|
+
logFailedAttempt(id: string, context?: TContext): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AccountLockService = void 0;
|
|
4
|
+
const errors_1 = require("../errors");
|
|
5
|
+
class AccountLockService {
|
|
6
|
+
config;
|
|
7
|
+
logger;
|
|
8
|
+
constructor(config, logger) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
}
|
|
12
|
+
async isAccountLocked(account) {
|
|
13
|
+
if (await this.config.isAccountLocked(account)) {
|
|
14
|
+
throw new errors_1.AccountLockedError();
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
async lockAccount(account) {
|
|
19
|
+
await this.config.lockAccount(account);
|
|
20
|
+
if (this.config?.notifyOnLockout) {
|
|
21
|
+
await this.config.notifyOnLockout(account);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async hasAccountLockExpired(account) {
|
|
25
|
+
return this.config.hasAccountLockExpired(account);
|
|
26
|
+
}
|
|
27
|
+
async removeAccountLock(account) {
|
|
28
|
+
return this.config.removeAccountLock(account);
|
|
29
|
+
}
|
|
30
|
+
async logFailedAttempt(id, context) {
|
|
31
|
+
try {
|
|
32
|
+
await this.config.logFailedAttempt?.(id, context);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
this.logger?.error(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.AccountLockService = AccountLockService;
|