1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-31 15:43:28 +00:00
Files
browser/libs/common/src/auth/services/token.service.ts
Jared Snider 473c5311fa Auth/PM-5501 - VaultTimeoutSettingsService State Provider Migration (#8604)
* PM-5501 - VaultTimeoutSettingsSvc - refactor var names in getVaultTimeoutAction

* PM-5501 - Add state definitions and key definitions + test deserialization of key defs.

* PM-5501 - Add state provider dep to VaultTimeoutSettingsSvc

* PM-5501 - Refactor getVaultTimeout

* PM-5501 - VaultTimeoutSettingsService - Build getMaxVaultTimeoutPolicyByUserId helper

* PM-5501 - (1) Update state definitions (2) convert KeyDefs to UserKeyDefs (2) Remove everBeenUnlocked as we won't need it

* PM-5501 - VaultTimeoutSettingsSvc - POC for getVaultTimeoutActionByUserId$ method + new private determineVaultTimeoutAction helper.

* PM-5501 - VaultTimeoutSettingsSvc - build set and observable get methods for vault timeout settings

* PM-5501 - Update web references to use new vault timeout setting service methods

* PM-5501 - VaultTimeoutSettingsSvc - write up abstraction js docs

* PM-5501 - VaultTimeoutSettingsSvc abstraction - finish tweaks

* PM-5501 - VaultTimeoutSettingsSvc - add catchError blocks to observables to protect outer observables and prevent cancellation in case of error.

* PM-5501 - Remove vault timeout settings from state service implementation.

* PM-5501 - VaultTimeoutSettingsServiceStateProviderMigrator first draft

* PM-5501 - WIP - replace some state service calls with calls to vault timeout settings svc.

* PM-5501 - Replace state service calls in login strategies to get vault timeout settings data with VaultTimeoutSettingsService calls.

* PM-5501 - Fix login strategy tests

* PM-5501 - Update login strategy tests to pass

* PM-5501 - CryptoSvc - share VaultTimeout user key def to allow crypto svc access to the vault timeout without creating a circular dep.

* PM-5501 - Fix dependency injections.

* PM-5501 - ApiSvc - replace state svc with vault timeout settings svc.

* PM-5501 - VaultTimeoutSettingsServiceStateProviderMigrator more cleanup

* PM-5501 - Test VaultTimeoutSettingsServiceStateProviderMigrator

* PM-5501 - VaultTimeoutSettingsSvc tests updated

* PM-5501 - Update all setVaultTimeoutOptions references

* PM-5501 - VaultTimeoutSettingsSvc - Update setVaultTimeoutOptions to remove unnecessary logic and clean up clearTokens condition.

* PM-5501 - Fix vault timeout service tests

* PM-5501 - Update VaultTimeoutSettings state tests to pass

* PM-5501 - Desktop - system svc - fix build by replacing use of removed method.

* PM-5501 - Fix CLI by properly configuring super class deps in NodeApiService

* PM-5501 - Actually finish getitng deps fixed to get CLI to build

* PM-5501 - VaultTimeoutSettingsSvc.determineVaultTimeoutAction - pass userId to getAvailableVaultTimeoutActions to prevent hang waiting for an active user.

* PM-5501 - VaultTimeoutSettingSvc test - enhance getVaultTimeoutActionByUserId$ to also test PIN scenarios as an unlock method

* PM-5501 - bump migration version

* PM-5501 - Refactor migration to ensure the migration persists null vault timeout values.

* PM-5501 - Bump migration version

* PM-5501 - Fix web build issues introduced by merging main.

* PM-5501 - Bump migration version

* PM-5501 - PreferencesComponent - revert dep change from InternalPolicyService to standard PolicyService abstraction

* PM-5501 - Address all PR feedback from Jake

Co-authored-by: Jake Fink <jfink@bitwarden.com>

* PM-5501 - VaultTimeoutSettingsSvc tests - add tests for setVaultTimeoutOptions

* PM-5501 - VaultTimeoutSettingsSvc - setVaultTimeoutOptions - Update tests to use platform's desired syntax.

* PM-5501 - Fix tests

* PM-5501 - Create new VaultTimeout type

* PM-5501 - Create new DEFAULT_VAULT_TIMEOUT to allow each client to inject their default timeout into the VaultTimeoutSettingService

* PM-5501 - Migrate client default vault timeout to new injection token

* PM-5501 - Update VaultTimeoutSettingsSvc to use VaultTimeout type and apply default vault timeout if it is null.

* PM-5501 - Update vaultTimeout: number to be vaultTimeout: VaultTimeout everywhere I could find it.

* PM-5501 - More changes based on changing vaultTimeout from number to VaultTimeout type.

* PM-5501 - VaultTimeoutSvc - Update shouldLock logic which previously checked for null (never) or any negative values (any strings except never) with a simple string type check.

* PM-5501 - More cleanup of vaultTimeout type change - replacing null checks with "never" checks

* PM-5501 - VaultTimeoutSettingsSvc - refactor determineVaultTimeout to properly treat string and numeric vault timeouts.

* PM-5501 - Update vault timeout settings service tests to reflect new VaultTimeout type.

* PM-5501 - VaultTimeoutSettingsService - add more test cases for getVaultTimeoutByUserId

* PM-5501 - (1) Remove "immediately" as 0 is numerically meaningful and can be used with Math.min (2) Add VaultTimeoutOption interface for use in all places we show the user a list of vault timeout options.

* PM-5501 - VaultTimeoutSettingSvc - update tests to use 0 as immediately.

* PM-5501 - VaultTimeoutInputComp - Add new types and update applyVaultTimeoutPolicy logic appropriately.

* PM-5501 - Add new types to all preferences and setting components across clients.

* PM-5501 - Fix bug on web where navigating to the preferences page throws an error b/c the validatorChange function isn't defined.

* PM-5501 - WIP on updating vault timeout setting migration and rollback + testing it.

* PM-5501 - Update VaultTimeoutSettingsSvc state provider migration and tests to map existing possible values into new VaultTImeout type.

* PM-5501 - Fix vault timeout settings state tests by changing number to new VaultTimeout type.

* PM-5501 - Fix crypto svc auto key refresh test to use "never" instead of null.

* PM-5501 - Add clarifying comment to vaulttimeout type

* PM-5501 - Desktop app comp - replace systemTimeoutOptions with vault timeout type.

* PM-5501 - Update vault timeout service tests to use VaultTimeout type.

* PM-5501 - VaultTimeoutSettingsSvc - (1) Fix bug where vault timeout action didn't have a default like it did before (2) Fix bug in userHasMasterPassword where it would incorrectly return the active user stream for a given user id as a fallback. There is no guarantee the given user would match the active user so the paths are mutually exclusive.

* PM-5501 - Login Strategy fix - Move retrieval of vault timeout settings and setting of the tokens until after account init and user decryption options set as those opts are needed to properly determine the user's available vault timeout actions.

* PM-5501 - Fix vault timeout settings svc tests

* PM-5501 - VaultTimeoutSettingSvc - move default logic to determine methods + refactor default vault timeout action to properly default to lock in scenarios the user has lock available.

* Update libs/angular/src/components/settings/vault-timeout-input.component.ts

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* PM-5501 - Per PR feedback, cleanup commented out vault timeout options

* PM-5501 - Fix vault timeout input comp lint issues

* PM-5501 - Per PR feedback from Cesar, update VaultTimeout type to use const so we can avoid any magic string usage. Awesome.

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* PM-5501 - CLI - use "never" as default vault timeout instead of null.

* PM-5501 - Fix broken tests

* PM-5501 - Bump migration version

* PM-5501 - Fix build errors after merging main.

* PM-5501 - Update mockMigrationHelper to pass along client type so tests will respect it.

* PM-5501 - Update VaultTimeoutSettingsServiceStateProviderMigrator and tests to use new CLI client type to convert undefined values to never so that CLI users don't lose their session upon running this migration.

* PM-5501 - Bump migration version

* PM-5501 - Fix migration tests to use new authenticated user format

* PM-5501 Update rollback tests

* PM-5501 - Adjust migration based on feedback.

* PM-5501 - Per Jake's find, fix missed -2

Co-authored-by: Jake Fink <jfink@bitwarden.com>

* PM-5501 - Add user id to needsStorageReseed.

Co-authored-by: Jake Fink <jfink@bitwarden.com>

* PM-5501 - Per PR feedback, setVaultTimeoutOptions shouldn't accept null for vault timeout anymore.

* PM-5501 - Per PR feedback, add null checks for set methods for setting vault timeout or vault timeout action.

* PM-5501 - Per PR feedback, add more context as to why we need vault timeout settings to persist after logout.

* PM-5501 - Per PR feedback, fix userHasMasterPassword

* PM-5501 - VaultTimeoutSettingsService - fix userHasMasterPassword check by checking for null decryption options.

* PM-5501 - Remove state service from vault timeout settings service (WOOO)

* PM-5501 - Bump migration version

* PM-5501 - Account Security comp - refactor to consider ease of debugging.

* PM-5501 - (1) Add checks for null vault timeout and vault timeout actions (2) Add tests for new scenarios.

* PM-5501 - VaultTimeoutSettingsSvc - setVaultTimeoutOptions - fix bug where nullish check would throw incorrectly if immediately (0) was picked as the timeout.

* PM-5501 - Per PR feedback, clean up remaining token service methods which accept null for timeout and add tests. .

* PM-5501 - Fix nit

---------

Co-authored-by: Jake Fink <jfink@bitwarden.com>
Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
2024-05-13 15:56:04 -04:00

994 lines
32 KiB
TypeScript

import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
import { Opaque } from "type-fest";
import { decodeJwtTokenToJson } from "@bitwarden/auth/common";
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 { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import {
GlobalState,
GlobalStateProvider,
SingleUserStateProvider,
UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service";
import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.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";
export enum TokenStorageLocation {
Disk = "disk",
SecureStorage = "secureStorage",
Memory = "memory",
}
/**
* Type representing the structure of a standard Bitwarden decoded access token.
* src: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
* Note: all claims are technically optional so we must verify their existence before using them.
* Note 2: NumericDate is a number representing a date in seconds since the Unix epoch.
*/
export type DecodedAccessToken = {
/** Issuer - the issuer of the token, typically the URL of the authentication server */
iss?: string;
/** Not Before - a timestamp defining when the token starts being valid */
nbf?: number;
/** Issued At - a timestamp of when the token was issued */
iat?: number;
/** Expiration Time - a NumericDate timestamp of when the token will expire */
exp?: number;
/** Scope - the scope of the access request, such as the permissions the token grants */
scope?: string[];
/** Authentication Method Reference - the methods used in the authentication */
amr?: string[];
/** Client ID - the identifier for the client that requested the token */
client_id?: string;
/** Subject - the unique identifier for the user */
sub?: string;
/** Authentication Time - a timestamp of when the user authentication occurred */
auth_time?: number;
/** Identity Provider - the system or service that authenticated the user */
idp?: string;
/** Premium - a boolean flag indicating whether the account is premium */
premium?: boolean;
/** Email - the user's email address */
email?: string;
/** Email Verified - a boolean flag indicating whether the user's email address has been verified */
email_verified?: boolean;
/**
* Security Stamp - a unique identifier which invalidates the access token if it changes in the db
* (typically after critical account changes like a password change)
*/
sstamp?: string;
/** Name - the name of the user */
name?: string;
/** Organization Owners - a list of organization owner identifiers */
orgowner?: string[];
/** Device - the identifier of the device used */
device?: string;
/** JWT ID - a unique identifier for the JWT */
jti?: string;
};
/**
* A symmetric key for encrypting the access token before the token is stored on disk.
* This key should be stored in secure storage.
* */
type AccessTokenKey = Opaque<SymmetricCryptoKey, "AccessTokenKey">;
export class TokenService implements TokenServiceAbstraction {
private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey";
private readonly refreshTokenSecureStorageKey: string = "_refreshToken";
private emailTwoFactorTokenRecordGlobalState: GlobalState<Record<string, string>>;
private activeUserIdGlobalState: GlobalState<UserId>;
constructor(
// Note: we cannot use ActiveStateProvider because if we ever want to inject
// this service into the AccountService, we will make a circular dependency
private singleUserStateProvider: SingleUserStateProvider,
private globalStateProvider: GlobalStateProvider,
private readonly platformSupportsSecureStorage: boolean,
private secureStorageService: AbstractStorageService,
private keyGenerationService: KeyGenerationService,
private encryptService: EncryptService,
private logService: LogService,
) {
this.initializeState();
}
hasAccessToken$(userId: UserId): Observable<boolean> {
// FIXME Once once vault timeout action is observable, we can use it to determine storage location
// and avoid the need to check both disk and memory.
return combineLatest([
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$,
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$,
]).pipe(map(([disk, memory]) => Boolean(disk || memory)));
}
// pivoting to an approach where we create a symmetric key we store in secure storage
// which is used to protect the data before persisting to disk.
// We will also use the same symmetric key to decrypt the data when reading from disk.
private initializeState(): void {
this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get(
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
);
this.activeUserIdGlobalState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
}
async setTokens(
accessToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
refreshToken?: string,
clientIdClientSecret?: [string, string],
): Promise<void> {
if (!accessToken) {
throw new Error("Access token is required.");
}
// Can't check for falsey b/c 0 is a valid value
if (vaultTimeout == null) {
throw new Error("Vault Timeout is required.");
}
if (vaultTimeoutAction == null) {
throw new Error("Vault Timeout Action is required.");
}
// get user id the access token
const userId: UserId = await this.getUserIdFromAccessToken(accessToken);
if (!userId) {
throw new Error("User id not found. Cannot set tokens.");
}
await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
if (refreshToken) {
await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId);
}
if (clientIdClientSecret != null) {
await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId);
await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId);
}
}
private async getAccessTokenKey(userId: UserId): Promise<AccessTokenKey | null> {
const accessTokenKeyB64 = await this.secureStorageService.get<
ReturnType<SymmetricCryptoKey["toJSON"]>
>(`${userId}${this.accessTokenKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
if (!accessTokenKeyB64) {
return null;
}
const accessTokenKey = SymmetricCryptoKey.fromJSON(accessTokenKeyB64) as AccessTokenKey;
return accessTokenKey;
}
private async createAndSaveAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
const newAccessTokenKey = (await this.keyGenerationService.createKey(512)) as AccessTokenKey;
await this.secureStorageService.save<AccessTokenKey>(
`${userId}${this.accessTokenKeySecureStorageKey}`,
newAccessTokenKey,
this.getSecureStorageOptions(userId),
);
return newAccessTokenKey;
}
private async clearAccessTokenKey(userId: UserId): Promise<void> {
await this.secureStorageService.remove(
`${userId}${this.accessTokenKeySecureStorageKey}`,
this.getSecureStorageOptions(userId),
);
}
private async getOrCreateAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
if (!this.platformSupportsSecureStorage) {
throw new Error("Platform does not support secure storage. Cannot obtain access token key.");
}
if (!userId) {
throw new Error("User id not found. Cannot obtain access token key.");
}
// First see if we have an accessTokenKey in secure storage and return it if we do
let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId);
if (!accessTokenKey) {
// Otherwise, create a new one and save it to secure storage, then return it
accessTokenKey = await this.createAndSaveAccessTokenKey(userId);
}
return accessTokenKey;
}
private async encryptAccessToken(accessToken: string, userId: UserId): Promise<EncString> {
const accessTokenKey = await this.getOrCreateAccessTokenKey(userId);
return await this.encryptService.encrypt(accessToken, accessTokenKey);
}
private async decryptAccessToken(
encryptedAccessToken: EncString,
userId: UserId,
): Promise<string | null> {
const accessTokenKey = await this.getAccessTokenKey(userId);
if (!accessTokenKey) {
// If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet
// and we have to return null here to properly indicate the user isn't logged in.
return null;
}
const decryptedAccessToken = await this.encryptService.decryptToUtf8(
encryptedAccessToken,
accessTokenKey,
);
return decryptedAccessToken;
}
/**
* Internal helper for set access token which always requires user id.
* This is useful because setTokens always will have a user id from the access token whereas
* the public setAccessToken method does not.
*/
private async _setAccessToken(
accessToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
userId: UserId,
): Promise<void> {
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
true,
);
switch (storageLocation) {
case TokenStorageLocation.SecureStorage: {
// Secure storage implementations have variable length limitations (Windows), so we cannot
// store the access token directly. Instead, we encrypt with accessTokenKey and store that
// in secure storage.
const encryptedAccessToken: EncString = await this.encryptAccessToken(accessToken, userId);
// Save the encrypted access token to disk
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_DISK)
.update((_) => encryptedAccessToken.encryptedString);
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
// 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time.
// Remove this call to remove the access token from memory after 3 releases.
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
return;
}
case TokenStorageLocation.Disk:
// Access token stored on disk unencrypted as platform does not support secure storage
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_DISK)
.update((_) => accessToken);
return;
case TokenStorageLocation.Memory:
// Access token stored in memory due to vault timeout settings
await this.singleUserStateProvider
.get(userId, ACCESS_TOKEN_MEMORY)
.update((_) => accessToken);
return;
}
}
async setAccessToken(
accessToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
): Promise<void> {
if (!accessToken) {
throw new Error("Access token is required.");
}
const userId: UserId = await this.getUserIdFromAccessToken(accessToken);
// If we don't have a user id, we can't save the value
if (!userId) {
throw new Error("User id not found. Cannot save access token.");
}
// Can't check for falsey b/c 0 is a valid value
if (vaultTimeout == null) {
throw new Error("Vault Timeout is required.");
}
if (vaultTimeoutAction == null) {
throw new Error("Vault Timeout Action is required.");
}
await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
}
async clearAccessToken(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear access token.");
}
// TODO: re-eval this implementation once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters.
if (this.platformSupportsSecureStorage) {
// Always clear the access token key when clearing the access token
// The next set of the access token will create a new access token key
await this.clearAccessTokenKey(userId);
}
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
}
async getAccessToken(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
// Try to get the access token from memory
const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
ACCESS_TOKEN_MEMORY,
);
if (accessTokenMemory != null) {
return accessTokenMemory;
}
// If memory is null, read from disk
const accessTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
if (!accessTokenDisk) {
return null;
}
if (this.platformSupportsSecureStorage) {
const accessTokenKey = await this.getAccessTokenKey(userId);
if (!accessTokenKey) {
// We know this is an unencrypted access token because we don't have an access token key
return accessTokenDisk;
}
try {
const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString);
const decryptedAccessToken = await this.decryptAccessToken(
encryptedAccessTokenEncString,
userId,
);
return decryptedAccessToken;
} catch (error) {
// If an error occurs during decryption, return null for logout.
// We don't try to recover here since we'd like to know
// if access token and key are getting out of sync.
this.logService.error(
`Failed to decrypt access token: ${error?.message ?? "Unknown error."}`,
);
return null;
}
}
return accessTokenDisk;
}
// Private because we only ever set the refresh token when also setting the access token
// and we need the user id from the access token to save to secure storage
private async setRefreshToken(
refreshToken: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
userId: UserId,
): Promise<void> {
// If we don't have a user id, we can't save the value
if (!userId) {
throw new Error("User id not found. Cannot save refresh token.");
}
// Can't check for falsey b/c 0 is a valid value
if (vaultTimeout == null) {
throw new Error("Vault Timeout is required.");
}
if (vaultTimeoutAction == null) {
throw new Error("Vault Timeout Action is required.");
}
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
true,
);
switch (storageLocation) {
case TokenStorageLocation.SecureStorage:
await this.saveStringToSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
refreshToken,
);
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
// 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time.
// Remove these 2 calls to remove the refresh token from memory and disk after 3 releases.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
return;
case TokenStorageLocation.Disk:
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_DISK)
.update((_) => refreshToken);
return;
case TokenStorageLocation.Memory:
await this.singleUserStateProvider
.get(userId, REFRESH_TOKEN_MEMORY)
.update((_) => refreshToken);
return;
}
}
async getRefreshToken(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
// pre-secure storage migration:
// Always read memory first b/c faster
const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
REFRESH_TOKEN_MEMORY,
);
if (refreshTokenMemory != null) {
return refreshTokenMemory;
}
// if memory is null, read from disk and then secure storage
const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK);
if (refreshTokenDisk != null) {
return refreshTokenDisk;
}
if (this.platformSupportsSecureStorage) {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
}
}
return null;
}
private async clearRefreshToken(userId: UserId): Promise<void> {
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear refresh token.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters
if (this.platformSupportsSecureStorage) {
await this.secureStorageService.remove(
`${userId}${this.refreshTokenSecureStorageKey}`,
this.getSecureStorageOptions(userId),
);
}
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
}
async setClientId(
clientId: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
userId?: UserId,
): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't save the value
if (!userId) {
throw new Error("User id not found. Cannot save client id.");
}
// Can't check for falsey b/c 0 is a valid value
if (vaultTimeout == null) {
throw new Error("Vault Timeout is required.");
}
if (vaultTimeoutAction == null) {
throw new Error("Vault Timeout Action is required.");
}
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
false, // don't use secure storage for client id
);
if (storageLocation === TokenStorageLocation.Disk) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_ID_DISK)
.update((_) => clientId);
} else if (storageLocation === TokenStorageLocation.Memory) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_ID_MEMORY)
.update((_) => clientId);
}
}
async getClientId(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
// Always read memory first b/c faster
const apiKeyClientIdMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
API_KEY_CLIENT_ID_MEMORY,
);
if (apiKeyClientIdMemory != null) {
return apiKeyClientIdMemory;
}
// if memory is null, read from disk
return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_ID_DISK);
}
private async clearClientId(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear client id.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear both locations to avoid the need to require those parameters
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_MEMORY).update((_) => null);
await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_DISK).update((_) => null);
}
async setClientSecret(
clientSecret: string,
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
userId?: UserId,
): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
throw new Error("User id not found. Cannot save client secret.");
}
// Can't check for falsey b/c 0 is a valid value
if (vaultTimeout == null) {
throw new Error("Vault Timeout is required.");
}
if (vaultTimeoutAction == null) {
throw new Error("Vault Timeout Action is required.");
}
const storageLocation = await this.determineStorageLocation(
vaultTimeoutAction,
vaultTimeout,
false, // don't use secure storage for client secret
);
if (storageLocation === TokenStorageLocation.Disk) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_SECRET_DISK)
.update((_) => clientSecret);
} else if (storageLocation === TokenStorageLocation.Memory) {
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_SECRET_MEMORY)
.update((_) => clientSecret);
}
}
async getClientSecret(userId?: UserId): Promise<string | undefined> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
return undefined;
}
// Always read memory first b/c faster
const apiKeyClientSecretMemory = await this.getStateValueByUserIdAndKeyDef(
userId,
API_KEY_CLIENT_SECRET_MEMORY,
);
if (apiKeyClientSecretMemory != null) {
return apiKeyClientSecretMemory;
}
// if memory is null, read from disk
return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_SECRET_DISK);
}
private async clearClientSecret(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
// If we don't have a user id, we can't clear the value
if (!userId) {
throw new Error("User id not found. Cannot clear client secret.");
}
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear both locations to avoid the need to require those parameters
// Platform doesn't support secure storage, so use state provider implementation
await this.singleUserStateProvider
.get(userId, API_KEY_CLIENT_SECRET_MEMORY)
.update((_) => null);
await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_SECRET_DISK).update((_) => null);
}
async setTwoFactorToken(email: string, twoFactorToken: string): Promise<void> {
await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => {
emailTwoFactorTokenRecord ??= {};
emailTwoFactorTokenRecord[email] = twoFactorToken;
return emailTwoFactorTokenRecord;
});
}
async getTwoFactorToken(email: string): Promise<string | null> {
const emailTwoFactorTokenRecord: Record<string, string> = await firstValueFrom(
this.emailTwoFactorTokenRecordGlobalState.state$,
);
if (!emailTwoFactorTokenRecord) {
return null;
}
return emailTwoFactorTokenRecord[email];
}
async clearTwoFactorToken(email: string): Promise<void> {
await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => {
emailTwoFactorTokenRecord ??= {};
delete emailTwoFactorTokenRecord[email];
return emailTwoFactorTokenRecord;
});
}
// TODO: stop accepting optional userIds
async clearTokens(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
throw new Error("User id not found. Cannot clear tokens.");
}
await Promise.all([
this.clearAccessToken(userId),
this.clearRefreshToken(userId),
this.clearClientId(userId),
this.clearClientSecret(userId),
]);
}
// jwthelper methods
// ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js
async decodeAccessToken(token?: string): Promise<DecodedAccessToken> {
token = token ?? (await this.getAccessToken());
if (token == null) {
throw new Error("Access token not found.");
}
return decodeJwtTokenToJson(token) as DecodedAccessToken;
}
// TODO: PM-6678- tech debt - consider consolidating the return types of all these access
// token data retrieval methods to return null if something goes wrong instead of throwing an error.
async getTokenExpirationDate(): Promise<Date | null> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
// per RFC, exp claim is optional but if it exists, it should be a number
if (!decoded || typeof decoded.exp !== "number") {
return null;
}
// The 0 in Date(0) is the key; it sets the date to the epoch
const expirationDate = new Date(0);
expirationDate.setUTCSeconds(decoded.exp);
return expirationDate;
}
async tokenSecondsRemaining(offsetSeconds = 0): Promise<number> {
const date = await this.getTokenExpirationDate();
if (date == null) {
return 0;
}
const msRemaining = date.valueOf() - (new Date().valueOf() + offsetSeconds * 1000);
return Math.round(msRemaining / 1000);
}
async tokenNeedsRefresh(minutes = 5): Promise<boolean> {
const sRemaining = await this.tokenSecondsRemaining();
return sRemaining < 60 * minutes;
}
async getUserId(): Promise<UserId> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.sub !== "string") {
throw new Error("No user id found");
}
return decoded.sub as UserId;
}
private async getUserIdFromAccessToken(accessToken: string): Promise<UserId> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken(accessToken);
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.sub !== "string") {
throw new Error("No user id found");
}
return decoded.sub as UserId;
}
async getEmail(): Promise<string> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.email !== "string") {
throw new Error("No email found");
}
return decoded.email;
}
async getEmailVerified(): Promise<boolean> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.email_verified !== "boolean") {
throw new Error("No email verification found");
}
return decoded.email_verified;
}
async getName(): Promise<string> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.name !== "string") {
return null;
}
return decoded.name;
}
async getIssuer(): Promise<string> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
if (!decoded || typeof decoded.iss !== "string") {
throw new Error("No issuer found");
}
return decoded.iss;
}
async getIsExternal(): Promise<boolean> {
let decoded: DecodedAccessToken;
try {
decoded = await this.decodeAccessToken();
} catch (error) {
throw new Error("Failed to decode access token: " + error.message);
}
return Array.isArray(decoded.amr) && decoded.amr.includes("external");
}
async getSecurityStamp(userId?: UserId): Promise<string | null> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
throw new Error("User id not found. Cannot get security stamp.");
}
const securityStamp = await this.getStateValueByUserIdAndKeyDef(userId, SECURITY_STAMP_MEMORY);
return securityStamp;
}
async setSecurityStamp(securityStamp: string, userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
throw new Error("User id not found. Cannot set security stamp.");
}
await this.singleUserStateProvider
.get(userId, SECURITY_STAMP_MEMORY)
.update((_) => securityStamp);
}
private async getStateValueByUserIdAndKeyDef(
userId: UserId,
storageLocation: UserKeyDefinition<string>,
): Promise<string | undefined> {
// read from single user state provider
return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$);
}
private async determineStorageLocation(
vaultTimeoutAction: VaultTimeoutAction,
vaultTimeout: VaultTimeout,
useSecureStorage: boolean,
): Promise<TokenStorageLocation> {
if (vaultTimeoutAction == null) {
throw new Error(
"TokenService - determineStorageLocation: We expect the vault timeout action to always exist at this point.",
);
}
if (vaultTimeout == null) {
throw new Error(
"TokenService - determineStorageLocation: We expect the vault timeout to always exist at this point.",
);
}
if (
vaultTimeoutAction === VaultTimeoutAction.LogOut &&
vaultTimeout !== VaultTimeoutStringType.Never
) {
return TokenStorageLocation.Memory;
} else {
if (useSecureStorage && this.platformSupportsSecureStorage) {
return TokenStorageLocation.SecureStorage;
}
return TokenStorageLocation.Disk;
}
}
private async saveStringToSecureStorage(
userId: UserId,
storageKey: string,
value: string,
): Promise<void> {
await this.secureStorageService.save<string>(
`${userId}${storageKey}`,
value,
this.getSecureStorageOptions(userId),
);
}
private async getStringFromSecureStorage(
userId: UserId,
storageKey: string,
): Promise<string | null> {
// If we have a user ID, read from secure storage.
return await this.secureStorageService.get<string>(
`${userId}${storageKey}`,
this.getSecureStorageOptions(userId),
);
}
private getSecureStorageOptions(userId: UserId): StorageOptions {
return {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: userId,
};
}
}