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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user