1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-15 16:05:03 +00:00

Add Observable Ways to get access token

This commit is contained in:
Justin Baur
2024-11-12 10:58:52 -05:00
parent dadd337a6b
commit 63241f4a71
3 changed files with 239 additions and 126 deletions

View File

@@ -68,6 +68,12 @@ export abstract class TokenService {
*/
clearAccessToken: (userId?: UserId) => Promise<void>;
/**
* Gets a stream of the given users access token data.
* @param userId The user id of the user to get the access tokens for.
*/
abstract accessToken$(userId: UserId): Observable<string | null>;
/**
* Gets the access token
* @param userId - The optional user id to get the access token for; if not provided, the active user is used.
@@ -75,6 +81,12 @@ export abstract class TokenService {
*/
getAccessToken: (userId?: UserId) => Promise<string | null>;
/**
* Gets a stream of the given users refresh token data.
* @param userId The user id of the user to get the refresh tokens for.
*/
abstract refreshToken$(userId: UserId): Observable<string | null>;
/**
* Gets the refresh token.
* @param userId - The optional user id to get the refresh token for; if not provided, the active user is used.

View File

@@ -1,5 +1,5 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { bufferCount, firstValueFrom } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
@@ -627,6 +627,33 @@ describe("TokenService", () => {
});
});
describe("accessToken$", () => {
it("returns an observable that follows possible changes", async () => {
const finishedPromise = firstValueFrom(
tokenService.accessToken$(userIdFromAccessToken).pipe(bufferCount(4)),
);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, "accessToken_memory"]);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, null]);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, "accessToken_disk"]);
const values = await finishedPromise;
expect(values[0]).toBeNull();
expect(values[1]).toEqual("accessToken_memory");
expect(values[2]).toBeNull();
expect(values[3]).toEqual("accessToken_disk");
});
});
describe("clearAccessToken", () => {
it("throws an error when no user id is provided and there is no active user in global state", async () => {
// Act
@@ -1510,10 +1537,46 @@ describe("TokenService", () => {
});
});
describe("refreshToken$", () => {
it("returns observable that follows possible changes", async () => {
const finishedPromise = firstValueFrom(
tokenService.refreshToken$(userIdFromAccessToken).pipe(bufferCount(5)),
);
// Set a token in memory
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, refreshToken + "_memory"]);
// Null out the memory location so that it will look at other locations
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, null]);
// The refresh token is saved in disk
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken + "_disk"]);
// A new refresh token is saved to disk
singleUserStateProvider
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, refreshToken + "_disk2"]);
const values = await finishedPromise;
expect(values[0]).toBeNull();
expect(values[1]).toBe(refreshToken + "_memory");
expect(values[2]).toBeNull();
expect(values[3]).toBe(refreshToken + "_disk");
expect(values[4]).toBe(refreshToken + "_disk2");
});
});
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();
const result = await tokenService.getRefreshToken();
// Assert
expect(result).toBeNull();
});
@@ -1525,7 +1588,7 @@ describe("TokenService", () => {
.stateSubject.next(userIdFromAccessToken);
// Act
const result = await (tokenService as any).getRefreshToken();
const result = await tokenService.getRefreshToken();
// Assert
expect(result).toBeNull();
});

View File

@@ -1,4 +1,4 @@
import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
import { Observable, combineLatest, defer, firstValueFrom, map, of, switchMap } from "rxjs";
import { Opaque } from "type-fest";
import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common";
@@ -425,87 +425,102 @@ export class TokenService implements TokenServiceAbstraction {
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
}
accessToken$(userId: UserId): Observable<string | null> {
return this.getStateByUserIdAndKeyDef$(userId, ACCESS_TOKEN_MEMORY).pipe(
switchMap((accessTokenMemory) => {
if (accessTokenMemory != null) {
return of(accessTokenMemory);
}
// If memory is null, read from disk
return this.getStateByUserIdAndKeyDef$(userId, ACCESS_TOKEN_DISK).pipe(
switchMap((accessTokenDisk) => {
if (!accessTokenDisk) {
return of(null);
}
if (!this.platformSupportsSecureStorage) {
// If the platform doesn't support secure storage then we will not have encrypted it
// and can just return this value.
return of(accessTokenDisk);
}
// Secure storage isn't observable in order to make it "feel" observable
// we rely on disk storage emitting the encrypted value that we decrypt
return defer(async () => {
let accessTokenKey: AccessTokenKey;
try {
accessTokenKey = await this.getAccessTokenKey(userId);
} catch (error) {
if (EncString.isSerializedEncString(accessTokenDisk)) {
this.logService.error(
"Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.",
error,
);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
// If the access token key is not found, but the access token is unencrypted then
// this indicates that this is the pre-migration state where the access token
// was stored unencrypted on disk. We can return the access token as is.
// Note: this is likely to only be hit for linux users who don't
// have a secure storage provider configured.
return accessTokenDisk;
}
if (!accessTokenKey) {
if (EncString.isSerializedEncString(accessTokenDisk)) {
// The access token is encrypted but we don't have the key to decrypt it for
// whatever reason so we have to log the user out.
this.logService.error(
"Access token key not found to decrypt encrypted access token. Logging user out.",
);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
// We know this is an unencrypted access token
return accessTokenDisk;
}
try {
const encryptedAccessTokenEncString = new EncString(
accessTokenDisk as EncryptedString,
);
const decryptedAccessToken = await this.decryptAccessToken(
accessTokenKey,
encryptedAccessTokenEncString,
);
return decryptedAccessToken;
} catch (error) {
// If an error occurs during decryption, logout and then return null.
// 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);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
});
}),
);
}),
);
}
async getAccessToken(userId?: UserId): Promise<string | null> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
if (userId == null) {
return null;
}
// 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) {
let accessTokenKey: AccessTokenKey;
try {
accessTokenKey = await this.getAccessTokenKey(userId);
} catch (error) {
if (EncString.isSerializedEncString(accessTokenDisk)) {
this.logService.error(
"Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.",
error,
);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
// If the access token key is not found, but the access token is unencrypted then
// this indicates that this is the pre-migration state where the access token
// was stored unencrypted on disk. We can return the access token as is.
// Note: this is likely to only be hit for linux users who don't
// have a secure storage provider configured.
return accessTokenDisk;
}
if (!accessTokenKey) {
if (EncString.isSerializedEncString(accessTokenDisk)) {
// The access token is encrypted but we don't have the key to decrypt it for
// whatever reason so we have to log the user out.
this.logService.error(
"Access token key not found to decrypt encrypted access token. Logging user out.",
);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
// We know this is an unencrypted access token
return accessTokenDisk;
}
try {
const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString);
const decryptedAccessToken = await this.decryptAccessToken(
accessTokenKey,
encryptedAccessTokenEncString,
);
return decryptedAccessToken;
} catch (error) {
// If an error occurs during decryption, logout and then return null.
// 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);
await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
return null;
}
}
return accessTokenDisk;
return await firstValueFrom(this.accessToken$(userId));
}
// Private because we only ever set the refresh token when also setting the access token
@@ -565,10 +580,14 @@ export class TokenService implements TokenServiceAbstraction {
// so that the caller can use it immediately.
decryptedRefreshToken = refreshToken;
// We continue to set disk storage to null so that the `refreshToken$` observable will get
// an emission then go to secure storage to get a new value. This is because secure storage
// isn't observable. It's very important that we do this AFTER the value has been saved to
// secure storage.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
// TODO: 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 months.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
// Remove this call to remove the refresh token from memory after 3 months.
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
} catch (error) {
// This case could be hit for both Linux users who don't have secure storage configured
@@ -598,56 +617,71 @@ export class TokenService implements TokenServiceAbstraction {
}
}
refreshToken$(userId: UserId): Observable<string | null> {
// pre-secure storage migration:
// Always read memory first b/c faster
return this.getStateByUserIdAndKeyDef$(userId, REFRESH_TOKEN_MEMORY).pipe(
switchMap((refreshTokenMemory) => {
if (refreshTokenMemory != null) {
return of(refreshTokenMemory);
}
// if memory is null, read from disk and then secure storage
return this.getStateByUserIdAndKeyDef$(userId, REFRESH_TOKEN_DISK).pipe(
switchMap((refreshTokenDisk) => {
if (refreshTokenDisk != null) {
// This handles the scenario pre-secure storage migration where the refresh token was stored on disk.
return of(refreshTokenDisk);
}
if (!this.platformSupportsSecureStorage) {
return of(null);
}
// Secure storage isn't observable, so it secure storage alone changes,
// this observable won't emit an update. Because of this we need to always emit
// a change to the disk storage (even if setting it to just null) so the outer
// observable will re-emit and run this code once more.
return defer(async () => {
try {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
}
this.logService.error(
"Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.",
);
} catch (error) {
// This case will be hit for Linux users who don't have secure storage configured.
this.logService.error(
`Failed to retrieve refresh token from secure storage`,
error,
);
await this.logoutCallback("refreshTokenSecureStorageRetrievalFailure", userId);
}
return null;
});
}),
);
}),
);
}
async getRefreshToken(userId?: UserId): Promise<string | null> {
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
if (!userId) {
if (userId == null) {
return null;
}
// 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) {
// This handles the scenario pre-secure storage migration where the refresh token was stored on disk.
return refreshTokenDisk;
}
if (this.platformSupportsSecureStorage) {
try {
const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
userId,
this.refreshTokenSecureStorageKey,
);
if (refreshTokenSecureStorage != null) {
return refreshTokenSecureStorage;
}
this.logService.error(
"Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.",
);
} catch (error) {
// This case will be hit for Linux users who don't have secure storage configured.
this.logService.error(`Failed to retrieve refresh token from secure storage`, error);
await this.logoutCallback("refreshTokenSecureStorageRetrievalFailure", userId);
}
}
return null;
return await firstValueFrom(this.refreshToken$(userId));
}
private async clearRefreshToken(userId: UserId): Promise<void> {
@@ -1052,7 +1086,11 @@ export class TokenService implements TokenServiceAbstraction {
storageLocation: UserKeyDefinition<string>,
): Promise<string | undefined> {
// read from single user state provider
return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$);
return await firstValueFrom(this.getStateByUserIdAndKeyDef$(userId, storageLocation));
}
private getStateByUserIdAndKeyDef$(userId: UserId, storageLocation: UserKeyDefinition<string>) {
return this.singleUserStateProvider.get(userId, storageLocation).state$;
}
private async determineStorageLocation(