1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

[PM-7564] Move 2fa and login strategy service to popup and add state providers to 2fa service (#8820)

* remove 2fa from main.background

* remove login strategy service from main.background

* move 2fa and login strategy service to popup, init in browser

* add state providers to 2fa service
- add deserializer helpers

* use key definitions for global state

* fix calls to 2fa service

* remove extra await

* add delay to wait for active account emission in popup

* add and fix tests

* fix cli

* really fix cli

* remove timeout and wait for active account

* verify expected user is active account

* fix tests

* address feedback
This commit is contained in:
Jake Fink 2024-04-25 16:45:23 -04:00 committed by GitHub
parent cbf7c292f3
commit 8afe915be1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 217 additions and 152 deletions

View File

@ -1,11 +1,13 @@
import { TwoFactorService as AbstractTwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorService as AbstractTwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { import {
FactoryOptions, FactoryOptions,
CachedServices, CachedServices,
factory, factory,
} from "../../../platform/background/service-factories/factory-options"; } from "../../../platform/background/service-factories/factory-options";
import { globalStateProviderFactory } from "../../../platform/background/service-factories/global-state-provider.factory";
import { import {
I18nServiceInitOptions, I18nServiceInitOptions,
i18nServiceFactory, i18nServiceFactory,
@ -19,7 +21,8 @@ type TwoFactorServiceFactoryOptions = FactoryOptions;
export type TwoFactorServiceInitOptions = TwoFactorServiceFactoryOptions & export type TwoFactorServiceInitOptions = TwoFactorServiceFactoryOptions &
I18nServiceInitOptions & I18nServiceInitOptions &
PlatformUtilsServiceInitOptions; PlatformUtilsServiceInitOptions &
GlobalStateProvider;
export async function twoFactorServiceFactory( export async function twoFactorServiceFactory(
cache: { twoFactorService?: AbstractTwoFactorService } & CachedServices, cache: { twoFactorService?: AbstractTwoFactorService } & CachedServices,
@ -33,6 +36,7 @@ export async function twoFactorServiceFactory(
new TwoFactorService( new TwoFactorService(
await i18nServiceFactory(cache, opts), await i18nServiceFactory(cache, opts),
await platformUtilsServiceFactory(cache, opts), await platformUtilsServiceFactory(cache, opts),
await globalStateProviderFactory(cache, opts),
), ),
); );
service.init(); service.init();

View File

@ -2,7 +2,10 @@ import { Component } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { TwoFactorOptionsComponent as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options.component"; import { TwoFactorOptionsComponent as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options.component";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import {
TwoFactorProviderDetails,
TwoFactorService,
} from "@bitwarden/common/auth/abstractions/two-factor.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -27,9 +30,9 @@ export class TwoFactorOptionsComponent extends BaseTwoFactorOptionsComponent {
this.navigateTo2FA(); this.navigateTo2FA();
} }
choose(p: any) { override async choose(p: TwoFactorProviderDetails) {
super.choose(p); await super.choose(p);
this.twoFactorService.setSelectedProvider(p.type); await this.twoFactorService.setSelectedProvider(p.type);
this.navigateTo2FA(); this.navigateTo2FA();
} }

View File

@ -3,8 +3,6 @@ import { Subject, firstValueFrom, merge } from "rxjs";
import { import {
PinCryptoServiceAbstraction, PinCryptoServiceAbstraction,
PinCryptoService, PinCryptoService,
LoginStrategyServiceAbstraction,
LoginStrategyService,
InternalUserDecryptionOptionsServiceAbstraction, InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptionsService, UserDecryptionOptionsService,
AuthRequestServiceAbstraction, AuthRequestServiceAbstraction,
@ -38,7 +36,6 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -54,7 +51,6 @@ import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connect
import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { import {
@ -277,7 +273,6 @@ export default class MainBackground {
containerService: ContainerService; containerService: ContainerService;
auditService: AuditServiceAbstraction; auditService: AuditServiceAbstraction;
authService: AuthServiceAbstraction; authService: AuthServiceAbstraction;
loginStrategyService: LoginStrategyServiceAbstraction;
loginEmailService: LoginEmailServiceAbstraction; loginEmailService: LoginEmailServiceAbstraction;
importApiService: ImportApiServiceAbstraction; importApiService: ImportApiServiceAbstraction;
importService: ImportServiceAbstraction; importService: ImportServiceAbstraction;
@ -301,7 +296,6 @@ export default class MainBackground {
providerService: ProviderServiceAbstraction; providerService: ProviderServiceAbstraction;
keyConnectorService: KeyConnectorServiceAbstraction; keyConnectorService: KeyConnectorServiceAbstraction;
userVerificationService: UserVerificationServiceAbstraction; userVerificationService: UserVerificationServiceAbstraction;
twoFactorService: TwoFactorServiceAbstraction;
vaultFilterService: VaultFilterService; vaultFilterService: VaultFilterService;
usernameGenerationService: UsernameGenerationServiceAbstraction; usernameGenerationService: UsernameGenerationServiceAbstraction;
encryptService: EncryptService; encryptService: EncryptService;
@ -614,8 +608,6 @@ export default class MainBackground {
this.stateService, this.stateService,
); );
this.twoFactorService = new TwoFactorService(this.i18nService, this.platformUtilsService);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); this.devicesApiService = new DevicesApiServiceImplementation(this.apiService);
@ -659,32 +651,6 @@ export default class MainBackground {
this.loginEmailService = new LoginEmailService(this.stateProvider); this.loginEmailService = new LoginEmailService(this.stateProvider);
this.loginStrategyService = new LoginStrategyService(
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.keyConnectorService,
this.environmentService,
this.stateService,
this.twoFactorService,
this.i18nService,
this.encryptService,
this.passwordStrengthService,
this.policyService,
this.deviceTrustService,
this.authRequestService,
this.userDecryptionOptionsService,
this.globalStateProvider,
this.billingAccountProfileStateService,
this.kdfConfigService,
);
this.ssoLoginService = new SsoLoginService(this.stateProvider); this.ssoLoginService = new SsoLoginService(this.stateProvider);
this.userVerificationApiService = new UserVerificationApiService(this.apiService); this.userVerificationApiService = new UserVerificationApiService(this.apiService);
@ -1114,8 +1080,7 @@ export default class MainBackground {
this.userKeyInitService.listenForActiveUserChangesToSetUserKey(); this.userKeyInitService.listenForActiveUserChangesToSetUserKey();
await (this.i18nService as I18nService).init(); await (this.i18nService as I18nService).init();
await (this.eventUploadService as EventUploadService).init(true); (this.eventUploadService as EventUploadService).init(true);
this.twoFactorService.init();
if (this.popupOnlyContext) { if (this.popupOnlyContext) {
return; return;

View File

@ -2,6 +2,7 @@ import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core"; import { Inject, Injectable } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -15,6 +16,7 @@ export class InitService {
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private stateService: StateServiceAbstraction, private stateService: StateServiceAbstraction,
private twoFactorService: TwoFactorService,
private logService: LogServiceAbstraction, private logService: LogServiceAbstraction,
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
@ -24,6 +26,7 @@ export class InitService {
return async () => { return async () => {
await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations
await this.i18nService.init(); await this.i18nService.init();
this.twoFactorService.init();
if (!BrowserPopupUtils.inPopup(window)) { if (!BrowserPopupUtils.inPopup(window)) {
window.document.body.classList.add("body-full"); window.document.body.classList.add("body-full");

View File

@ -15,10 +15,7 @@ import {
INTRAPROCESS_MESSAGING_SUBJECT, INTRAPROCESS_MESSAGING_SUBJECT,
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
AuthRequestServiceAbstraction,
LoginStrategyServiceAbstraction,
} from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
@ -33,7 +30,6 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { import {
@ -168,21 +164,11 @@ const safeProviders: SafeProvider[] = [
useClass: UnauthGuardService, useClass: UnauthGuardService,
deps: [AuthServiceAbstraction, Router], deps: [AuthServiceAbstraction, Router],
}), }),
safeProvider({
provide: TwoFactorService,
useFactory: getBgService<TwoFactorService>("twoFactorService"),
deps: [],
}),
safeProvider({ safeProvider({
provide: AuthServiceAbstraction, provide: AuthServiceAbstraction,
useFactory: getBgService<AuthService>("authService"), useFactory: getBgService<AuthService>("authService"),
deps: [], deps: [],
}), }),
safeProvider({
provide: LoginStrategyServiceAbstraction,
useFactory: getBgService<LoginStrategyServiceAbstraction>("loginStrategyService"),
deps: [],
}),
safeProvider({ safeProvider({
provide: SsoLoginServiceAbstraction, provide: SsoLoginServiceAbstraction,
useFactory: getBgService<SsoLoginServiceAbstraction>("ssoLoginService"), useFactory: getBgService<SsoLoginServiceAbstraction>("ssoLoginService"),

View File

@ -231,7 +231,7 @@ export class LoginCommand {
} }
} }
if (response.requiresTwoFactor) { if (response.requiresTwoFactor) {
const twoFactorProviders = this.twoFactorService.getSupportedProviders(null); const twoFactorProviders = await this.twoFactorService.getSupportedProviders(null);
if (twoFactorProviders.length === 0) { if (twoFactorProviders.length === 0) {
return Response.badRequest("No providers available for this client."); return Response.badRequest("No providers available for this client.");
} }
@ -272,7 +272,7 @@ export class LoginCommand {
if ( if (
twoFactorToken == null && twoFactorToken == null &&
response.twoFactorProviders.size > 1 && Object.keys(response.twoFactorProviders).length > 1 &&
selectedProvider.type === TwoFactorProviderType.Email selectedProvider.type === TwoFactorProviderType.Email
) { ) {
const emailReq = new TwoFactorEmailRequest(); const emailReq = new TwoFactorEmailRequest();

View File

@ -455,7 +455,11 @@ export class Main {
this.stateProvider, this.stateProvider,
); );
this.twoFactorService = new TwoFactorService(this.i18nService, this.platformUtilsService); this.twoFactorService = new TwoFactorService(
this.i18nService,
this.platformUtilsService,
this.globalStateProvider,
);
this.passwordStrengthService = new PasswordStrengthService(); this.passwordStrengthService = new PasswordStrengthService();

View File

@ -253,7 +253,7 @@ describe("SsoComponent", () => {
describe("2FA scenarios", () => { describe("2FA scenarios", () => {
beforeEach(() => { beforeEach(() => {
const authResult = new AuthResult(); const authResult = new AuthResult();
authResult.twoFactorProviders = new Map([[TwoFactorProviderType.Authenticator, {}]]); authResult.twoFactorProviders = { [TwoFactorProviderType.Authenticator]: {} };
// use standard user with MP because this test is not concerned with password reset. // use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);

View File

@ -2,7 +2,10 @@ import { Directive, EventEmitter, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import {
TwoFactorProviderDetails,
TwoFactorService,
} from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -24,11 +27,11 @@ export class TwoFactorOptionsComponent implements OnInit {
protected environmentService: EnvironmentService, protected environmentService: EnvironmentService,
) {} ) {}
ngOnInit() { async ngOnInit() {
this.providers = this.twoFactorService.getSupportedProviders(this.win); this.providers = await this.twoFactorService.getSupportedProviders(this.win);
} }
choose(p: any) { async choose(p: TwoFactorProviderDetails) {
this.onProviderSelected.emit(p.type); this.onProviderSelected.emit(p.type);
} }

View File

@ -102,7 +102,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
} }
async ngOnInit() { async ngOnInit() {
if (!(await this.authing()) || this.twoFactorService.getProviders() == null) { if (!(await this.authing()) || (await this.twoFactorService.getProviders()) == null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.loginRoute]); this.router.navigate([this.loginRoute]);
@ -145,7 +145,9 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
); );
} }
this.selectedProviderType = this.twoFactorService.getDefaultProvider(this.webAuthnSupported); this.selectedProviderType = await this.twoFactorService.getDefaultProvider(
this.webAuthnSupported,
);
await this.init(); await this.init();
} }
@ -162,12 +164,14 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
this.cleanupWebAuthn(); this.cleanupWebAuthn();
this.title = (TwoFactorProviders as any)[this.selectedProviderType].name; this.title = (TwoFactorProviders as any)[this.selectedProviderType].name;
const providerData = this.twoFactorService.getProviders().get(this.selectedProviderType); const providerData = await this.twoFactorService.getProviders().then((providers) => {
return providers.get(this.selectedProviderType);
});
switch (this.selectedProviderType) { switch (this.selectedProviderType) {
case TwoFactorProviderType.WebAuthn: case TwoFactorProviderType.WebAuthn:
if (!this.webAuthnNewTab) { if (!this.webAuthnNewTab) {
setTimeout(() => { setTimeout(async () => {
this.authWebAuthn(); await this.authWebAuthn();
}, 500); }, 500);
} }
break; break;
@ -212,7 +216,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
break; break;
case TwoFactorProviderType.Email: case TwoFactorProviderType.Email:
this.twoFactorEmail = providerData.Email; this.twoFactorEmail = providerData.Email;
if (this.twoFactorService.getProviders().size > 1) { if ((await this.twoFactorService.getProviders()).size > 1) {
await this.sendEmail(false); await this.sendEmail(false);
} }
break; break;
@ -474,8 +478,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
this.emailPromise = null; this.emailPromise = null;
} }
authWebAuthn() { async authWebAuthn() {
const providerData = this.twoFactorService.getProviders().get(this.selectedProviderType); const providerData = await this.twoFactorService.getProviders().then((providers) => {
return providers.get(this.selectedProviderType);
});
if (!this.webAuthnSupported || this.webAuthn == null) { if (!this.webAuthnSupported || this.webAuthn == null) {
return; return;

View File

@ -874,7 +874,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: TwoFactorServiceAbstraction, provide: TwoFactorServiceAbstraction,
useClass: TwoFactorService, useClass: TwoFactorService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction, GlobalStateProvider],
}), }),
safeProvider({ safeProvider({
provide: FormValidationErrorsServiceAbstraction, provide: FormValidationErrorsServiceAbstraction,

View File

@ -86,7 +86,9 @@ describe("AuthRequestLoginStrategy", () => {
tokenService.getTwoFactorToken.mockResolvedValue(null); tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId); appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeAccessToken.mockResolvedValue({}); tokenService.decodeAccessToken.mockResolvedValue({
sub: mockUserId,
});
authRequestLoginStrategy = new AuthRequestLoginStrategy( authRequestLoginStrategy = new AuthRequestLoginStrategy(
cache, cache,

View File

@ -25,11 +25,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account";
Account,
AccountProfile,
AccountKeys,
} from "@bitwarden/common/platform/models/domain/account";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
@ -214,7 +210,6 @@ describe("LoginStrategy", () => {
email: email, email: email,
}, },
}, },
keys: new AccountKeys(),
}), }),
); );
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith( expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
@ -223,6 +218,21 @@ describe("LoginStrategy", () => {
expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
}); });
it("throws if active account isn't found after being initialized", async () => {
const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
const mockVaultTimeoutAction = VaultTimeoutAction.Lock;
const mockVaultTimeout = 1000;
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
accountService.activeAccountSubject.next(null);
await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow();
});
it("builds AuthResult", async () => { it("builds AuthResult", async () => {
const tokenResponse = identityTokenResponseFactory(); const tokenResponse = identityTokenResponseFactory();
tokenResponse.forcePasswordReset = true; tokenResponse.forcePasswordReset = true;
@ -306,8 +316,10 @@ describe("LoginStrategy", () => {
expect(tokenService.clearTwoFactorToken).toHaveBeenCalled(); expect(tokenService.clearTwoFactorToken).toHaveBeenCalled();
const expected = new AuthResult(); const expected = new AuthResult();
expected.twoFactorProviders = new Map<TwoFactorProviderType, { [key: string]: string }>(); expected.twoFactorProviders = { 0: null } as Record<
expected.twoFactorProviders.set(0, null); TwoFactorProviderType,
Record<string, string>
>;
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
@ -336,8 +348,9 @@ describe("LoginStrategy", () => {
expect(messagingService.send).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled();
const expected = new AuthResult(); const expected = new AuthResult();
expected.twoFactorProviders = new Map<TwoFactorProviderType, { [key: string]: string }>(); expected.twoFactorProviders = {
expected.twoFactorProviders.set(1, { Email: "k***@bitwarden.com" }); [TwoFactorProviderType.Email]: { Email: "k***@bitwarden.com" },
};
expected.email = userEmail; expected.email = userEmail;
expected.ssoEmail2FaSessionToken = ssoEmail2FaSessionToken; expected.ssoEmail2FaSessionToken = ssoEmail2FaSessionToken;

View File

@ -1,4 +1,4 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, filter, firstValueFrom, timeout } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@ -101,7 +101,7 @@ export abstract class LoginStrategy {
} }
protected async startLogIn(): Promise<[AuthResult, IdentityResponse]> { protected async startLogIn(): Promise<[AuthResult, IdentityResponse]> {
this.twoFactorService.clearSelectedProvider(); await this.twoFactorService.clearSelectedProvider();
const tokenRequest = this.cache.value.tokenRequest; const tokenRequest = this.cache.value.tokenRequest;
const response = await this.apiService.postIdentityToken(tokenRequest); const response = await this.apiService.postIdentityToken(tokenRequest);
@ -159,12 +159,12 @@ export abstract class LoginStrategy {
* It also sets the access token and refresh token in the token service. * It also sets the access token and refresh token in the token service.
* *
* @param {IdentityTokenResponse} tokenResponse - The response from the server containing the identity token. * @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. * @returns {Promise<UserId>} - A promise that resolves the the UserId when the account information has been successfully saved.
*/ */
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<UserId> { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<UserId> {
const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken); const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken);
const userId = accountInformation.sub; const userId = accountInformation.sub as UserId;
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId }); const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
const vaultTimeout = await this.stateService.getVaultTimeout({ userId }); const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
@ -191,6 +191,8 @@ export abstract class LoginStrategy {
}), }),
); );
await this.verifyAccountAdded(userId);
await this.userDecryptionOptionsService.setUserDecryptionOptions( await this.userDecryptionOptionsService.setUserDecryptionOptions(
UserDecryptionOptions.fromResponse(tokenResponse), UserDecryptionOptions.fromResponse(tokenResponse),
); );
@ -207,7 +209,7 @@ export abstract class LoginStrategy {
); );
await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false); await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false);
return userId as UserId; return userId;
} }
protected async processTokenResponse(response: IdentityTokenResponse): Promise<AuthResult> { protected async processTokenResponse(response: IdentityTokenResponse): Promise<AuthResult> {
@ -284,7 +286,7 @@ export abstract class LoginStrategy {
const result = new AuthResult(); const result = new AuthResult();
result.twoFactorProviders = response.twoFactorProviders2; result.twoFactorProviders = response.twoFactorProviders2;
this.twoFactorService.setProviders(response); await this.twoFactorService.setProviders(response);
this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null }); this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null });
result.ssoEmail2FaSessionToken = response.ssoEmail2faSessionToken; result.ssoEmail2FaSessionToken = response.ssoEmail2faSessionToken;
result.email = response.email; result.email = response.email;
@ -306,4 +308,24 @@ export abstract class LoginStrategy {
result.captchaSiteKey = response.siteKey; result.captchaSiteKey = response.siteKey;
return result; return result;
} }
/**
* Verifies that the active account is set after initialization.
* Note: In browser there is a slight delay between when active account emits in background,
* and when it emits in foreground. We're giving the foreground 1 second to catch up.
* If nothing is emitted, we throw an error.
*/
private async verifyAccountAdded(expectedUserId: UserId) {
await firstValueFrom(
this.accountService.activeAccount$.pipe(
filter((account) => account?.id === expectedUserId),
timeout({
first: 1000,
with: () => {
throw new Error("Expected user never made active user after initialization.");
},
}),
),
);
}
} }

View File

@ -99,7 +99,9 @@ describe("PasswordLoginStrategy", () => {
kdfConfigService = mock<KdfConfigService>(); kdfConfigService = mock<KdfConfigService>();
appIdService.getAppId.mockResolvedValue(deviceId); appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeAccessToken.mockResolvedValue({}); tokenService.decodeAccessToken.mockResolvedValue({
sub: userId,
});
loginStrategyService.makePreloginKey.mockResolvedValue(masterKey); loginStrategyService.makePreloginKey.mockResolvedValue(masterKey);

View File

@ -92,7 +92,9 @@ describe("SsoLoginStrategy", () => {
tokenService.getTwoFactorToken.mockResolvedValue(null); tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId); appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeAccessToken.mockResolvedValue({}); tokenService.decodeAccessToken.mockResolvedValue({
sub: userId,
});
ssoLoginStrategy = new SsoLoginStrategy( ssoLoginStrategy = new SsoLoginStrategy(
null, null,

View File

@ -82,7 +82,9 @@ describe("UserApiLoginStrategy", () => {
appIdService.getAppId.mockResolvedValue(deviceId); appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.getTwoFactorToken.mockResolvedValue(null); tokenService.getTwoFactorToken.mockResolvedValue(null);
tokenService.decodeAccessToken.mockResolvedValue({}); tokenService.decodeAccessToken.mockResolvedValue({
sub: userId,
});
apiLogInStrategy = new UserApiLoginStrategy( apiLogInStrategy = new UserApiLoginStrategy(
cache, cache,

View File

@ -18,7 +18,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService } from "@bitwarden/common/spec"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { PrfKey, UserKey } from "@bitwarden/common/types/key"; import { PrfKey, UserKey } from "@bitwarden/common/types/key";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
@ -49,6 +50,7 @@ describe("WebAuthnLoginStrategy", () => {
const token = "mockToken"; const token = "mockToken";
const deviceId = Utils.newGuid(); const deviceId = Utils.newGuid();
const userId = Utils.newGuid() as UserId;
let webAuthnCredentials!: WebAuthnLoginCredentials; let webAuthnCredentials!: WebAuthnLoginCredentials;
@ -69,7 +71,7 @@ describe("WebAuthnLoginStrategy", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
accountService = new FakeAccountService(null); accountService = mockAccountServiceWith(userId);
masterPasswordService = new FakeMasterPasswordService(); masterPasswordService = new FakeMasterPasswordService();
cryptoService = mock<CryptoService>(); cryptoService = mock<CryptoService>();
@ -87,7 +89,9 @@ describe("WebAuthnLoginStrategy", () => {
tokenService.getTwoFactorToken.mockResolvedValue(null); tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId); appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeAccessToken.mockResolvedValue({}); tokenService.decodeAccessToken.mockResolvedValue({
sub: userId,
});
webAuthnLoginStrategy = new WebAuthnLoginStrategy( webAuthnLoginStrategy = new WebAuthnLoginStrategy(
cache, cache,

View File

@ -12,12 +12,12 @@ export interface TwoFactorProviderDetails {
export abstract class TwoFactorService { export abstract class TwoFactorService {
init: () => void; init: () => void;
getSupportedProviders: (win: Window) => TwoFactorProviderDetails[]; getSupportedProviders: (win: Window) => Promise<TwoFactorProviderDetails[]>;
getDefaultProvider: (webAuthnSupported: boolean) => TwoFactorProviderType; getDefaultProvider: (webAuthnSupported: boolean) => Promise<TwoFactorProviderType>;
setSelectedProvider: (type: TwoFactorProviderType) => void; setSelectedProvider: (type: TwoFactorProviderType) => Promise<void>;
clearSelectedProvider: () => void; clearSelectedProvider: () => Promise<void>;
setProviders: (response: IdentityTwoFactorResponse) => void; setProviders: (response: IdentityTwoFactorResponse) => Promise<void>;
clearProviders: () => void; clearProviders: () => Promise<void>;
getProviders: () => Map<TwoFactorProviderType, { [key: string]: string }>; getProviders: () => Promise<Map<TwoFactorProviderType, { [key: string]: string }>>;
} }

View File

@ -14,7 +14,7 @@ export class AuthResult {
resetMasterPassword = false; resetMasterPassword = false;
forcePasswordReset: ForceSetPasswordReason = ForceSetPasswordReason.None; forcePasswordReset: ForceSetPasswordReason = ForceSetPasswordReason.None;
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string }> = null; twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
ssoEmail2FaSessionToken?: string; ssoEmail2FaSessionToken?: string;
email: string; email: string;
requiresEncryptionKeyMigration: boolean; requiresEncryptionKeyMigration: boolean;

View File

@ -4,8 +4,10 @@ import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response"; import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
export class IdentityTwoFactorResponse extends BaseResponse { export class IdentityTwoFactorResponse extends BaseResponse {
// contains available two-factor providers
twoFactorProviders: TwoFactorProviderType[]; twoFactorProviders: TwoFactorProviderType[];
twoFactorProviders2 = new Map<TwoFactorProviderType, { [key: string]: string }>(); // a map of two-factor providers to necessary data for completion
twoFactorProviders2: Record<TwoFactorProviderType, Record<string, string>>;
captchaToken: string; captchaToken: string;
ssoEmail2faSessionToken: string; ssoEmail2faSessionToken: string;
email?: string; email?: string;
@ -15,15 +17,7 @@ export class IdentityTwoFactorResponse extends BaseResponse {
super(response); super(response);
this.captchaToken = this.getResponseProperty("CaptchaBypassToken"); this.captchaToken = this.getResponseProperty("CaptchaBypassToken");
this.twoFactorProviders = this.getResponseProperty("TwoFactorProviders"); this.twoFactorProviders = this.getResponseProperty("TwoFactorProviders");
const twoFactorProviders2 = this.getResponseProperty("TwoFactorProviders2"); this.twoFactorProviders2 = this.getResponseProperty("TwoFactorProviders2");
if (twoFactorProviders2 != null) {
for (const prop in twoFactorProviders2) {
// eslint-disable-next-line
if (twoFactorProviders2.hasOwnProperty(prop)) {
this.twoFactorProviders2.set(parseInt(prop, null), twoFactorProviders2[prop]);
}
}
}
this.masterPasswordPolicy = new MasterPasswordPolicyResponse( this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"), this.getResponseProperty("MasterPasswordPolicy"),
); );

View File

@ -1,5 +1,9 @@
import { firstValueFrom, map } from "rxjs";
import { I18nService } from "../../platform/abstractions/i18n.service"; import { I18nService } from "../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { Utils } from "../../platform/misc/utils";
import { GlobalStateProvider, KeyDefinition, TWO_FACTOR_MEMORY } from "../../platform/state";
import { import {
TwoFactorProviderDetails, TwoFactorProviderDetails,
TwoFactorService as TwoFactorServiceAbstraction, TwoFactorService as TwoFactorServiceAbstraction,
@ -59,13 +63,36 @@ export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactor
}, },
}; };
// Memory storage as only required during authentication process
export const PROVIDERS = KeyDefinition.record<Record<string, string>, TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"providers",
{
deserializer: (obj) => obj,
},
);
// Memory storage as only required during authentication process
export const SELECTED_PROVIDER = new KeyDefinition<TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"selected",
{
deserializer: (obj) => obj,
},
);
export class TwoFactorService implements TwoFactorServiceAbstraction { export class TwoFactorService implements TwoFactorServiceAbstraction {
private twoFactorProvidersData: Map<TwoFactorProviderType, { [key: string]: string }>; private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedTwoFactorProviderType: TwoFactorProviderType = null; private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
) {} ) {}
init() { init() {
@ -93,63 +120,60 @@ export class TwoFactorService implements TwoFactorServiceAbstraction {
this.i18nService.t("yubiKeyDesc"); this.i18nService.t("yubiKeyDesc");
} }
getSupportedProviders(win: Window): TwoFactorProviderDetails[] { async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = []; const providers: any[] = [];
if (this.twoFactorProvidersData == null) { if (data == null) {
return providers; return providers;
} }
if ( if (
this.twoFactorProvidersData.has(TwoFactorProviderType.OrganizationDuo) && data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo() this.platformUtilsService.supportsDuo()
) { ) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]); providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
} }
if (this.twoFactorProvidersData.has(TwoFactorProviderType.Authenticator)) { if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]); providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
} }
if (this.twoFactorProvidersData.has(TwoFactorProviderType.Yubikey)) { if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]); providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
} }
if ( if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
this.twoFactorProvidersData.has(TwoFactorProviderType.Duo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]); providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
} }
if ( if (
this.twoFactorProvidersData.has(TwoFactorProviderType.WebAuthn) && data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win) this.platformUtilsService.supportsWebAuthn(win)
) { ) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]); providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
} }
if (this.twoFactorProvidersData.has(TwoFactorProviderType.Email)) { if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]); providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
} }
return providers; return providers;
} }
getDefaultProvider(webAuthnSupported: boolean): TwoFactorProviderType { async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
if (this.twoFactorProvidersData == null) { const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null; return null;
} }
if ( if (selected != null && data.has(selected)) {
this.selectedTwoFactorProviderType != null && return selected;
this.twoFactorProvidersData.has(this.selectedTwoFactorProviderType)
) {
return this.selectedTwoFactorProviderType;
} }
let providerType: TwoFactorProviderType = null; let providerType: TwoFactorProviderType = null;
let providerPriority = -1; let providerPriority = -1;
this.twoFactorProvidersData.forEach((_value, type) => { data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type]; const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) { if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) { if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
@ -164,23 +188,23 @@ export class TwoFactorService implements TwoFactorServiceAbstraction {
return providerType; return providerType;
} }
setSelectedProvider(type: TwoFactorProviderType) { async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
this.selectedTwoFactorProviderType = type; await this.selectedState.update(() => type);
} }
clearSelectedProvider() { async clearSelectedProvider(): Promise<void> {
this.selectedTwoFactorProviderType = null; await this.selectedState.update(() => null);
} }
setProviders(response: IdentityTwoFactorResponse) { async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
this.twoFactorProvidersData = response.twoFactorProviders2; await this.providersState.update(() => response.twoFactorProviders2);
} }
clearProviders() { async clearProviders(): Promise<void> {
this.twoFactorProvidersData = null; await this.providersState.update(() => null);
} }
getProviders() { getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }>> {
return this.twoFactorProvidersData; return firstValueFrom(this.providers$);
} }
} }

View File

@ -0,0 +1,25 @@
import { record } from "./deserialization-helpers";
describe("deserialization helpers", () => {
describe("record", () => {
it("deserializes a record when keys are strings", () => {
const deserializer = record((value: number) => value);
const input = {
a: 1,
b: 2,
};
const output = deserializer(input);
expect(output).toEqual(input);
});
it("deserializes a record when keys are numbers", () => {
const deserializer = record((value: number) => value);
const input = {
1: 1,
2: 2,
};
const output = deserializer(input);
expect(output).toEqual(input);
});
});
});

View File

@ -21,7 +21,7 @@ export function array<T>(
* *
* @param valueDeserializer * @param valueDeserializer
*/ */
export function record<T, TKey extends string = string>( export function record<T, TKey extends string | number = string>(
valueDeserializer: (value: Jsonify<T>) => T, valueDeserializer: (value: Jsonify<T>) => T,
): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> { ): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> {
return (jsonValue: Jsonify<Record<TKey, T> | null>) => { return (jsonValue: Jsonify<Record<TKey, T> | null>) => {
@ -29,10 +29,10 @@ export function record<T, TKey extends string = string>(
return null; return null;
} }
const output: Record<string, T> = {}; const output: Record<TKey, T> = {} as any;
for (const key in jsonValue) { Object.entries(jsonValue).forEach(([key, value]) => {
output[key] = valueDeserializer((jsonValue as Record<string, Jsonify<T>>)[key]); output[key as TKey] = valueDeserializer(value);
} });
return output; return output;
}; };
} }

View File

@ -113,7 +113,7 @@ export class KeyDefinition<T> {
* }); * });
* ``` * ```
*/ */
static record<T, TKey extends string = string>( static record<T, TKey extends string | number = string>(
stateDefinition: StateDefinition, stateDefinition: StateDefinition,
key: string, key: string,
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird. // We have them provide options for the value of the record, depending on future options we add, this could get a little weird.

View File

@ -40,6 +40,7 @@ export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
export const ROUTER_DISK = new StateDefinition("router", "disk"); export const ROUTER_DISK = new StateDefinition("router", "disk");
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", { export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {

View File

@ -120,7 +120,7 @@ export class UserKeyDefinition<T> {
* }); * });
* ``` * ```
*/ */
static record<T, TKey extends string = string>( static record<T, TKey extends string | number = string>(
stateDefinition: StateDefinition, stateDefinition: StateDefinition,
key: string, key: string,
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird. // We have them provide options for the value of the record, depending on future options we add, this could get a little weird.