mirror of
https://github.com/bitwarden/browser
synced 2026-03-01 11:01:17 +00:00
* PM-7392 - EncryptSvc - add new method for detecting if a simple string is an enc string. * PM-7392 - TokenSvc - add checks when setting and retrieving the access token to improve handling around the access token encryption. * PM-7392 - (1) Clean up token svc (2) export access token key type for use in tests. * PM-7392 - Get token svc tests passing; WIP more tests to come for new scenarios. * PM-7392 - Access token secure storage to disk fallback WIP but mostly functional besides weird logout behavior. * PM-7392 - Clean up unnecessary comment * PM-7392 - TokenSvc - refresh token disk storage fallback * PM-7392 - Fix token service tests in prep for adding tests for new scenarios. * PM-7392 - TokenSvc tests - Test new setRefreshToken scenarios * PM-7392 - TokenSvc - getRefreshToken should return null or a value - not undefined. * PM-7392 - Fix test name. * PM-7392 - TokenSvc tests - clean up test names that reference removed refresh token migrated flag. * PM-7392 - getRefreshToken tests done. * PM-7392 - Fix error quote * PM-7392 - TokenSvc tests - setAccessToken new scenarios tested. * PM-7392 - TokenSvc - getAccessToken - if secure storage errors add error to log. * PM-7392 - TokenSvc tests - getAccessToken - all new scenarios tested * PM-7392 - EncryptSvc - test new stringIsEncString method * PM-7392 - Main.ts - fix circ dep issue. * PM-7392 - Main.ts - remove comment. * PM-7392 - Don't re-invent the wheel and simply use existing isSerializedEncString static method. * PM-7392 - Enc String - (1) Add handling for Nan in parseEncryptedString (2) Added null handling to isSerializedEncString. (3) Plan to remove encrypt service implementation Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-7392 - Remove encrypt service method Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-7392 - Actually fix circ dep issues with Justin. Ty! Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * PM-7392 - TokenSvc - update to use EncString instead of EncryptSvc + fix tests. * PM-7392 - TokenSvc - (1) Remove test code (2) Refactor decryptAccessToken method to accept access token key and error on failure to pass required decryption key to method. * PM-7392 - Per PR feedback and discussion, do not log the user out if hte refresh token cannot be found. This will allow users to continue to use the app until their access token expires and we will error on trying to refresh it. The app will then still work on a fresh login for 55 min. * PM-7392 - API service - update doAuthRefresh error to clarify which token cannot be refreshed. * PM-7392 - Fix SetRefreshToken case where a null input would incorrectly trigger a fallback to disk. * PM-7392 - If the access token cannot be refreshed due to a missing refresh token or API keys, then surface an error to the user and log it so it isn't a silent failure + we get a log. * PM-7392 - Fix CLI build errors * PM-7392 - Per PR feedback, add missing tests (thank you Jake for writing these!) Co-authored-by: Jake Fink <jfink@bitwarden.com> * PM-7392 - Per PR feedback, update incorrect comment from 3 releases to 3 months. * PM-7392 - Per PR feedback, remove links. * PM-7392 - Per PR feedback, move tests to existing describe. * PM-7392 - Per PR feedback, adjust all test names to match naming convention. * PM-7392 - ApiService - refreshIdentityToken - log error before swallowing it so we have a record of it. * PM-7392 - Fix copy for errorRefreshingAccessToken * PM-7392 - Per PR feedback, move error handling toast responsibility to client specific app component logic reached via messaging. * PM-7392 - Swap logout reason from enum to type. * PM-7392 - ApiService - Stop using messaging to trigger toast to let user know about refresh access token errors; replace with client specific callback logic. Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * PM-7392 - Per PR feedback, adjust enc string changes and tests. * PM-7392 - Rename file to be type from enum * PM-7392 - ToastService - we need to await the activeToast.onHidden observable so return the activeToast from the showToast. * PM-7392 - Desktop AppComp - cleanup messaging * PM-7392 - Move Logout reason custom type to auth/common * PM-7392 - WIP - Enhancing logout callback to consider the logout reason + move show toast logic into logout callback * PM-7392 - Logout callback should simply pass along the LogoutReason instead of handling it - let each client's message listener handle it. * PM-7392 - More replacements of expired with logoutReason * PM-7392 - More expired to logoutReason replacements * PM-7392 - Build new handlers for displaying the logout reason for desktop & web. * PM-7392 - Revert ToastService changes * PM-7392 - TokenSvc - Replace messageSender with logout callback per PR feedback. * PM-7392 - Desktop App comp - replace toast usage with simple dialog to guarantee users will see the reason for them being logged out. * PM-7392 - Web app comp - fix issue * PM-7392 - Desktop App comp - don't show cancel btn on simple dialogs. * PM-7392 - Desktop App comp - Don't open n simple dialogs. * PM-7392 - Fix browser build * PM-7392 - Remove logout reason from CLI as each logout call handles messaging on its own. * PM-7392 - Previously, if a security stamp was invalid, the session was marked as expired. Restore that functionality. * PM-7392 - Update sync service logoutCallback to include optional user id. * PM-7392 - Clean up web app comp * PM-7392 - Web - app comp - only handle actually possible web logout scenarios. * PM-7392 - Browser Popup app comp - restore done logging out message functionality + add new default logout message * PM-7392 - Add optional user id to logout callbacks. * PM-7392 - Main.background.ts - add clarifying comment. * PM-7392 - Per feedback, use danger simple dialog type for error. * PM-7392 - Browser Popup - add comment clarifying expectation of seeing toasts. * PM-7392 - Consolidate invalidSecurityStamp error handling * PM-7392 - Per PR feedback, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK can be completely sync. + Refactor to method in main.background. * PM-7392 - Per PR feedback, use a named callback for refreshAccessTokenErrorCallback in CLI * PM-7392 - Add TODO * PM-7392 - Re-apply bw.ts changes to new service-container. * PM-7392 - TokenSvc - tweak error message. * PM-7392 - Fix test * PM-7392 - Clean up merge conflict where I duplicated dependencies. * PM-7392 - Per discussion with product, change default logout toast to be info * PM-7392 - After merge, add new logout reason to sync service. * PM-7392 - Remove default logout message per discussion with product since it isn't really visible on desktop or browser. * PM-7392 - address PR feedback. --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Jake Fink <jfink@bitwarden.com> Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
2985 lines
111 KiB
TypeScript
2985 lines
111 KiB
TypeScript
import { MockProxy, mock } from "jest-mock-extended";
|
|
import { firstValueFrom } from "rxjs";
|
|
|
|
import { LogoutReason } from "@bitwarden/auth/common";
|
|
|
|
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
|
|
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
|
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
|
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
|
import { LogService } from "../../platform/abstractions/log.service";
|
|
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
|
import { StorageLocation } from "../../platform/enums";
|
|
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
|
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
|
import { CsprngArray } from "../../types/csprng";
|
|
import { UserId } from "../../types/guid";
|
|
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
|
|
|
|
import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";
|
|
import {
|
|
AccessTokenKey,
|
|
DecodedAccessToken,
|
|
TokenService,
|
|
TokenStorageLocation,
|
|
} from "./token.service";
|
|
import {
|
|
ACCESS_TOKEN_DISK,
|
|
ACCESS_TOKEN_MEMORY,
|
|
API_KEY_CLIENT_ID_DISK,
|
|
API_KEY_CLIENT_ID_MEMORY,
|
|
API_KEY_CLIENT_SECRET_DISK,
|
|
API_KEY_CLIENT_SECRET_MEMORY,
|
|
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
|
|
REFRESH_TOKEN_DISK,
|
|
REFRESH_TOKEN_MEMORY,
|
|
SECURITY_STAMP_MEMORY,
|
|
} from "./token.state";
|
|
|
|
describe("TokenService", () => {
|
|
let tokenService: TokenService;
|
|
let singleUserStateProvider: FakeSingleUserStateProvider;
|
|
let globalStateProvider: FakeGlobalStateProvider;
|
|
|
|
let secureStorageService: MockProxy<AbstractStorageService>;
|
|
let keyGenerationService: MockProxy<KeyGenerationService>;
|
|
let encryptService: MockProxy<EncryptService>;
|
|
let logService: MockProxy<LogService>;
|
|
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
|
|
|
|
const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut;
|
|
const memoryVaultTimeout: VaultTimeout = 30;
|
|
|
|
const diskVaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const diskVaultTimeout: VaultTimeout = VaultTimeoutStringType.Never;
|
|
|
|
const accessTokenJwt =
|
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q";
|
|
|
|
const encryptedAccessToken =
|
|
"2.rFNYSTJoljn8h6GOSNVYdQ==|4dIp7ONJzC+Kx1ClA+1aIAb7EqCQ4OjnADCYdCPg7BKkdheG+yM62ZiONFk+S6at84M+RnGWWO04aIjinTdJhlhyUmszePNATxIfX60Y+bFKQhlMuCtZpYdEmQDzXVgT43YRbf/6NnN9WzhefLqeMiocwoIJTEpLptb+Zcm7T3MJpkX4dR9w5LUOxUTNFEGd5PlWaI8FBavOkNsrzY5skRK70pvFABET5IDeRlKhi8NwbzvTzkO3SisLRzih+djiz5nEZf0+ujeGAp6P+o7l0mB0sXVsNJzcuE4S9QtHLnx31N6z3mQm5pOgP4EmEOdRIcQGc1p7dL1vXcXtaTJLtfKXoJjJbYT3wplnY9Pf8+2FVxdbM3bRB2yVsnEzgLcf9UchKThQSdOy8+5TO/prDbUt5mDpO4GmRltom5ncda8yJaD3Hw1DO7fa0Xh+kfeByxb1AwBC+GTPfqmo5uqr0J4dZsf9cGlPMTElwR3GYmD60OcQ6iDX36CZZjqqJqBwKSpepDXV39p9G347e6YAAvJenLDKtdjgfWXCMXbkwETbMgYooFDRd60KYsGIXV16UwzJSvczgTY2d+hYb2Cl0lClequaiwcRxLVtW2xau6qoEPjTqJjJi9I0Cs2WNL4LRH96Ir14a3bEtnTvkO1NjN+bQNon+KksaP2BqTbuiAfZbBP/cL4S1Oew4G00PSLZUGV5S1BI0ooJy6e2NLQJlYqfCeKM6RgpvgfOiXlZddVgkkB6lohLjyVvcSZNuKPjs1wZMZ9C76bKb6o39NFK8G3/YScELFf9gkueWjmhcjrs22+xNDn5rxXeedwIkVW9UJVNLc//eGxLfp70y8fNDcyTPRN1UUpqT8+wSz+9ZHl4DLUK0DE2jIveEDke8vi4MK/XLMC/c50rr1NCEuVy6iA3nwiOzVo/GNfeKTpzMcR/D9A0gxkC9GyZ3riSsMQsGNXhZCZLdsFYp0gLiiJxVilMUfyTWaygsNm87GPY3ep3GEHcq/pCuxrpLQQYT3V1j95WJvFxb8dSLiPHb8STR0GOZhe7SquI5LIRmYCFTo+3VBnItYeuin9i2xCIqWz886xIyllHN2BIPILbA1lCOsCsz1BRRGNqtLvmTeVRO8iujsHWBJicVgSI7/dgSJwcdOv2t4TIVtnN1hJkQnz+HZcJ2FYK/VWlo4UQYYoML52sBd1sSz/n8/8hrO2N4X9frHHNCrcxeoyChTKo2cm4rAxHylLbCZYvGt/KIW9x3AFkPBMr7tAc3yq98J0Crna8ukXc3F3uGb5QXLnBi//3zBDN6RCv7ByaFW5G0I+pglBegzeFBqKH8xwfy76B2e2VLFF8rz/r/wQzlumGFypsRhAoGxrkZyzjec/k+RNR0arf7TTX7ymC1cueTnItRDx89veW6WLlF53NpAGqC8GJSp4T2FGIIk01y29j6Ji7GOlQ8BUbyLWYjMfHf3khRzAfr6UC2QgVvKWQTKET4Y/b1nZCnwxeW8wC80GHtYGuarsU+KlsEw4242cjyIN1GobrWaA2GTOedQDEMWUA64McAw5fAvMEEao5DM7i57tMzJHeKfruyMuXYQkBca094vmATjJ/T+kIrWGIcmxCT/Fp2SW1hcxr6Ciwuog84LVfbVlUl2MAj3eC/xqL/5HP6Q3ObD0ld444GV+HSrQUqfIvEIn9gFmalW6TGugyhfROACCogoXbeIr1AyMUNDnl4EWlPl6u7SQvPX+itKyq4qhaK2J0W6f7ElLVQ5GbC2uwARuhXOi7mqEZ5FP0V675C5NPZOl2ZEd6BhmuyhGkmQEtEvw0DCKnbKM7bKMk90Y599DSnuEna4BNFBVjJ7k+BuNhXUKO+iNcDZT0pCQhOKRVLWsaqVff3BsuQ4zMEOVnccJwwAVipwSRyxZi8bF+Wyun6BVI8pz1CBvRMy+6ifmIq2awEL8NnV65hF2jyZDEVwsnrvCyT7MlM8l5C3MhqH/MgMcKqOsUz+P6Jv5sBi4WvojsaHzqxQ6miBHpHhGDpYH5K53LVs36henB/tOUTcg5ZnO4ZM67jjB7Oz7to+QnJsldp5Bdwvi1XD/4jeh/Llezu5/KwwytSHnZG1z6dZA7B8rKwnI+yN2Qnfi70h68jzGZ1xCOFPz9KMorNKP3XLw8x2g9H6lEBXdV95uc/TNw+WTJbvKRawns/DZhM1u/g13lU6JG19cht3dh/DlKRcJpj1AdOAxPiUubTSkhBmdwRj2BTTHrVlF3/9ladTP4s4f6Zj9TtQvR9CREVe7CboGflxDYC+Jww3PU50XLmxQjkuV5MkDAmBVcyFCFOcHhDRoxet4FX9ec0wjNeDpYtkI8B/qUS1Rp+is1jOxr4/ni|pabwMkF/SdYKdDlow4uKxaObrAP0urmv7N7fA9bedec=";
|
|
|
|
const accessTokenDecoded: DecodedAccessToken = {
|
|
iss: "http://localhost",
|
|
nbf: 1709324111,
|
|
iat: 1709324111,
|
|
exp: 1709327711,
|
|
scope: ["api", "offline_access"],
|
|
amr: ["Application"],
|
|
client_id: "web",
|
|
sub: "ece70a13-7216-43c4-9977-b1030146e1e7", // user id
|
|
auth_time: 1709324104,
|
|
idp: "bitwarden",
|
|
premium: false,
|
|
email: "example@bitwarden.com",
|
|
email_verified: false,
|
|
sstamp: "GY7JAO64CKKTKBB6ZEAUYL2WOQU7AST2",
|
|
name: "Test User",
|
|
orgowner: [
|
|
"92b49908-b514-45a8-badb-b1030148fe53",
|
|
"38ede322-b4b4-4bd8-9e09-b1070112dc11",
|
|
"b2d07028-a583-4c3e-8d60-b10701198c29",
|
|
"bf934ba2-0fd4-49f2-a95e-b107011fc9e6",
|
|
"c0b7f75d-015f-42c9-b3a6-b108017607ca",
|
|
],
|
|
device: "4b872367-0da6-41a0-adcb-77f2feefc4f4",
|
|
jti: "75161BE4131FF5A2DE511B8C4E2FF89A",
|
|
};
|
|
|
|
const userIdFromAccessToken: UserId = accessTokenDecoded.sub as UserId;
|
|
|
|
const secureStorageOptions: StorageOptions = {
|
|
storageLocation: StorageLocation.Disk,
|
|
useSecureStorage: true,
|
|
userId: userIdFromAccessToken,
|
|
};
|
|
|
|
const accessTokenKeyB64 = { keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" };
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
singleUserStateProvider = new FakeSingleUserStateProvider();
|
|
globalStateProvider = new FakeGlobalStateProvider();
|
|
|
|
secureStorageService = mock<AbstractStorageService>();
|
|
keyGenerationService = mock<KeyGenerationService>();
|
|
encryptService = mock<EncryptService>();
|
|
logService = mock<LogService>();
|
|
logoutCallback = jest.fn();
|
|
|
|
const supportsSecureStorage = false; // default to false; tests will override as needed
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it("instantiates", () => {
|
|
expect(tokenService).not.toBeFalsy();
|
|
});
|
|
|
|
describe("Access Token methods", () => {
|
|
const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`;
|
|
const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`;
|
|
|
|
describe("hasAccessToken$", () => {
|
|
it("returns true when an access token exists in memory", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// Act
|
|
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
|
|
|
// Assert
|
|
expect(result).toEqual(true);
|
|
});
|
|
|
|
it("returns true when an access token exists in disk", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// Act
|
|
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
|
|
|
// Assert
|
|
expect(result).toEqual(true);
|
|
});
|
|
|
|
it("returns true when an access token exists in secure storage", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
|
|
|
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
|
|
|
// Act
|
|
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
|
|
|
// Assert
|
|
expect(result).toEqual(true);
|
|
});
|
|
|
|
it("returns false when no access token exists in memory, disk, or secure storage", async () => {
|
|
// Act
|
|
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
|
|
|
// Assert
|
|
expect(result).toEqual(false);
|
|
});
|
|
});
|
|
|
|
describe("setAccessToken", () => {
|
|
it("throws an error when the access token is null", async () => {
|
|
// Act
|
|
const result = tokenService.setAccessToken(
|
|
null,
|
|
VaultTimeoutAction.Lock,
|
|
VaultTimeoutStringType.Never,
|
|
);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Access token is required.");
|
|
});
|
|
|
|
it("throws an error when an invalid token is passed in", async () => {
|
|
// Act
|
|
const result = tokenService.setAccessToken(
|
|
"invalidToken",
|
|
VaultTimeoutAction.Lock,
|
|
VaultTimeoutStringType.Never,
|
|
);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("JWT must have 3 parts");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout is missing", async () => {
|
|
// Act
|
|
const result = tokenService.setAccessToken(accessTokenJwt, VaultTimeoutAction.Lock, null);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout is required.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout action is missing", async () => {
|
|
// Act
|
|
const result = tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
null,
|
|
VaultTimeoutStringType.Never,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout Action is required.");
|
|
});
|
|
|
|
it("should not throw an error as long as the token is valid", async () => {
|
|
// Act
|
|
const result = tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
VaultTimeoutAction.Lock,
|
|
VaultTimeoutStringType.Never,
|
|
);
|
|
// Assert
|
|
await expect(result).resolves.not.toThrow();
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("set the access token in memory", async () => {
|
|
// Act
|
|
await tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
memoryVaultTimeoutAction,
|
|
memoryVaultTimeout,
|
|
);
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(accessTokenJwt);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
|
it("should set the access token in disk", async () => {
|
|
// Act
|
|
await tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
);
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(accessTokenJwt);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage supported on platform)", () => {
|
|
const accessTokenKey = new SymmetricCryptoKey(
|
|
new Uint8Array(64) as CsprngArray,
|
|
) as AccessTokenKey;
|
|
|
|
const accessTokenKeyB64 = {
|
|
keyB64:
|
|
"lI7lSoejJ1HsrTkRs2Ipm0x+YcZMKpgm7WQGCNjAWmFAyGOKossXwBJvvtbxcYDZ0G0XNY8Gp7DBXZV2tWAO5w==",
|
|
};
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it("should set an access token key in secure storage, the encrypted access token in disk, and clear out the token in memory", async () => {
|
|
// Arrange:
|
|
|
|
// For testing purposes, let's assume that the access token is already in memory
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
keyGenerationService.createKey.mockResolvedValue(accessTokenKey);
|
|
|
|
const mockEncryptedAccessToken = "encryptedAccessToken";
|
|
|
|
encryptService.encrypt.mockResolvedValue({
|
|
encryptedString: mockEncryptedAccessToken,
|
|
} as any);
|
|
|
|
// First call resolves to null to simulate no key in secure storage
|
|
// then resolves to the key to simulate the key being set in secure storage
|
|
// and retrieved successfully to ensure it was set.
|
|
secureStorageService.get.mockResolvedValueOnce(null).mockResolvedValue(accessTokenKeyB64);
|
|
|
|
// Act
|
|
await tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
);
|
|
// Assert
|
|
|
|
// assert that the AccessTokenKey was set in secure storage
|
|
expect(secureStorageService.save).toHaveBeenCalledWith(
|
|
accessTokenKeySecureStorageKey,
|
|
accessTokenKey,
|
|
secureStorageOptions,
|
|
);
|
|
|
|
// assert that the access token was encrypted and set in disk
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(mockEncryptedAccessToken);
|
|
|
|
// assert data was migrated out of memory
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("should fallback to disk storage for the access token if the access token cannot be set in secure storage", async () => {
|
|
// This tests the scenario where the access token key silently fails to be set in secure storage
|
|
|
|
// Arrange:
|
|
keyGenerationService.createKey.mockResolvedValue(accessTokenKey);
|
|
|
|
// First call resolves to null to simulate no key in secure storage
|
|
// and then resolves to no key after it should have been set
|
|
secureStorageService.get.mockResolvedValueOnce(null).mockResolvedValue(null);
|
|
|
|
// Act
|
|
await tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
);
|
|
// Assert
|
|
|
|
// assert that we tried to store the AccessTokenKey in secure storage
|
|
expect(secureStorageService.save).toHaveBeenCalledWith(
|
|
accessTokenKeySecureStorageKey,
|
|
accessTokenKey,
|
|
secureStorageOptions,
|
|
);
|
|
|
|
// assert that we logged the error
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
"SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.",
|
|
new Error("New Access token key unable to be retrieved from secure storage."),
|
|
);
|
|
|
|
// assert that the access token was put on disk unencrypted
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(accessTokenJwt);
|
|
});
|
|
|
|
it("should fallback to disk storage for the access token if secure storage errors on trying to get an existing access token key", async () => {
|
|
// This tests the scenario for linux users who don't have secure storage configured.
|
|
|
|
// Arrange:
|
|
keyGenerationService.createKey.mockResolvedValue(accessTokenKey);
|
|
|
|
// Mock linux secure storage error
|
|
const secureStorageError = "Secure storage error";
|
|
secureStorageService.get.mockRejectedValue(new Error(secureStorageError));
|
|
|
|
// Act
|
|
await tokenService.setAccessToken(
|
|
accessTokenJwt,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
);
|
|
// Assert
|
|
|
|
// assert that we logged the error
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
"SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.",
|
|
new Error(secureStorageError),
|
|
);
|
|
|
|
// assert that the access token was put on disk unencrypted
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(accessTokenJwt);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getAccessToken", () => {
|
|
it("returns null when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
const result = await tokenService.getAccessToken();
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when no access token is found in memory, disk, or secure storage", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken();
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
test.each([
|
|
["gets the access token from memory when a user id is provided ", userIdFromAccessToken],
|
|
["gets the access token from memory when no user id is provided", undefined],
|
|
])("%s", async (_, userId) => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// set disk to undefined
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Need to have global active id set to the user id
|
|
if (!userId) {
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
}
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userId);
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenJwt);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
|
test.each([
|
|
["gets the access token from disk when the user id is specified", userIdFromAccessToken],
|
|
["gets the access token from disk when no user id is specified", undefined],
|
|
])("%s", async (_, userId) => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// Need to have global active id set to the user id
|
|
if (!userId) {
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
}
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userId);
|
|
// Assert
|
|
expect(result).toEqual(accessTokenJwt);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage supported on platform)", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
"gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided",
|
|
userIdFromAccessToken,
|
|
],
|
|
[
|
|
"gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided",
|
|
undefined,
|
|
],
|
|
])("%s", async (_, userId) => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
|
|
|
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
|
encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken");
|
|
|
|
// Need to have global active id set to the user id
|
|
if (!userId) {
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
}
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userId);
|
|
|
|
// Assert
|
|
expect(result).toEqual("decryptedAccessToken");
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
"falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided",
|
|
userIdFromAccessToken,
|
|
],
|
|
[
|
|
"falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided",
|
|
undefined,
|
|
],
|
|
])("%s", async (_, userId) => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// Need to have global active id set to the user id
|
|
if (!userId) {
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
}
|
|
|
|
// No access token key set
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userId);
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenJwt);
|
|
});
|
|
|
|
it("logs the error and logs the user out when the access token key cannot be retrieved from secure storage if the access token is encrypted", async () => {
|
|
// This tests the intermittent windows 10/11 scenario in which the access token key was stored successfully in secure storage and the
|
|
// access token was encrypted with it and stored on disk successfully. However, on retrieval the access token key isn't able to
|
|
// retrieved for whatever reason.
|
|
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, encryptedAccessToken]);
|
|
|
|
// No access token key set
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
|
|
// assert that we logged the error
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
"Access token key not found to decrypt encrypted access token. Logging user out.",
|
|
);
|
|
|
|
// assert that we logged the user out
|
|
expect(logoutCallback).toHaveBeenCalledWith(
|
|
"accessTokenUnableToBeDecrypted",
|
|
userIdFromAccessToken,
|
|
);
|
|
});
|
|
|
|
it("logs the error and logs the user out when secure storage errors on trying to get an access token key", async () => {
|
|
// This tests the linux scenario where users might not have secure storage support configured.
|
|
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, encryptedAccessToken]);
|
|
|
|
// Mock linux secure storage error
|
|
const secureStorageError = "Secure storage error";
|
|
secureStorageService.get.mockRejectedValue(new Error(secureStorageError));
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
|
|
// assert that we logged the error
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
"Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.",
|
|
new Error(secureStorageError),
|
|
);
|
|
|
|
// assert that we logged the user out
|
|
expect(logoutCallback).toHaveBeenCalledWith(
|
|
"accessTokenUnableToBeDecrypted",
|
|
userIdFromAccessToken,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clearAccessToken", () => {
|
|
it("throws an error when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.clearAccessToken();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot clear access token.");
|
|
});
|
|
|
|
describe("Secure storage enabled", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
"clears the access token from all storage locations when a user id is provided",
|
|
userIdFromAccessToken,
|
|
],
|
|
[
|
|
"clears the access token from all storage locations when there is a global active user",
|
|
undefined,
|
|
],
|
|
])("%s", async (_, userId) => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// Need to have global active id set to the user id
|
|
if (!userId) {
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
}
|
|
|
|
// Act
|
|
await tokenService.clearAccessToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
|
|
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
|
accessTokenKeySecureStorageKey,
|
|
secureStorageOptions,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("decodeAccessToken", () => {
|
|
it("throws an error when no access token is provided or retrievable from state", async () => {
|
|
// Access
|
|
tokenService.getAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.decodeAccessToken();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Access token not found.");
|
|
});
|
|
|
|
it("decodes the access token when a valid one is stored", async () => {
|
|
// Arrange
|
|
tokenService.getAccessToken = jest.fn().mockResolvedValue(accessTokenJwt);
|
|
|
|
// Act
|
|
const result = await tokenService.decodeAccessToken();
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenDecoded);
|
|
});
|
|
});
|
|
|
|
describe("Data methods", () => {
|
|
describe("getTokenExpirationDate", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getTokenExpirationDate();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("returns null when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
const result = await tokenService.getTokenExpirationDate();
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when the decoded access token does not have an expiration date", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithoutExp = { ...accessTokenDecoded };
|
|
delete accessTokenDecodedWithoutExp.exp;
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithoutExp);
|
|
|
|
// Act
|
|
const result = await tokenService.getTokenExpirationDate();
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when the decoded access token has a non numeric expiration date", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonNumericExp = { ...accessTokenDecoded, exp: "non-numeric" };
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonNumericExp);
|
|
|
|
// Act
|
|
const result = await tokenService.getTokenExpirationDate();
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns the expiration date of the access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await tokenService.getTokenExpirationDate();
|
|
|
|
// Assert
|
|
expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000));
|
|
});
|
|
});
|
|
|
|
describe("tokenSecondsRemaining", () => {
|
|
it("returns 0 when the tokenExpirationDate is null", async () => {
|
|
// Arrange
|
|
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenSecondsRemaining();
|
|
|
|
// Assert
|
|
expect(result).toEqual(0);
|
|
});
|
|
|
|
it("returns the number of seconds remaining until the token expires", async () => {
|
|
// Arrange
|
|
// Lock the time to ensure a consistent test environment
|
|
// otherwise we have flaky issues with set system time date and the Date.now() call.
|
|
const fixedCurrentTime = new Date("2024-03-06T00:00:00Z");
|
|
jest.useFakeTimers().setSystemTime(fixedCurrentTime);
|
|
|
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
|
const expirationInSeconds = nowInSeconds + 3600; // token expires in 1 hr
|
|
const expectedSecondsRemaining = expirationInSeconds - nowInSeconds;
|
|
|
|
const expirationDate = new Date(0);
|
|
expirationDate.setUTCSeconds(expirationInSeconds);
|
|
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenSecondsRemaining();
|
|
|
|
// Assert
|
|
expect(result).toEqual(expectedSecondsRemaining);
|
|
|
|
// Reset the timers to be the real ones
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it("returns the number of seconds remaining until the token expires when given an offset", async () => {
|
|
// Arrange
|
|
// Lock the time to ensure a consistent test environment
|
|
// otherwise we have flaky issues with set system time date and the Date.now() call.
|
|
const fixedCurrentTime = new Date("2024-03-06T00:00:00Z");
|
|
jest.useFakeTimers().setSystemTime(fixedCurrentTime);
|
|
|
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
|
const offsetSeconds = 300; // 5 minute offset
|
|
const expirationInSeconds = nowInSeconds + 3600; // token expires in 1 hr
|
|
const expectedSecondsRemaining = expirationInSeconds - nowInSeconds - offsetSeconds; // Adjust for offset
|
|
|
|
const expirationDate = new Date(0);
|
|
expirationDate.setUTCSeconds(expirationInSeconds);
|
|
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenSecondsRemaining(offsetSeconds);
|
|
|
|
// Assert
|
|
expect(result).toEqual(expectedSecondsRemaining);
|
|
|
|
// Reset the timers to be the real ones
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe("tokenNeedsRefresh", () => {
|
|
it("returns true when the token is within the default refresh threshold (5 min)", async () => {
|
|
// Arrange
|
|
const tokenSecondsRemaining = 60;
|
|
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenNeedsRefresh();
|
|
|
|
// Assert
|
|
expect(result).toEqual(true);
|
|
});
|
|
|
|
it("returns false when the token is outside the default refresh threshold (5 min)", async () => {
|
|
// Arrange
|
|
const tokenSecondsRemaining = 600;
|
|
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenNeedsRefresh();
|
|
|
|
// Assert
|
|
expect(result).toEqual(false);
|
|
});
|
|
|
|
it("returns true when the token is within the specified refresh threshold", async () => {
|
|
// Arrange
|
|
const tokenSecondsRemaining = 60;
|
|
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenNeedsRefresh(2);
|
|
|
|
// Assert
|
|
expect(result).toEqual(true);
|
|
});
|
|
|
|
it("returns false when the token is outside the specified refresh threshold", async () => {
|
|
// Arrange
|
|
const tokenSecondsRemaining = 600;
|
|
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
|
|
|
// Act
|
|
const result = await tokenService.tokenNeedsRefresh(5);
|
|
|
|
// Assert
|
|
expect(result).toEqual(false);
|
|
});
|
|
});
|
|
|
|
describe("getUserId", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getUserId();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("throws an error when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getUserId();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No user id found");
|
|
});
|
|
|
|
it("throws an error when the decoded access token has a non-string user id", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 };
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonStringSub);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getUserId();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No user id found");
|
|
});
|
|
|
|
it("returns the user id from the decoded access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await tokenService.getUserId();
|
|
|
|
// Assert
|
|
expect(result).toEqual(userIdFromAccessToken);
|
|
});
|
|
});
|
|
|
|
describe("getUserIdFromAccessToken", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("throws an error when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No user id found");
|
|
});
|
|
|
|
it("throws an error when the decoded access token has a non-string user id", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 };
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonStringSub);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).getUserIdFromAccessToken(accessTokenJwt);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No user id found");
|
|
});
|
|
|
|
it("returns the user id from the decoded access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await (tokenService as any).getUserIdFromAccessToken(accessTokenJwt);
|
|
|
|
// Assert
|
|
expect(result).toEqual(userIdFromAccessToken);
|
|
});
|
|
});
|
|
|
|
describe("getEmail", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getEmail();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("throws an error when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getEmail();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No email found");
|
|
});
|
|
|
|
it("throws an error when the decoded access token has a non-string email", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonStringEmail = { ...accessTokenDecoded, email: 123 };
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonStringEmail);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getEmail();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No email found");
|
|
});
|
|
|
|
it("returns the email from the decoded access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await tokenService.getEmail();
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenDecoded.email);
|
|
});
|
|
});
|
|
|
|
describe("getEmailVerified", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getEmailVerified();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("throws an error when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getEmailVerified();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No email verification found");
|
|
});
|
|
|
|
it("throws an error when the decoded access token has a non-boolean email_verified", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonBooleanEmailVerified = {
|
|
...accessTokenDecoded,
|
|
email_verified: 123,
|
|
};
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonBooleanEmailVerified);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getEmailVerified();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No email verification found");
|
|
});
|
|
|
|
it("returns the email_verified from the decoded access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await tokenService.getEmailVerified();
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenDecoded.email_verified);
|
|
});
|
|
});
|
|
|
|
describe("getName", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getName();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("returns null when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
const result = await tokenService.getName();
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when the decoded access token has a non-string name", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonStringName = { ...accessTokenDecoded, name: 123 };
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonStringName);
|
|
|
|
// Act
|
|
const result = await tokenService.getName();
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns the name from the decoded access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await tokenService.getName();
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenDecoded.name);
|
|
});
|
|
});
|
|
|
|
describe("getIssuer", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getIssuer();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("throws an error when the decoded access token is null", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getIssuer();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No issuer found");
|
|
});
|
|
|
|
it("throws an error when the decoded access token has a non-string iss", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithNonStringIss = { ...accessTokenDecoded, iss: 123 };
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithNonStringIss);
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getIssuer();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("No issuer found");
|
|
});
|
|
|
|
it("returns the issuer from the decoded access token when a valid access token is stored", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
|
|
|
// Act
|
|
const result = await tokenService.getIssuer();
|
|
|
|
// Assert
|
|
expect(result).toEqual(accessTokenDecoded.iss);
|
|
});
|
|
});
|
|
|
|
describe("getIsExternal", () => {
|
|
it("throws an error when the access token cannot be decoded", async () => {
|
|
// Arrange
|
|
tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error"));
|
|
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getIsExternal();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
|
});
|
|
|
|
it("returns false when the amr (Authentication Method Reference) claim does not contain 'external'", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithoutExternalAmr = {
|
|
...accessTokenDecoded,
|
|
amr: ["not-external"],
|
|
};
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithoutExternalAmr);
|
|
|
|
// Act
|
|
const result = await tokenService.getIsExternal();
|
|
|
|
// Assert
|
|
expect(result).toEqual(false);
|
|
});
|
|
|
|
it("returns true when the amr (Authentication Method Reference) claim contains 'external'", async () => {
|
|
// Arrange
|
|
const accessTokenDecodedWithExternalAmr = {
|
|
...accessTokenDecoded,
|
|
amr: ["external"],
|
|
};
|
|
tokenService.decodeAccessToken = jest
|
|
.fn()
|
|
.mockResolvedValue(accessTokenDecodedWithExternalAmr);
|
|
|
|
// Act
|
|
const result = await tokenService.getIsExternal();
|
|
|
|
// Assert
|
|
expect(result).toEqual(true);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Refresh Token methods", () => {
|
|
const refreshToken = "refreshToken";
|
|
const refreshTokenPartialSecureStorageKey = `_refreshToken`;
|
|
const refreshTokenSecureStorageKey = `${userIdFromAccessToken}${refreshTokenPartialSecureStorageKey}`;
|
|
|
|
describe("setRefreshToken", () => {
|
|
it("throws an error when no user id is provided", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
VaultTimeoutAction.Lock,
|
|
null,
|
|
null,
|
|
);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot save refresh token.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout is missing", async () => {
|
|
// Act
|
|
const result = (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
VaultTimeoutAction.Lock,
|
|
null,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout is required.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout action is missing", async () => {
|
|
// Act
|
|
const result = (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
null,
|
|
VaultTimeoutStringType.Never,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout Action is required.");
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("sets the refresh token in memory when given a user id", async () => {
|
|
// Act
|
|
await (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
memoryVaultTimeoutAction,
|
|
memoryVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(refreshToken);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
|
it("sets the refresh token in disk when given a user id", async () => {
|
|
// Act
|
|
await (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(refreshToken);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage supported on platform)", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it("sets the refresh token in secure storage, removes data on disk or in memory, and sets a flag to indicate the token has been migrated when given a user id", async () => {
|
|
// Arrange:
|
|
// For testing purposes, let's assume that the token is already in disk and memory
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
// We immediately call to get the refresh token from secure storage after setting it to ensure it was set.
|
|
secureStorageService.get.mockResolvedValue(refreshToken);
|
|
|
|
// Act
|
|
await (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
// Assert
|
|
|
|
// assert that the refresh token was set in secure storage
|
|
expect(secureStorageService.save).toHaveBeenCalledWith(
|
|
refreshTokenSecureStorageKey,
|
|
refreshToken,
|
|
secureStorageOptions,
|
|
);
|
|
|
|
// assert data was migrated out of disk and memory
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("tries to set the refresh token in secure storage then falls back to disk storage when the refresh token cannot be read back out of secure storage", async () => {
|
|
// Arrange:
|
|
// We immediately call to get the refresh token from secure storage after setting it to ensure it was set.
|
|
// So, set it to return null to mock a failure to set the refresh token in secure storage.
|
|
// This mocks the windows 10/11 intermittent issue where the token is not set in secure storage successfully.
|
|
secureStorageService.get.mockResolvedValue(null);
|
|
|
|
// Act
|
|
await (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
// Assert
|
|
|
|
// assert that the refresh token was set in secure storage
|
|
expect(secureStorageService.save).toHaveBeenCalledWith(
|
|
refreshTokenSecureStorageKey,
|
|
refreshToken,
|
|
secureStorageOptions,
|
|
);
|
|
|
|
// assert that we tried to set the refresh token in secure storage, but it failed, so we reverted to disk storage
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(refreshToken);
|
|
});
|
|
|
|
it("tries to set the refresh token in secure storage, throws an error, then falls back to disk storage when secure storage isn't supported", async () => {
|
|
// Arrange:
|
|
// Mock the secure storage service to throw an error when trying to save the refresh token
|
|
// to simulate linux scenarios where a secure storage provider isn't configured.
|
|
secureStorageService.save.mockRejectedValue(new Error("Secure storage not supported"));
|
|
|
|
// Act
|
|
await (tokenService as any).setRefreshToken(
|
|
refreshToken,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
// Assert
|
|
|
|
// assert that the refresh token was set in secure storage
|
|
expect(secureStorageService.save).toHaveBeenCalledWith(
|
|
refreshTokenSecureStorageKey,
|
|
refreshToken,
|
|
secureStorageOptions,
|
|
);
|
|
|
|
// assert that we tried to set the refresh token in secure storage, but it failed, so we reverted to disk storage
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(refreshToken);
|
|
});
|
|
|
|
it("returns the unencrypted access token when secure storage retrieval fails but the access token is still pre-migration", async () => {
|
|
// This tests the linux scenario where users might not have secure storage support configured.
|
|
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
|
|
|
// Mock linux secure storage error
|
|
const secureStorageError = "Secure storage error";
|
|
secureStorageService.get.mockRejectedValue(new Error(secureStorageError));
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
// assert that we returned the unencrypted, pre-migration access token
|
|
expect(result).toBe(accessTokenJwt);
|
|
|
|
// assert that we did not log an error or log the user out
|
|
expect(logService.error).not.toHaveBeenCalled();
|
|
|
|
expect(logoutCallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not error and fallback to disk storage when passed a null value for the refresh token", async () => {
|
|
// Arrange
|
|
secureStorageService.get.mockResolvedValue(null);
|
|
|
|
// Act
|
|
await (tokenService as any).setRefreshToken(
|
|
null,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(secureStorageService.save).toHaveBeenCalledWith(
|
|
refreshTokenSecureStorageKey,
|
|
null,
|
|
secureStorageOptions,
|
|
);
|
|
|
|
expect(logService.error).not.toHaveBeenCalled();
|
|
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("logs the error and logs the user out when the access token cannot be decrypted", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, encryptedAccessToken]);
|
|
|
|
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
|
encryptService.decryptToUtf8.mockRejectedValue(new Error("Decryption error"));
|
|
|
|
// Act
|
|
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
|
|
// assert that we logged the error
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
"Failed to decrypt access token",
|
|
new Error("Decryption error"),
|
|
);
|
|
|
|
// assert that we logged the user out
|
|
expect(logoutCallback).toHaveBeenCalledWith(
|
|
"accessTokenUnableToBeDecrypted",
|
|
userIdFromAccessToken,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getRefreshToken", () => {
|
|
it("returns null when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
const result = await (tokenService as any).getRefreshToken();
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when no refresh token is found in memory, disk, or secure storage", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await (tokenService as any).getRefreshToken();
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken();
|
|
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
});
|
|
|
|
it("gets the refresh token from memory when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
|
it("gets the refresh token from disk when no user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken();
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
});
|
|
|
|
it("gets the refresh token from disk when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests (secure storage supported on platform)", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it("gets the refresh token from secure storage when no user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
secureStorageService.get.mockResolvedValue(refreshToken);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken();
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
});
|
|
|
|
it("gets the refresh token from secure storage when a user id is specified", async () => {
|
|
// Arrange
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
secureStorageService.get.mockResolvedValue(refreshToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
});
|
|
|
|
it("falls back and gets the refresh token from disk when a user id is specified even if the platform supports secure storage", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
|
|
// assert that secure storage was not called
|
|
expect(secureStorageService.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken();
|
|
|
|
// Assert
|
|
expect(result).toEqual(refreshToken);
|
|
|
|
// assert that secure storage was not called
|
|
expect(secureStorageService.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => {
|
|
// Arrange
|
|
secureStorageService.get.mockResolvedValue(null);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null and logs when the refresh token is not found in secure storage when it should be", async () => {
|
|
// This scenario mocks the case where we have intermittent windows 10/11 issues w/ secure storage not
|
|
// returning the refresh token when it should be there.
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
secureStorageService.get.mockResolvedValue(null);
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
"Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.",
|
|
);
|
|
});
|
|
|
|
it("logs out when retrieving the refresh token out of secure storage errors", async () => {
|
|
// This scenario mocks the case where linux users don't have secure storage configured.
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
const secureStorageSvcMockErrorMsg = "Secure storage retrieval error";
|
|
|
|
secureStorageService.get.mockRejectedValue(new Error(secureStorageSvcMockErrorMsg));
|
|
|
|
// Act
|
|
const result = await tokenService.getRefreshToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
|
|
// expect that we logged an error and logged the user out
|
|
expect(logService.error).toHaveBeenCalledWith(
|
|
`Failed to retrieve refresh token from secure storage`,
|
|
new Error(secureStorageSvcMockErrorMsg),
|
|
);
|
|
|
|
expect(logoutCallback).toHaveBeenCalledWith(
|
|
"refreshTokenSecureStorageRetrievalFailure",
|
|
userIdFromAccessToken,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clearRefreshToken", () => {
|
|
it("throws an error when no user id is provided", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).clearRefreshToken();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot clear refresh token.");
|
|
});
|
|
|
|
describe("Secure storage enabled", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it("clears the refresh token from all storage locations when given a user id", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, refreshToken]);
|
|
|
|
// Act
|
|
await (tokenService as any).clearRefreshToken(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
|
|
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
|
refreshTokenSecureStorageKey,
|
|
secureStorageOptions,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Client Id methods", () => {
|
|
const clientId = "clientId";
|
|
|
|
describe("setClientId", () => {
|
|
it("throws an error when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot save client id.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout is missing", async () => {
|
|
// Arrange
|
|
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout is required.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout action is missing", async () => {
|
|
// Arrange
|
|
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = tokenService.setClientId(clientId, null, VaultTimeoutStringType.Never);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout Action is required.");
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("sets the client id in memory when there is an active user in global state", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await tokenService.setClientId(clientId, memoryVaultTimeoutAction, memoryVaultTimeout);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(clientId);
|
|
});
|
|
|
|
it("sets the client id in memory when given a user id", async () => {
|
|
// Act
|
|
await tokenService.setClientId(
|
|
clientId,
|
|
memoryVaultTimeoutAction,
|
|
memoryVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(clientId);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests", () => {
|
|
it("sets the client id in disk when there is an active user in global state", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await tokenService.setClientId(clientId, diskVaultTimeoutAction, diskVaultTimeout);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock,
|
|
).toHaveBeenCalledWith(clientId);
|
|
});
|
|
|
|
it("sets the client id on disk when given a user id", async () => {
|
|
// Act
|
|
await tokenService.setClientId(
|
|
clientId,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock,
|
|
).toHaveBeenCalledWith(clientId);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getClientId", () => {
|
|
it("returns undefined when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
const result = await tokenService.getClientId();
|
|
// Assert
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("returns null when no client id is found in memory or disk", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientId();
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("gets the client id from memory when no user id is specified (uses global active user)", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
// set disk to undefined
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientId();
|
|
|
|
// Assert
|
|
expect(result).toEqual(clientId);
|
|
});
|
|
|
|
it("gets the client id from memory when given a user id", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
// set disk to undefined
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientId(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(clientId);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests", () => {
|
|
it("gets the client id from disk when no user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientId();
|
|
// Assert
|
|
expect(result).toEqual(clientId);
|
|
});
|
|
|
|
it("gets the client id from disk when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientId(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(clientId);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clearClientId", () => {
|
|
it("throws an error when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).clearClientId();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot clear client id.");
|
|
});
|
|
|
|
it("clears the client id from memory and disk when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
// Act
|
|
await (tokenService as any).clearClientId(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("clears the client id from memory and disk when there is a global active user", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientId]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await (tokenService as any).clearClientId();
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK).nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Client Secret methods", () => {
|
|
const clientSecret = "clientSecret";
|
|
|
|
describe("setClientSecret", () => {
|
|
it("throws an error when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.setClientSecret(
|
|
clientSecret,
|
|
VaultTimeoutAction.Lock,
|
|
VaultTimeoutStringType.Never,
|
|
);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot save client secret.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout is missing", async () => {
|
|
// Arrange
|
|
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = tokenService.setClientSecret(clientSecret, VaultTimeoutAction.Lock, null);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout is required.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout action is missing", async () => {
|
|
// Arrange
|
|
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = tokenService.setClientSecret(
|
|
clientSecret,
|
|
null,
|
|
VaultTimeoutStringType.Never,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout Action is required.");
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("sets the client secret in memory when there is an active user in global state", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await tokenService.setClientSecret(
|
|
clientSecret,
|
|
memoryVaultTimeoutAction,
|
|
memoryVaultTimeout,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(clientSecret);
|
|
});
|
|
|
|
it("sets the client secret in memory when a user id is specified", async () => {
|
|
// Act
|
|
await tokenService.setClientSecret(
|
|
clientSecret,
|
|
memoryVaultTimeoutAction,
|
|
memoryVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(clientSecret);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests", () => {
|
|
it("sets the client secret on disk when there is an active user in global state", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await tokenService.setClientSecret(
|
|
clientSecret,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(clientSecret);
|
|
});
|
|
|
|
it("sets the client secret on disk when a user id is specified", async () => {
|
|
// Act
|
|
await tokenService.setClientSecret(
|
|
clientSecret,
|
|
diskVaultTimeoutAction,
|
|
diskVaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(clientSecret);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getClientSecret", () => {
|
|
it("returns undefined when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
const result = await tokenService.getClientSecret();
|
|
// Assert
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("returns null when no client secret is found in memory or disk", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientSecret();
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
describe("Memory storage tests", () => {
|
|
it("gets the client secret from memory when no user id is specified (uses global active user)", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
// set disk to undefined
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientSecret();
|
|
|
|
// Assert
|
|
expect(result).toEqual(clientSecret);
|
|
});
|
|
|
|
it("gets the client secret from memory when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
// set disk to undefined
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientSecret(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(clientSecret);
|
|
});
|
|
});
|
|
|
|
describe("Disk storage tests", () => {
|
|
it("gets the client secret from disk when no user id specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientSecret();
|
|
// Assert
|
|
expect(result).toEqual(clientSecret);
|
|
});
|
|
|
|
it("gets the client secret from disk when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, undefined]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
// Act
|
|
const result = await tokenService.getClientSecret(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(clientSecret);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clearClientSecret", () => {
|
|
it("throws an error when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = (tokenService as any).clearClientSecret();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot clear client secret.");
|
|
});
|
|
|
|
it("clears the client secret from memory and disk when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
// Act
|
|
await (tokenService as any).clearClientSecret(userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("clears the client secret from memory and disk when there is a global active user", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.stateSubject.next([userIdFromAccessToken, clientSecret]);
|
|
|
|
// Need to have global active id set to the user id
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await (tokenService as any).clearClientSecret();
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
|
.nextMock,
|
|
).toHaveBeenCalledWith(null);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("setTokens", () => {
|
|
it("calls to set all tokens after deriving user id from the access token when called with valid params", async () => {
|
|
// Arrange
|
|
const refreshToken = "refreshToken";
|
|
// specific vault timeout actions and vault timeouts don't change this test so values don't matter.
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout = 30;
|
|
const clientId = "clientId";
|
|
const clientSecret = "clientSecret";
|
|
|
|
(tokenService as any)._setAccessToken = jest.fn();
|
|
// any hack allows for mocking private method.
|
|
(tokenService as any).setRefreshToken = jest.fn();
|
|
tokenService.setClientId = jest.fn();
|
|
tokenService.setClientSecret = jest.fn();
|
|
|
|
// Act
|
|
// Note: passing a valid access token so that a valid user id can be determined from the access token
|
|
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken, [
|
|
clientId,
|
|
clientSecret,
|
|
]);
|
|
|
|
// Assert
|
|
expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith(
|
|
accessTokenJwt,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// any hack allows for testing private methods
|
|
expect((tokenService as any).setRefreshToken).toHaveBeenCalledWith(
|
|
refreshToken,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
expect(tokenService.setClientId).toHaveBeenCalledWith(
|
|
clientId,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
expect(tokenService.setClientSecret).toHaveBeenCalledWith(
|
|
clientSecret,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
});
|
|
|
|
it("does not try to set client id and client secret when they are not passed in", async () => {
|
|
// Arrange
|
|
const refreshToken = "refreshToken";
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout = 30;
|
|
|
|
(tokenService as any)._setAccessToken = jest.fn();
|
|
(tokenService as any).setRefreshToken = jest.fn();
|
|
tokenService.setClientId = jest.fn();
|
|
tokenService.setClientSecret = jest.fn();
|
|
|
|
// Act
|
|
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken);
|
|
|
|
// Assert
|
|
expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith(
|
|
accessTokenJwt,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
// any hack allows for testing private methods
|
|
expect((tokenService as any).setRefreshToken).toHaveBeenCalledWith(
|
|
refreshToken,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
userIdFromAccessToken,
|
|
);
|
|
|
|
expect(tokenService.setClientId).not.toHaveBeenCalled();
|
|
expect(tokenService.setClientSecret).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws an error when the access token is invalid", async () => {
|
|
// Arrange
|
|
const accessToken = "invalidToken";
|
|
const refreshToken = "refreshToken";
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout = 30;
|
|
|
|
// Act
|
|
const result = tokenService.setTokens(
|
|
accessToken,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
refreshToken,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("JWT must have 3 parts");
|
|
});
|
|
|
|
it("throws an error when the access token is missing", async () => {
|
|
// Arrange
|
|
const accessToken: string = null;
|
|
const refreshToken = "refreshToken";
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout = 30;
|
|
|
|
// Act
|
|
const result = tokenService.setTokens(
|
|
accessToken,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
refreshToken,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Access token is required.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout is missing", async () => {
|
|
// Arrange
|
|
const refreshToken = "refreshToken";
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout: VaultTimeout = null;
|
|
|
|
// Act
|
|
const result = tokenService.setTokens(
|
|
accessTokenJwt,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
refreshToken,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout is required.");
|
|
});
|
|
|
|
it("should throw an error if the vault timeout action is missing", async () => {
|
|
// Arrange
|
|
const refreshToken = "refreshToken";
|
|
const vaultTimeoutAction: VaultTimeoutAction = null;
|
|
const vaultTimeout: VaultTimeout = VaultTimeoutStringType.Never;
|
|
|
|
// Act
|
|
const result = tokenService.setTokens(
|
|
accessTokenJwt,
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
refreshToken,
|
|
);
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("Vault Timeout Action is required.");
|
|
});
|
|
|
|
it("does not throw an error or set the refresh token when the refresh token is missing", async () => {
|
|
// Arrange
|
|
const refreshToken: string = null;
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout = 30;
|
|
(tokenService as any).setRefreshToken = jest.fn();
|
|
|
|
// Act
|
|
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken);
|
|
|
|
// Assert
|
|
expect((tokenService as any).setRefreshToken).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("clearTokens", () => {
|
|
it("calls to clear all tokens when given a specified user id", async () => {
|
|
// Arrange
|
|
const userId = "userId" as UserId;
|
|
|
|
tokenService.clearAccessToken = jest.fn();
|
|
(tokenService as any).clearRefreshToken = jest.fn();
|
|
(tokenService as any).clearClientId = jest.fn();
|
|
(tokenService as any).clearClientSecret = jest.fn();
|
|
|
|
// Act
|
|
|
|
await tokenService.clearTokens(userId);
|
|
|
|
// Assert
|
|
|
|
expect(tokenService.clearAccessToken).toHaveBeenCalledWith(userId);
|
|
expect((tokenService as any).clearRefreshToken).toHaveBeenCalledWith(userId);
|
|
expect((tokenService as any).clearClientId).toHaveBeenCalledWith(userId);
|
|
expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId);
|
|
});
|
|
|
|
it("calls to clear all tokens when there is an active user", async () => {
|
|
// Arrange
|
|
const userId = "userId" as UserId;
|
|
|
|
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).stateSubject.next(userId);
|
|
|
|
tokenService.clearAccessToken = jest.fn();
|
|
(tokenService as any).clearRefreshToken = jest.fn();
|
|
(tokenService as any).clearClientId = jest.fn();
|
|
(tokenService as any).clearClientSecret = jest.fn();
|
|
|
|
// Act
|
|
|
|
await tokenService.clearTokens();
|
|
|
|
// Assert
|
|
|
|
expect(tokenService.clearAccessToken).toHaveBeenCalledWith(userId);
|
|
expect((tokenService as any).clearRefreshToken).toHaveBeenCalledWith(userId);
|
|
expect((tokenService as any).clearClientId).toHaveBeenCalledWith(userId);
|
|
expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId);
|
|
});
|
|
|
|
it("does not call to clear all tokens when no user id is provided and there is no active user in global state", async () => {
|
|
// Arrange
|
|
tokenService.clearAccessToken = jest.fn();
|
|
(tokenService as any).clearRefreshToken = jest.fn();
|
|
(tokenService as any).clearClientId = jest.fn();
|
|
(tokenService as any).clearClientSecret = jest.fn();
|
|
|
|
// Act
|
|
|
|
const result = tokenService.clearTokens();
|
|
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot clear tokens.");
|
|
});
|
|
});
|
|
|
|
describe("Two Factor Token methods", () => {
|
|
describe("setTwoFactorToken", () => {
|
|
it("sets the email and two factor token when there hasn't been a previous record (initializing the record)", async () => {
|
|
// Arrange
|
|
const email = "testUser@email.com";
|
|
const twoFactorToken = "twoFactorTokenForTestUser";
|
|
// Act
|
|
await tokenService.setTwoFactorToken(email, twoFactorToken);
|
|
// Assert
|
|
expect(
|
|
globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock,
|
|
).toHaveBeenCalledWith({ [email]: twoFactorToken });
|
|
});
|
|
|
|
it("sets the email and two factor token when there is an initialized value already (updating the existing record)", async () => {
|
|
// Arrange
|
|
const email = "testUser@email.com";
|
|
const twoFactorToken = "twoFactorTokenForTestUser";
|
|
const initialTwoFactorTokenRecord: Record<string, string> = {
|
|
otherUser: "otherUserTwoFactorToken",
|
|
};
|
|
|
|
globalStateProvider
|
|
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
|
|
.stateSubject.next(initialTwoFactorTokenRecord);
|
|
|
|
// Act
|
|
await tokenService.setTwoFactorToken(email, twoFactorToken);
|
|
|
|
// Assert
|
|
expect(
|
|
globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock,
|
|
).toHaveBeenCalledWith({ [email]: twoFactorToken, ...initialTwoFactorTokenRecord });
|
|
});
|
|
});
|
|
|
|
describe("getTwoFactorToken", () => {
|
|
it("returns the two factor token when given an email", async () => {
|
|
// Arrange
|
|
const email = "testUser";
|
|
const twoFactorToken = "twoFactorTokenForTestUser";
|
|
const initialTwoFactorTokenRecord: Record<string, string> = {
|
|
[email]: twoFactorToken,
|
|
};
|
|
|
|
globalStateProvider
|
|
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
|
|
.stateSubject.next(initialTwoFactorTokenRecord);
|
|
|
|
// Act
|
|
const result = await tokenService.getTwoFactorToken(email);
|
|
|
|
// Assert
|
|
expect(result).toEqual(twoFactorToken);
|
|
});
|
|
|
|
it("does not return the two factor token when given an email that doesn't exist", async () => {
|
|
// Arrange
|
|
const email = "testUser";
|
|
const initialTwoFactorTokenRecord: Record<string, string> = {
|
|
otherUser: "twoFactorTokenForOtherUser",
|
|
};
|
|
|
|
globalStateProvider
|
|
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
|
|
.stateSubject.next(initialTwoFactorTokenRecord);
|
|
|
|
// Act
|
|
const result = await tokenService.getTwoFactorToken(email);
|
|
|
|
// Assert
|
|
expect(result).toEqual(undefined);
|
|
});
|
|
|
|
it("returns null when there is no two factor token record", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
|
|
.stateSubject.next(null);
|
|
|
|
// Act
|
|
const result = await tokenService.getTwoFactorToken("testUser");
|
|
|
|
// Assert
|
|
expect(result).toEqual(null);
|
|
});
|
|
});
|
|
|
|
describe("clearTwoFactorToken", () => {
|
|
it("clears the two factor token for the given email when a record exists", async () => {
|
|
// Arrange
|
|
const email = "testUser";
|
|
const twoFactorToken = "twoFactorTokenForTestUser";
|
|
const initialTwoFactorTokenRecord: Record<string, string> = {
|
|
[email]: twoFactorToken,
|
|
};
|
|
|
|
globalStateProvider
|
|
.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL)
|
|
.stateSubject.next(initialTwoFactorTokenRecord);
|
|
|
|
// Act
|
|
await tokenService.clearTwoFactorToken(email);
|
|
|
|
// Assert
|
|
expect(
|
|
globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock,
|
|
).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
it("initializes the record and deletes the value when the record doesn't exist", async () => {
|
|
// Arrange
|
|
const email = "testUser";
|
|
|
|
// Act
|
|
await tokenService.clearTwoFactorToken(email);
|
|
|
|
// Assert
|
|
expect(
|
|
globalStateProvider.getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL).nextMock,
|
|
).toHaveBeenCalledWith({});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Security Stamp methods", () => {
|
|
const mockSecurityStamp = "securityStamp";
|
|
|
|
describe("setSecurityStamp", () => {
|
|
it("throws an error deletes the value no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.setSecurityStamp(mockSecurityStamp);
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot set security stamp.");
|
|
});
|
|
|
|
it("sets the security stamp in memory when there is an active user in global state", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
// Act
|
|
await tokenService.setSecurityStamp(mockSecurityStamp);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(mockSecurityStamp);
|
|
});
|
|
|
|
it("sets the security stamp in memory when a user id is specified", async () => {
|
|
// Act
|
|
await tokenService.setSecurityStamp(mockSecurityStamp, userIdFromAccessToken);
|
|
|
|
// Assert
|
|
expect(
|
|
singleUserStateProvider.getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY).nextMock,
|
|
).toHaveBeenCalledWith(mockSecurityStamp);
|
|
});
|
|
});
|
|
|
|
describe("getSecurityStamp", () => {
|
|
it("throws an error when no user id is provided and there is no active user in global state", async () => {
|
|
// Act
|
|
// note: don't await here because we want to test the error
|
|
const result = tokenService.getSecurityStamp();
|
|
// Assert
|
|
await expect(result).rejects.toThrow("User id not found. Cannot get security stamp.");
|
|
});
|
|
|
|
it("returns the security stamp from memory when no user id is specified (uses global active user)", async () => {
|
|
// Arrange
|
|
globalStateProvider
|
|
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
|
.stateSubject.next(userIdFromAccessToken);
|
|
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, mockSecurityStamp]);
|
|
|
|
// Act
|
|
const result = await tokenService.getSecurityStamp();
|
|
|
|
// Assert
|
|
expect(result).toEqual(mockSecurityStamp);
|
|
});
|
|
|
|
it("returns the security stamp from memory when a user id is specified", async () => {
|
|
// Arrange
|
|
singleUserStateProvider
|
|
.getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY)
|
|
.stateSubject.next([userIdFromAccessToken, mockSecurityStamp]);
|
|
|
|
// Act
|
|
const result = await tokenService.getSecurityStamp(userIdFromAccessToken);
|
|
// Assert
|
|
expect(result).toEqual(mockSecurityStamp);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("determineStorageLocation", () => {
|
|
it("should throw an error if the vault timeout is null", async () => {
|
|
// Arrange
|
|
const vaultTimeoutAction: VaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout: VaultTimeout = null;
|
|
// Act
|
|
const result = (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
false,
|
|
);
|
|
// Assert
|
|
await expect(result).rejects.toThrow(
|
|
"TokenService - determineStorageLocation: We expect the vault timeout to always exist at this point.",
|
|
);
|
|
});
|
|
|
|
it("should throw an error if the vault timeout action is null", async () => {
|
|
// Arrange
|
|
const vaultTimeoutAction: VaultTimeoutAction = null;
|
|
const vaultTimeout: VaultTimeout = 0;
|
|
// Act
|
|
const result = (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
false,
|
|
);
|
|
// Assert
|
|
await expect(result).rejects.toThrow(
|
|
"TokenService - determineStorageLocation: We expect the vault timeout action to always exist at this point.",
|
|
);
|
|
});
|
|
|
|
describe("Secure storage disabled", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = false;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it.each([
|
|
[VaultTimeoutStringType.OnRestart],
|
|
[VaultTimeoutStringType.OnLocked],
|
|
[VaultTimeoutStringType.OnSleep],
|
|
[VaultTimeoutStringType.OnIdle],
|
|
[0],
|
|
[30],
|
|
[60],
|
|
[90],
|
|
[120],
|
|
])(
|
|
"returns memory when the vault timeout action is logout and the vault timeout is defined %s (not Never)",
|
|
async (vaultTimeout: VaultTimeout) => {
|
|
// Arrange
|
|
const vaultTimeoutAction = VaultTimeoutAction.LogOut;
|
|
const useSecureStorage = false;
|
|
// Act
|
|
const result = await (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
useSecureStorage,
|
|
);
|
|
// Assert
|
|
expect(result).toEqual(TokenStorageLocation.Memory);
|
|
},
|
|
);
|
|
|
|
it("returns disk when the vault timeout action is logout and the vault timeout is never", async () => {
|
|
// Arrange
|
|
const vaultTimeoutAction = VaultTimeoutAction.LogOut;
|
|
const vaultTimeout: VaultTimeout = VaultTimeoutStringType.Never;
|
|
const useSecureStorage = false;
|
|
// Act
|
|
const result = await (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
useSecureStorage,
|
|
);
|
|
// Assert
|
|
expect(result).toEqual(TokenStorageLocation.Disk);
|
|
});
|
|
|
|
it("returns disk when the vault timeout action is lock and the vault timeout is never", async () => {
|
|
// Arrange
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout: VaultTimeout = VaultTimeoutStringType.Never;
|
|
const useSecureStorage = false;
|
|
// Act
|
|
const result = await (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
useSecureStorage,
|
|
);
|
|
// Assert
|
|
expect(result).toEqual(TokenStorageLocation.Disk);
|
|
});
|
|
});
|
|
|
|
describe("Secure storage enabled", () => {
|
|
beforeEach(() => {
|
|
const supportsSecureStorage = true;
|
|
tokenService = createTokenService(supportsSecureStorage);
|
|
});
|
|
|
|
it.each([
|
|
[VaultTimeoutStringType.OnRestart],
|
|
[VaultTimeoutStringType.OnLocked],
|
|
[VaultTimeoutStringType.OnSleep],
|
|
[VaultTimeoutStringType.OnIdle],
|
|
[0],
|
|
[30],
|
|
[60],
|
|
[90],
|
|
[120],
|
|
])(
|
|
"returns memory when the vault timeout action is logout and the vault timeout is defined %s (not Never)",
|
|
async (vaultTimeout: VaultTimeout) => {
|
|
// Arrange
|
|
const vaultTimeoutAction = VaultTimeoutAction.LogOut;
|
|
const useSecureStorage = true;
|
|
// Act
|
|
const result = await (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
useSecureStorage,
|
|
);
|
|
// Assert
|
|
expect(result).toEqual(TokenStorageLocation.Memory);
|
|
},
|
|
);
|
|
|
|
it("returns secure storage when the vault timeout action is logout and the vault timeout is never", async () => {
|
|
// Arrange
|
|
const vaultTimeoutAction = VaultTimeoutAction.LogOut;
|
|
const vaultTimeout: VaultTimeout = VaultTimeoutStringType.Never;
|
|
const useSecureStorage = true;
|
|
// Act
|
|
const result = await (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
useSecureStorage,
|
|
);
|
|
// Assert
|
|
expect(result).toEqual(TokenStorageLocation.SecureStorage);
|
|
});
|
|
|
|
it("returns secure storage when the vault timeout action is lock and the vault timeout is never", async () => {
|
|
// Arrange
|
|
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
|
const vaultTimeout: VaultTimeout = VaultTimeoutStringType.Never;
|
|
const useSecureStorage = true;
|
|
// Act
|
|
const result = await (tokenService as any).determineStorageLocation(
|
|
vaultTimeoutAction,
|
|
vaultTimeout,
|
|
useSecureStorage,
|
|
);
|
|
// Assert
|
|
expect(result).toEqual(TokenStorageLocation.SecureStorage);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Helpers
|
|
function createTokenService(supportsSecureStorage: boolean) {
|
|
return new TokenService(
|
|
singleUserStateProvider,
|
|
globalStateProvider,
|
|
supportsSecureStorage,
|
|
secureStorageService,
|
|
keyGenerationService,
|
|
encryptService,
|
|
logService,
|
|
logoutCallback,
|
|
);
|
|
}
|
|
});
|