mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 07:13:32 +00:00
Auth/PM-7072 - Token Service - Access Token Secure Storage Refactor (#8412)
* PM-5263 - TokenSvc - WIP on access token secure storage refactor * PM-5263 - Add key generation svc to token svc. * PM-5263 - TokenSvc - more progress on encrypt access token work. * PM-5263 - TokenSvc TODO cleanup * PM-5263 - TokenSvc - rename * PM-5263 - TokenSvc - decryptAccess token must return null as that is a valid case. * PM-5263 - Add EncryptSvc dep to TokenSvc * PM-5263 - Add secure storage to token service * PM-5263 - TokenSvc - (1) Finish implementing accessTokenKey stored in secure storage + encrypted access token stored on disk (2) Remove no longer necessary migration flag as the presence of the accessTokenKey now serves the same purpose. Co-authored-by: Jake Fink <jfink@bitwarden.com> * PM-5263 - TokenSvc - (1) Tweak return structure of decryptAccessToken to be more debuggable (2) Add TODO to add more error handling. * PM-5263 - TODO: update tests * PM-5263 - add temp logs * PM-5263 - TokenSvc - remove logs now that I don't need them. * fix tests for access token * PM-5263 - TokenSvc test cleanup - small tweaks / cleanup * PM-5263 - TokenService - per PR feedback from Justin - add error message to error message if possible. Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --------- Co-authored-by: Jake Fink <jfink@bitwarden.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
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";
|
||||
@@ -12,7 +15,6 @@ import { DecodedAccessToken, TokenService } from "./token.service";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -28,7 +30,10 @@ describe("TokenService", () => {
|
||||
let singleUserStateProvider: FakeSingleUserStateProvider;
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
|
||||
const secureStorageService = mock<AbstractStorageService>();
|
||||
let secureStorageService: MockProxy<AbstractStorageService>;
|
||||
let keyGenerationService: MockProxy<KeyGenerationService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut;
|
||||
const memoryVaultTimeout = 30;
|
||||
@@ -74,12 +79,19 @@ describe("TokenService", () => {
|
||||
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>();
|
||||
|
||||
const supportsSecureStorage = false; // default to false; tests will override as needed
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
@@ -89,8 +101,8 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Access Token methods", () => {
|
||||
const accessTokenPartialSecureStorageKey = `_accessToken`;
|
||||
const accessTokenSecureStorageKey = `${userIdFromAccessToken}${accessTokenPartialSecureStorageKey}`;
|
||||
const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`;
|
||||
const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`;
|
||||
|
||||
describe("setAccessToken", () => {
|
||||
it("should throw an error if the access token is null", async () => {
|
||||
@@ -150,18 +162,22 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should set the access token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated", async () => {
|
||||
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 disk and memory
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// 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" as any);
|
||||
|
||||
const mockEncryptedAccessToken = "encryptedAccessToken";
|
||||
|
||||
encryptService.encrypt.mockResolvedValue({
|
||||
encryptedString: mockEncryptedAccessToken,
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
await tokenService.setAccessToken(
|
||||
accessTokenJwt,
|
||||
@@ -170,27 +186,22 @@ describe("TokenService", () => {
|
||||
);
|
||||
// Assert
|
||||
|
||||
// assert that the access token was set in secure storage
|
||||
// assert that the AccessTokenKey was set in secure storage
|
||||
expect(secureStorageService.save).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
accessTokenJwt,
|
||||
accessTokenKeySecureStorageKey,
|
||||
"accessTokenKey",
|
||||
secureStorageOptions,
|
||||
);
|
||||
|
||||
// assert data was migrated out of disk and memory + flag was set
|
||||
// assert that the access token was encrypted and set in disk
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
).toHaveBeenCalledWith(mockEncryptedAccessToken);
|
||||
|
||||
// assert data was migrated out of memory
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(
|
||||
singleUserStateProvider.getFake(
|
||||
userIdFromAccessToken,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
).nextMock,
|
||||
).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -216,7 +227,13 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("should get the access token from memory with no user id specified (uses global active user)", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the access token from memory for the provided user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
["should get the access token from memory with no user id provided", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -228,37 +245,28 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from memory for the specified user id", async () => {
|
||||
// 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]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
it("should get the access token from disk with no user id specified", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the access token from disk for the specified user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
["should get the access token from disk with no user id specified", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -269,28 +277,14 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from disk for the specified user id", 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 tokenService.getAccessToken(userIdFromAccessToken);
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
@@ -302,7 +296,16 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should get the access token from secure storage when no user id is specified and the migration flag is set to true", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the encrypted access token from disk, decrypt it, and return it when user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should get the encrypted access token from disk, decrypt it, and return it when no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -310,76 +313,35 @@ describe("TokenService", () => {
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenJwt);
|
||||
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
||||
encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken");
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
|
||||
// set access token migration flag to true
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, true]);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from secure storage when user id is specified and the migration flag set to true", async () => {
|
||||
// Arrange
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenJwt);
|
||||
|
||||
// set access token migration flag to true
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, true]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should fallback and get the access token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// set access token migration flag to false
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, false]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual("decryptedAccessToken");
|
||||
});
|
||||
|
||||
it("should fallback and get the access token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should fallback and get 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)
|
||||
@@ -390,23 +352,19 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// set access token migration flag to false
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, false]);
|
||||
// No access token key set
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -426,7 +384,16 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should clear the access token from all storage locations for the specified user id", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should clear the access token from all storage locations for the provided user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should clear the access token from all storage locations for the global active user",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -436,6 +403,13 @@ describe("TokenService", () => {
|
||||
.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);
|
||||
|
||||
@@ -448,39 +422,7 @@ describe("TokenService", () => {
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("should clear the access token from all storage locations for the global active user", async () => {
|
||||
// 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
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
await tokenService.clearAccessToken();
|
||||
|
||||
// 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(
|
||||
accessTokenSecureStorageKey,
|
||||
accessTokenKeySecureStorageKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
@@ -2232,6 +2174,9 @@ describe("TokenService", () => {
|
||||
globalStateProvider,
|
||||
supportsSecureStorage,
|
||||
secureStorageService,
|
||||
keyGenerationService,
|
||||
encryptService,
|
||||
logService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user