[PM-5499] auth request service migrations (#8597)

* move auth request storage to service

* create migrations for auth requests

* fix tests

* fix browser

* fix login strategy

* update migration

* use correct test descriptions in migration
This commit is contained in:
Jake Fink 2024-04-15 12:34:30 -04:00 committed by GitHub
parent d0bcc75721
commit 576431d29e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 503 additions and 120 deletions

View File

@ -17,6 +17,10 @@ import {
FactoryOptions,
factory,
} from "../../../platform/background/service-factories/factory-options";
import {
stateProviderFactory,
StateProviderInitOptions,
} from "../../../platform/background/service-factories/state-provider.factory";
import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory";
import {
@ -31,7 +35,8 @@ export type AuthRequestServiceInitOptions = AuthRequestServiceFactoryOptions &
AccountServiceInitOptions &
MasterPasswordServiceInitOptions &
CryptoServiceInitOptions &
ApiServiceInitOptions;
ApiServiceInitOptions &
StateProviderInitOptions;
export function authRequestServiceFactory(
cache: { authRequestService?: AuthRequestServiceAbstraction } & CachedServices,
@ -48,6 +53,7 @@ export function authRequestServiceFactory(
await internalMasterPasswordServiceFactory(cache, opts),
await cryptoServiceFactory(cache, opts),
await apiServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
),
);
}

View File

@ -596,6 +596,7 @@ export default class MainBackground {
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.stateProvider,
);
this.authService = new AuthService(
@ -844,6 +845,7 @@ export default class MainBackground {
logoutCallback,
this.stateService,
this.authService,
this.authRequestService,
this.messagingService,
);

View File

@ -483,6 +483,7 @@ export class Main {
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.stateProvider,
);
this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService(

View File

@ -3,6 +3,7 @@ import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs";
import { concatMap, debounceTime, filter, map, switchMap, takeUntil, tap } from "rxjs/operators";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@ -20,6 +21,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
import { ThemeType, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { SetPinComponent } from "../../auth/components/set-pin.component";
@ -60,6 +62,7 @@ export class SettingsComponent implements OnInit {
showAppPreferences = true;
currentUserEmail: string;
currentUserId: UserId;
availableVaultTimeoutActions$: Observable<VaultTimeoutAction[]>;
vaultTimeoutPolicyCallout: Observable<{
@ -122,6 +125,7 @@ export class SettingsComponent implements OnInit {
private desktopSettingsService: DesktopSettingsService,
private biometricStateService: BiometricStateService,
private desktopAutofillSettingsService: DesktopAutofillSettingsService,
private authRequestService: AuthRequestServiceAbstraction,
) {
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
@ -207,6 +211,7 @@ export class SettingsComponent implements OnInit {
return;
}
this.currentUserEmail = await this.stateService.getEmail();
this.currentUserId = (await this.stateService.getUserId()) as UserId;
this.availableVaultTimeoutActions$ = this.refreshTimeoutSettings$.pipe(
switchMap(() => this.vaultTimeoutSettingsService.availableVaultTimeoutActions$()),
@ -249,7 +254,8 @@ export class SettingsComponent implements OnInit {
requirePasswordOnStart: await firstValueFrom(
this.biometricStateService.requirePasswordOnStart$,
),
approveLoginRequests: (await this.stateService.getApproveLoginRequests()) ?? false,
approveLoginRequests:
(await this.authRequestService.getAcceptAuthRequests(this.currentUserId)) ?? false,
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(),
enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$),
@ -665,7 +671,10 @@ export class SettingsComponent implements OnInit {
}
async updateApproveLoginRequests() {
await this.stateService.setApproveLoginRequests(this.form.value.approveLoginRequests);
await this.authRequestService.setAcceptAuthRequests(
this.form.value.approveLoginRequests,
this.currentUserId,
);
}
ngOnDestroy() {

View File

@ -8,7 +8,7 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { first } from "rxjs/operators";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
@ -16,13 +16,13 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@ -32,6 +32,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { AuthRequestServiceAbstraction } from "../../../../../../libs/auth/src/common/abstractions";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
import { GeneratorComponent } from "../../../app/tools/generator.component";
import { invokeMenu, RendererMenuItem } from "../../../utils";
@ -102,11 +103,12 @@ export class VaultComponent implements OnInit, OnDestroy {
private eventCollectionService: EventCollectionService,
private totpService: TotpService,
private passwordRepromptService: PasswordRepromptService,
private stateService: StateService,
private searchBarService: SearchBarService,
private apiService: ApiService,
private dialogService: DialogService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private authRequestService: AuthRequestServiceAbstraction,
private accountService: AccountService,
) {}
async ngOnInit() {
@ -224,7 +226,8 @@ export class VaultComponent implements OnInit, OnDestroy {
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
const approveLoginRequests = await this.stateService.getApproveLoginRequests();
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const approveLoginRequests = await this.authRequestService.getAcceptAuthRequests(userId);
if (approveLoginRequests) {
const authRequest = await this.apiService.getLastAuthRequest();
if (authRequest != null) {

View File

@ -33,6 +33,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UserId } from "@bitwarden/common/types/guid";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@ -131,6 +132,7 @@ export class LoginViaAuthRequestComponent
// This also prevents it from being lost on refresh as the
// login service email does not persist.
this.email = await this.stateService.getEmail();
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
if (!this.email) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing"));
@ -142,10 +144,10 @@ export class LoginViaAuthRequestComponent
// We only allow a single admin approval request to be active at a time
// so must check state to see if we have an existing one or not
const adminAuthReqStorable = await this.stateService.getAdminAuthRequest();
const adminAuthReqStorable = await this.authRequestService.getAdminAuthRequest(userId);
if (adminAuthReqStorable) {
await this.handleExistingAdminAuthRequest(adminAuthReqStorable);
await this.handleExistingAdminAuthRequest(adminAuthReqStorable, userId);
} else {
// No existing admin auth request; so we need to create one
await this.startAuthRequestLogin();
@ -173,7 +175,10 @@ export class LoginViaAuthRequestComponent
this.destroy$.complete();
}
private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) {
private async handleExistingAdminAuthRequest(
adminAuthReqStorable: AdminAuthRequestStorable,
userId: UserId,
) {
// Note: on login, the SSOLoginStrategy will also call to see an existing admin auth req
// has been approved and handle it if so.
@ -183,13 +188,13 @@ export class LoginViaAuthRequestComponent
adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
return await this.handleExistingAdminAuthReqDeletedOrDenied();
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
}
// Request doesn't exist anymore
if (!adminAuthReqResponse) {
return await this.handleExistingAdminAuthReqDeletedOrDenied();
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
// Re-derive the user's fingerprint phrase
@ -203,7 +208,7 @@ export class LoginViaAuthRequestComponent
// Request denied
if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) {
return await this.handleExistingAdminAuthReqDeletedOrDenied();
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
// Request approved
@ -211,6 +216,7 @@ export class LoginViaAuthRequestComponent
return await this.handleApprovedAdminAuthRequest(
adminAuthReqResponse,
adminAuthReqStorable.privateKey,
userId,
);
}
@ -219,9 +225,9 @@ export class LoginViaAuthRequestComponent
await this.anonymousHubService.createHubConnection(adminAuthReqStorable.id);
}
private async handleExistingAdminAuthReqDeletedOrDenied() {
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
// clear the admin auth request from state
await this.stateService.setAdminAuthRequest(null);
await this.authRequestService.clearAdminAuthRequest(userId);
// start new auth request
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
@ -269,7 +275,8 @@ export class LoginViaAuthRequestComponent
privateKey: this.authRequestKeyPair.privateKey,
});
await this.stateService.setAdminAuthRequest(adminAuthReqStorable);
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
} else {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
reqResponse = await this.apiService.postAuthRequest(this.authRequest);
@ -333,9 +340,11 @@ export class LoginViaAuthRequestComponent
// if user has authenticated via SSO
if (this.userAuthNStatus === AuthenticationStatus.Locked) {
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
return await this.handleApprovedAdminAuthRequest(
authReqResponse,
this.authRequestKeyPair.privateKey,
userId,
);
}
@ -363,6 +372,7 @@ export class LoginViaAuthRequestComponent
async handleApprovedAdminAuthRequest(
adminAuthReqResponse: AuthRequestResponse,
privateKey: ArrayBuffer,
userId: UserId,
) {
// See verifyAndHandleApprovedAuthReq(...) for flow details
// it's flow 2 or 3 based on presence of masterPasswordHash
@ -384,7 +394,7 @@ export class LoginViaAuthRequestComponent
// clear the admin auth request from state so it cannot be used again (it's a one time use)
// TODO: this should eventually be enforced via deleting this on the server once it is used
await this.stateService.setAdminAuthRequest(null);
await this.authRequestService.clearAdminAuthRequest(userId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));

View File

@ -740,6 +740,7 @@ const safeProviders: SafeProvider[] = [
LOGOUT_CALLBACK,
StateServiceAbstraction,
AuthServiceAbstraction,
AuthRequestServiceAbstraction,
MessagingServiceAbstraction,
],
}),
@ -963,6 +964,7 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction,
CryptoServiceAbstraction,
ApiServiceAbstraction,
StateProvider,
],
}),
safeProvider({

View File

@ -1,12 +1,52 @@
import { Observable } from "rxjs";
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
export abstract class AuthRequestServiceAbstraction {
/** Emits an auth request id when an auth request has been approved. */
authRequestPushNotification$: Observable<string>;
/**
* Returns true if the user has chosen to allow auth requests to show on this client.
* Intended to prevent spamming the user with auth requests.
* @param userId The user id.
* @throws If `userId` is not provided.
*/
abstract getAcceptAuthRequests: (userId: UserId) => Promise<boolean>;
/**
* Sets whether to allow auth requests to show on this client for this user.
* @param accept Whether to allow auth requests to show on this client.
* @param userId The user id.
* @throws If `userId` is not provided.
*/
abstract setAcceptAuthRequests: (accept: boolean, userId: UserId) => Promise<void>;
/**
* Returns an admin auth request for the given user if it exists.
* @param userId The user id.
* @throws If `userId` is not provided.
*/
abstract getAdminAuthRequest: (userId: UserId) => Promise<AdminAuthRequestStorable | null>;
/**
* Sets an admin auth request for the given user.
* Note: use {@link clearAdminAuthRequest} to clear the request.
* @param authRequest The admin auth request.
* @param userId The user id.
* @throws If `authRequest` or `userId` is not provided.
*/
abstract setAdminAuthRequest: (
authRequest: AdminAuthRequestStorable,
userId: UserId,
) => Promise<void>;
/**
* Clears an admin auth request for the given user.
* @param userId The user id.
* @throws If `userId` is not provided.
*/
abstract clearAdminAuthRequest: (userId: UserId) => Promise<void>;
/**
* Approve or deny an auth request.
* @param approve True to approve, false to deny.

View File

@ -132,7 +132,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
}
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
protected override async setUserKey(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
const authRequestCredentials = this.cache.value.authRequestCredentials;
// User now may or may not have a master password
// but set the master key encrypted user key if it exists regardless
@ -143,7 +146,6 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
} else {
await this.trySetUserKeyWithMasterKey();
const userId = (await this.stateService.getUserId()) as UserId;
// Establish trust if required after setting user key
await this.deviceTrustCryptoService.trustDeviceIfRequired(userId);
}

View File

@ -32,6 +32,7 @@ import {
AccountProfile,
AccountTokens,
} from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
import {
@ -160,14 +161,11 @@ export abstract class LoginStrategy {
* @param {IdentityTokenResponse} tokenResponse - The response from the server containing the identity token.
* @returns {Promise<void>} - A promise that resolves when the account information has been successfully saved.
*/
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<void> {
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<UserId> {
const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken);
const userId = accountInformation.sub;
// If you don't persist existing admin auth requests on login, they will get deleted.
const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId });
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
const vaultTimeout = await this.stateService.getVaultTimeout();
@ -197,7 +195,6 @@ export abstract class LoginStrategy {
tokens: {
...new AccountTokens(),
},
adminAuthRequest: adminAuthRequest?.toJSON(),
}),
);
@ -206,6 +203,7 @@ export abstract class LoginStrategy {
);
await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false);
return userId as UserId;
}
protected async processTokenResponse(response: IdentityTokenResponse): Promise<AuthResult> {
@ -228,7 +226,7 @@ export abstract class LoginStrategy {
}
// Must come before setting keys, user key needs email to update additional keys
await this.saveAccountInformation(response);
const userId = await this.saveAccountInformation(response);
if (response.twoFactorToken != null) {
// note: we can read email from access token b/c it was saved in saveAccountInformation
@ -238,7 +236,7 @@ export abstract class LoginStrategy {
}
await this.setMasterKey(response);
await this.setUserKey(response);
await this.setUserKey(response, userId);
await this.setPrivateKey(response);
this.messagingService.send("loggedIn");
@ -248,7 +246,7 @@ export abstract class LoginStrategy {
// The keys comes from different sources depending on the login strategy
protected abstract setMasterKey(response: IdentityTokenResponse): Promise<void>;
protected abstract setUserKey(response: IdentityTokenResponse): Promise<void>;
protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse): Promise<void>;
// Old accounts used master key for encryption. We are forcing migrations but only need to

View File

@ -25,6 +25,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { LoginStrategyServiceAbstraction } from "../abstractions";
@ -207,14 +208,16 @@ export class PasswordLoginStrategy extends LoginStrategy {
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
protected override async setUserKey(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
// If migration is required, we won't have a user key to set yet.
if (this.encryptionKeyMigrationRequired(response)) {
return;
}
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);

View File

@ -301,7 +301,7 @@ describe("SsoLoginStrategy", () => {
id: "1",
privateKey: "PRIVATE" as any,
} as AdminAuthRequestStorable;
stateService.getAdminAuthRequest.mockResolvedValue(
authRequestService.getAdminAuthRequest.mockResolvedValue(
new AdminAuthRequestStorable(adminAuthRequest),
);
});
@ -364,7 +364,7 @@ describe("SsoLoginStrategy", () => {
await ssoLoginStrategy.logIn(credentials);
expect(stateService.setAdminAuthRequest).toHaveBeenCalledWith(null);
expect(authRequestService.clearAdminAuthRequest).toHaveBeenCalled();
expect(
authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash,
).not.toHaveBeenCalled();

View File

@ -216,7 +216,10 @@ export class SsoLoginStrategy extends LoginStrategy {
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)
// so might be worth moving this logic to a common place (base login strategy or a separate service?)
protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise<void> {
protected override async setUserKey(
tokenResponse: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
const masterKeyEncryptedUserKey = tokenResponse.key;
// Note: masterKeyEncryptedUserKey is undefined for SSO JIT provisioned users
@ -232,7 +235,7 @@ export class SsoLoginStrategy extends LoginStrategy {
// Note: TDE and key connector are mutually exclusive
if (userDecryptionOptions?.trustedDeviceOption) {
await this.trySetUserKeyWithApprovedAdminRequestIfExists();
await this.trySetUserKeyWithApprovedAdminRequestIfExists(userId);
const hasUserKey = await this.cryptoService.hasUserKey();
@ -252,9 +255,9 @@ export class SsoLoginStrategy extends LoginStrategy {
// is responsible for deriving master key from MP entry and then decrypting the user key
}
private async trySetUserKeyWithApprovedAdminRequestIfExists(): Promise<void> {
private async trySetUserKeyWithApprovedAdminRequestIfExists(userId: UserId): Promise<void> {
// At this point a user could have an admin auth request that has been approved
const adminAuthReqStorable = await this.stateService.getAdminAuthRequest();
const adminAuthReqStorable = await this.authRequestService.getAdminAuthRequest(userId);
if (!adminAuthReqStorable) {
return;
@ -268,7 +271,7 @@ export class SsoLoginStrategy extends LoginStrategy {
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
// if we get a 404, it means the auth request has been deleted so clear it from storage
await this.stateService.setAdminAuthRequest(null);
await this.authRequestService.clearAdminAuthRequest(userId);
}
// Always return on an error here as we don't want to block the user from logging in
@ -295,12 +298,11 @@ export class SsoLoginStrategy extends LoginStrategy {
if (await this.cryptoService.hasUserKey()) {
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
const userId = (await this.stateService.getUserId()) as UserId;
await this.deviceTrustCryptoService.trustDeviceIfRequired(userId);
// if we successfully decrypted the user key, we can delete the admin auth request out of state
// TODO: eventually we post and clean up DB as well once consumed on client
await this.stateService.setAdminAuthRequest(null);
await this.authRequestService.clearAdminAuthRequest(userId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));
}

View File

@ -18,6 +18,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
@ -97,7 +98,10 @@ export class UserApiLoginStrategy extends LoginStrategy {
}
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
protected override async setUserKey(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
if (response.apiUseKeyConnector) {
@ -116,8 +120,8 @@ export class UserApiLoginStrategy extends LoginStrategy {
);
}
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {
await super.saveAccountInformation(tokenResponse);
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<UserId> {
const userId = await super.saveAccountInformation(tokenResponse);
const vaultTimeout = await this.stateService.getVaultTimeout();
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction();
@ -134,6 +138,7 @@ export class UserApiLoginStrategy extends LoginStrategy {
vaultTimeoutAction as VaultTimeoutAction,
vaultTimeout,
);
return userId;
}
exportCache(): CacheData {

View File

@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions";
@ -98,7 +99,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
return Promise.resolve();
}
protected override async setUserKey(idTokenResponse: IdentityTokenResponse) {
protected override async setUserKey(idTokenResponse: IdentityTokenResponse, userId: UserId) {
const masterKeyEncryptedUserKey = idTokenResponse.key;
if (masterKeyEncryptedUserKey) {

View File

@ -9,6 +9,7 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
@ -18,6 +19,7 @@ import { AuthRequestService } from "./auth-request.service";
describe("AuthRequestService", () => {
let sut: AuthRequestService;
const stateProvider = mock<StateProvider>();
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
const appIdService = mock<AppIdService>();
@ -38,6 +40,7 @@ describe("AuthRequestService", () => {
masterPasswordService,
cryptoService,
apiService,
stateProvider,
);
mockPrivateKey = new Uint8Array(64);
@ -59,6 +62,31 @@ describe("AuthRequestService", () => {
});
});
describe("AcceptAuthRequests", () => {
it("returns an error when userId isn't provided", async () => {
await expect(sut.getAcceptAuthRequests(undefined)).rejects.toThrow("User ID is required");
await expect(sut.setAcceptAuthRequests(true, undefined)).rejects.toThrow(
"User ID is required",
);
});
});
describe("AdminAuthRequest", () => {
it("returns an error when userId isn't provided", async () => {
await expect(sut.getAdminAuthRequest(undefined)).rejects.toThrow("User ID is required");
await expect(sut.setAdminAuthRequest(undefined, undefined)).rejects.toThrow(
"User ID is required",
);
await expect(sut.clearAdminAuthRequest(undefined)).rejects.toThrow("User ID is required");
});
it("does not allow clearing from setAdminAuthRequest", async () => {
await expect(sut.setAdminAuthRequest(null, "USER_ID" as UserId)).rejects.toThrow(
"Auth request is required",
);
});
});
describe("approveOrDenyAuthRequest", () => {
beforeEach(() => {
cryptoService.rsaEncrypt.mockResolvedValue({

View File

@ -1,8 +1,10 @@
import { firstValueFrom, Observable, Subject } from "rxjs";
import { Observable, Subject, firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
@ -10,10 +12,43 @@ import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.ser
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
AUTH_REQUEST_DISK_LOCAL,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { AuthRequestServiceAbstraction } from "../../abstractions/auth-request.service.abstraction";
/**
* Disk-local to maintain consistency between tabs (even though
* approvals are currently only available on desktop). We don't
* want to clear this on logout as it's a user preference.
*/
export const ACCEPT_AUTH_REQUESTS_KEY = new UserKeyDefinition<boolean>(
AUTH_REQUEST_DISK_LOCAL,
"acceptAuthRequests",
{
deserializer: (value) => value ?? false,
clearOn: [],
},
);
/**
* Disk-local to maintain consistency between tabs. We don't want to
* clear this on logout since admin auth requests are long-lived.
*/
export const ADMIN_AUTH_REQUEST_KEY = new UserKeyDefinition<Jsonify<AdminAuthRequestStorable>>(
AUTH_REQUEST_DISK_LOCAL,
"adminAuthRequest",
{
deserializer: (value) => value,
clearOn: [],
},
);
export class AuthRequestService implements AuthRequestServiceAbstraction {
private authRequestPushNotificationSubject = new Subject<string>();
authRequestPushNotification$: Observable<string>;
@ -24,10 +59,61 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private cryptoService: CryptoService,
private apiService: ApiService,
private stateProvider: StateProvider,
) {
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
}
async getAcceptAuthRequests(userId: UserId): Promise<boolean> {
if (userId == null) {
throw new Error("User ID is required");
}
const value = await firstValueFrom(
this.stateProvider.getUser(userId, ACCEPT_AUTH_REQUESTS_KEY).state$,
);
return value;
}
async setAcceptAuthRequests(accept: boolean, userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("User ID is required");
}
await this.stateProvider.setUserState(ACCEPT_AUTH_REQUESTS_KEY, accept, userId);
}
async getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null> {
if (userId == null) {
throw new Error("User ID is required");
}
const authRequestSerialized = await firstValueFrom(
this.stateProvider.getUser(userId, ADMIN_AUTH_REQUEST_KEY).state$,
);
const adminAuthRequestStorable = AdminAuthRequestStorable.fromJSON(authRequestSerialized);
return adminAuthRequestStorable;
}
async setAdminAuthRequest(authRequest: AdminAuthRequestStorable, userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("User ID is required");
}
if (authRequest == null) {
throw new Error("Auth request is required");
}
await this.stateProvider.setUserState(ADMIN_AUTH_REQUEST_KEY, authRequest.toJSON(), userId);
}
async clearAdminAuthRequest(userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("User ID is required");
}
await this.stateProvider.setUserState(ADMIN_AUTH_REQUEST_KEY, null, userId);
}
async approveOrDenyAuthRequest(
approve: boolean,
authRequest: AuthRequestResponse,

View File

@ -1,11 +1,7 @@
import { Jsonify } from "type-fest";
import { Utils } from "../../../platform/misc/utils";
// TODO: Tech Debt: potentially create a type Storage shape vs using a class here in the future
// type StorageShape {
// id: string;
// privateKey: string;
// }
// so we can get rid of the any type passed into fromJSON and coming out of ToJSON
export class AdminAuthRequestStorable {
id: string;
privateKey: Uint8Array;
@ -23,7 +19,7 @@ export class AdminAuthRequestStorable {
};
}
static fromJSON(obj: any): AdminAuthRequestStorable {
static fromJSON(obj: Jsonify<AdminAuthRequestStorable>): AdminAuthRequestStorable {
if (obj == null) {
return null;
}

View File

@ -1,6 +1,5 @@
import { Observable } from "rxjs";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key";
import { GeneratorOptions } from "../../tools/generator/generator-options";
@ -124,11 +123,6 @@ export abstract class StateService<T extends Account = Account> {
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getAdminAuthRequest: (options?: StorageOptions) => Promise<AdminAuthRequestStorable | null>;
setAdminAuthRequest: (
adminAuthRequest: AdminAuthRequestStorable,
options?: StorageOptions,
) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
@ -207,7 +201,5 @@ export abstract class StateService<T extends Account = Account> {
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
nextUpActiveUser: () => Promise<UserId>;
}

View File

@ -1,6 +1,5 @@
import { Jsonify } from "type-fest";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
import { GeneratorOptions } from "../../../tools/generator/generator-options";
import {
@ -169,7 +168,6 @@ export class AccountSettings {
protectedPin?: string;
vaultTimeout?: number;
vaultTimeoutAction?: string = "lock";
approveLoginRequests?: boolean;
/** @deprecated July 2023, left for migration purposes*/
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();
@ -206,7 +204,6 @@ export class Account {
profile?: AccountProfile = new AccountProfile();
settings?: AccountSettings = new AccountSettings();
tokens?: AccountTokens = new AccountTokens();
adminAuthRequest?: Jsonify<AdminAuthRequestStorable> = null;
constructor(init: Partial<Account>) {
Object.assign(this, {
@ -230,7 +227,6 @@ export class Account {
...new AccountTokens(),
...init?.tokens,
},
adminAuthRequest: init?.adminAuthRequest,
});
}
@ -245,7 +241,6 @@ export class Account {
profile: AccountProfile.fromJSON(json?.profile),
settings: AccountSettings.fromJSON(json?.settings),
tokens: AccountTokens.fromJSON(json?.tokens),
adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest),
});
}
}

View File

@ -3,7 +3,6 @@ import { Jsonify, JsonValue } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key";
import { GeneratorOptions } from "../../tools/generator/generator-options";
@ -548,37 +547,6 @@ export class StateService<
: await this.secureStorageService.save(DDG_SHARED_KEY, value, options);
}
async getAdminAuthRequest(options?: StorageOptions): Promise<AdminAuthRequestStorable | null> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return null;
}
const account = await this.getAccount(options);
return account?.adminAuthRequest
? AdminAuthRequestStorable.fromJSON(account.adminAuthRequest)
: null;
}
async setAdminAuthRequest(
adminAuthRequest: AdminAuthRequestStorable,
options?: StorageOptions,
): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return;
}
const account = await this.getAccount(options);
account.adminAuthRequest = adminAuthRequest?.toJSON();
await this.saveAccount(account, options);
}
async getEmail(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
@ -1032,24 +1000,6 @@ export class StateService<
);
}
async getApproveLoginRequests(options?: StorageOptions): Promise<boolean> {
const approveLoginRequests = (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.approveLoginRequests;
return approveLoginRequests;
}
async setApproveLoginRequests(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.settings.approveLoginRequests = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
protected async getGlobals(options: StorageOptions): Promise<TGlobalState> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {
@ -1392,7 +1342,6 @@ export class StateService<
protected resetAccount(account: TAccount) {
const persistentAccountInformation = {
settings: account.settings,
adminAuthRequest: account.adminAuthRequest,
};
return Object.assign(this.createAccount(), persistentAccountInformation);
}

View File

@ -45,6 +45,9 @@ export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
web: "disk-local",
});
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
export const AUTH_REQUEST_DISK_LOCAL = new StateDefinition("authRequestLocal", "disk", {
web: "disk-local",
});
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const TOKEN_DISK = new StateDefinition("token", "disk");
export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {

View File

@ -2,6 +2,7 @@ import * as signalR from "@microsoft/signalr";
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
import { AuthRequestServiceAbstraction } from "../../../auth/src/common/abstractions";
import { ApiService } from "../abstractions/api.service";
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
import { AuthService } from "../auth/abstractions/auth.service";
@ -18,6 +19,7 @@ import { EnvironmentService } from "../platform/abstractions/environment.service
import { LogService } from "../platform/abstractions/log.service";
import { MessagingService } from "../platform/abstractions/messaging.service";
import { StateService } from "../platform/abstractions/state.service";
import { UserId } from "../types/guid";
import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction";
export class NotificationsService implements NotificationsServiceAbstraction {
@ -37,6 +39,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
private logoutCallback: (expired: boolean) => Promise<void>,
private stateService: StateService,
private authService: AuthService,
private authRequestService: AuthRequestServiceAbstraction,
private messagingService: MessagingService,
) {
this.environmentService.environment$.subscribe(() => {
@ -199,10 +202,13 @@ export class NotificationsService implements NotificationsServiceAbstraction {
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
break;
case NotificationType.AuthRequest:
if (await this.stateService.getApproveLoginRequests()) {
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
{
const userId = await this.stateService.getUserId();
if (await this.authRequestService.getAcceptAuthRequests(userId as UserId)) {
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
}
}
break;
default:

View File

@ -52,6 +52,7 @@ import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version
import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers";
import { SendMigrator } from "./migrations/54-move-encrypted-sends";
import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider";
import { AuthRequestMigrator } from "./migrations/56-move-auth-requests";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
@ -59,7 +60,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 55;
export const CURRENT_VERSION = 56;
export type MinVersion = typeof MIN_VERSION;
@ -117,7 +118,8 @@ export function createMigrationBuilder() {
.with(DeleteInstalledVersion, 51, 52)
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53)
.with(SendMigrator, 53, 54)
.with(MoveMasterKeyStateToProviderMigrator, 54, CURRENT_VERSION);
.with(MoveMasterKeyStateToProviderMigrator, 54, 55)
.with(AuthRequestMigrator, 55, CURRENT_VERSION);
}
export async function currentVersion(

View File

@ -0,0 +1,138 @@
import { MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { AuthRequestMigrator } from "./56-move-auth-requests";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
FirstAccount: {
settings: {
otherStuff: "otherStuff2",
approveLoginRequests: true,
},
otherStuff: "otherStuff3",
adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
},
SecondAccount: {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
user_FirstAccount_authRequestLocal_adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
user_FirstAccount_authRequestLocal_acceptAuthRequests: true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
FirstAccount: {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "adminAuthRequest",
};
const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "acceptAuthRequests",
};
describe("AuthRequestMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AuthRequestMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 55);
sut = new AuthRequestMigrator(55, 56);
});
it("removes the existing adminAuthRequest and approveLoginRequests", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).not.toHaveBeenCalledWith("SecondAccount");
});
it("sets the adminAuthRequest and approveLoginRequests under the new key definitions", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, {
id: "id1",
privateKey: "privateKey1",
});
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, true);
expect(helper.setToUser).not.toHaveBeenCalledWith("SecondAccount");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 56);
sut = new AuthRequestMigrator(55, 56);
});
it("nulls the new adminAuthRequest and acceptAuthRequests values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, null);
});
it("sets back the adminAuthRequest and approveLoginRequests under old account object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
settings: {
otherStuff: "otherStuff2",
approveLoginRequests: true,
},
otherStuff: "otherStuff3",
});
});
});
});

View File

@ -0,0 +1,104 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type AdminAuthRequestStorable = {
id: string;
privateKey: string;
};
type ExpectedAccountType = {
adminAuthRequest?: AdminAuthRequestStorable;
settings?: {
approveLoginRequests?: boolean;
};
};
const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "adminAuthRequest",
};
const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "acceptAuthRequests",
};
export class AuthRequestMigrator extends Migrator<55, 56> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Migrate admin auth request
const existingAdminAuthRequest = account?.adminAuthRequest;
if (existingAdminAuthRequest != null) {
await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, existingAdminAuthRequest);
delete account.adminAuthRequest;
updatedAccount = true;
}
// Migrate approve login requests
const existingApproveLoginRequests = account?.settings?.approveLoginRequests;
if (existingApproveLoginRequests != null) {
await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, existingApproveLoginRequests);
delete account.settings.approveLoginRequests;
updatedAccount = true;
}
if (updatedAccount) {
// Save the migrated account
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Rollback admin auth request
const migratedAdminAuthRequest: AdminAuthRequestStorable = await helper.getFromUser(
userId,
ADMIN_AUTH_REQUEST_KEY,
);
if (migratedAdminAuthRequest != null) {
account.adminAuthRequest = migratedAdminAuthRequest;
updatedAccount = true;
}
await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, null);
// Rollback approve login requests
const migratedAcceptAuthRequest: boolean = await helper.getFromUser(
userId,
ACCEPT_AUTH_REQUESTS_KEY,
);
if (migratedAcceptAuthRequest != null) {
account.settings = Object.assign(account.settings ?? {}, {
approveLoginRequests: migratedAcceptAuthRequest,
});
updatedAccount = true;
}
await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, null);
if (updatedAccount) {
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}