1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 11:01:17 +00:00
Files
browser/libs/common/src/auth/services/token.service.spec.ts
Jared Snider f691854387 Auth - PM-7392 & PM-7436 - Token Service - Desktop - Add disk fallback for secure storage failures (#8913)
* 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>
2024-06-03 12:36:45 -04:00

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,
);
}
});