1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 04:33:38 +00:00

Merge branch 'main' into km/package-types

This commit is contained in:
Bernd Schoolmann
2025-11-21 18:00:38 +01:00
committed by GitHub
410 changed files with 10679 additions and 5217 deletions

View File

@@ -102,7 +102,6 @@ import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.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 { 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 { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
@@ -125,13 +124,17 @@ import { OrganizationInviteService } from "@bitwarden/common/auth/services/organ
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.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 { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service";
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import {
TwoFactorApiService,
DefaultTwoFactorApiService,
TwoFactorService,
DefaultTwoFactorService,
} from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -527,7 +530,7 @@ const safeProviders: SafeProvider[] = [
KeyConnectorServiceAbstraction,
EnvironmentService,
StateServiceAbstraction,
TwoFactorServiceAbstraction,
TwoFactorService,
I18nServiceAbstraction,
EncryptService,
PasswordStrengthServiceAbstraction,
@@ -1165,9 +1168,14 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider],
}),
safeProvider({
provide: TwoFactorServiceAbstraction,
useClass: TwoFactorService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction, GlobalStateProvider],
provide: TwoFactorService,
useClass: DefaultTwoFactorService,
deps: [
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
GlobalStateProvider,
TwoFactorApiService,
],
}),
safeProvider({
provide: FormValidationErrorsServiceAbstraction,
@@ -1451,7 +1459,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationMetadataServiceAbstraction,
useClass: DefaultOrganizationMetadataService,
deps: [BillingApiServiceAbstraction, ConfigService],
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: BillingAccountProfileStateService,

View File

@@ -37,6 +37,7 @@ export const NudgeType = {
NewNoteItemStatus: "new-note-item-status",
NewSshItemStatus: "new-ssh-item-status",
GeneratorNudgeStatus: "generator-nudge-status",
PremiumUpgrade: "premium-upgrade",
} as const;
export type NudgeType = UnionOfValues<typeof NudgeType>;

View File

@@ -5,6 +5,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
import { Subject, firstValueFrom } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
@@ -31,6 +32,12 @@ import { PasswordInputResult } from "../../input-password/password-input-result"
import { RegistrationFinishService } from "./registration-finish.service";
const MarketingInitiative = Object.freeze({
Premium: "premium",
} as const);
type MarketingInitiative = (typeof MarketingInitiative)[keyof typeof MarketingInitiative];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -46,6 +53,12 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
submitting = false;
email: string;
/**
* Indicates that the user is coming from a marketing page designed to streamline
* users who intend to setup a premium subscription after registration.
*/
premiumInterest = false;
// Note: this token is the email verification token. When it is supplied as a query param,
// it either comes from the email verification email or, if email verification is disabled server side
// via global settings, it comes directly from the registration-start component directly.
@@ -79,6 +92,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
private logService: LogService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private premiumInterestStateService: PremiumInterestStateService,
) {}
async ngOnInit() {
@@ -126,6 +140,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
this.providerInviteToken = qParams.providerInviteToken;
this.providerUserId = qParams.providerUserId;
}
if (qParams.fromMarketing != null && qParams.fromMarketing === MarketingInitiative.Premium) {
this.premiumInterest = true;
}
}
private async initOrgInviteFlowIfPresent(): Promise<boolean> {
@@ -190,6 +208,13 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
await this.loginSuccessHandlerService.run(authenticationResult.userId);
if (this.premiumInterest) {
await this.premiumInterestStateService.setPremiumInterest(
authenticationResult.userId,
true,
);
}
await this.router.navigate(["/vault"]);
} catch (e) {
// If login errors, redirect to login page per product. Don't show error

View File

@@ -4,10 +4,9 @@ import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -68,7 +67,6 @@ export class TwoFactorAuthEmailComponent implements OnInit {
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
protected twoFactorApiService: TwoFactorApiService,
protected appIdService: AppIdService,
private toastService: ToastService,
private cacheService: TwoFactorAuthEmailComponentCacheService,
@@ -137,7 +135,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
request.deviceIdentifier = await this.appIdService.getAppId();
request.authRequestAccessCode = (await this.loginStrategyService.getAccessCode()) ?? "";
request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? "";
this.emailPromise = this.twoFactorApiService.postTwoFactorEmail(request);
this.emailPromise = this.twoFactorService.postTwoFactorEmail(request);
await this.emailPromise;
this.emailSent = true;

View File

@@ -6,8 +6,8 @@ import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -18,12 +18,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import {
InternalMasterPasswordServiceAbstraction,

View File

@@ -32,12 +32,12 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";

View File

@@ -4,8 +4,8 @@ import { provideRouter, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { LoginStrategyServiceAbstraction } from "../../common";

View File

@@ -8,7 +8,7 @@ import {
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { LoginStrategyServiceAbstraction } from "../../common";

View File

@@ -9,11 +9,8 @@ import {
TwoFactorAuthWebAuthnIcon,
TwoFactorAuthYubicoIcon,
} from "@bitwarden/assets/svg";
import {
TwoFactorProviderDetails,
TwoFactorService,
} from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderDetails, TwoFactorService } from "@bitwarden/common/auth/two-factor";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {

View File

@@ -277,13 +277,13 @@ export class UserVerificationDialogComponent {
});
}
}
} catch (e) {
} catch {
// Catch handles OTP and MP verification scenarios as those throw errors on verification failure instead of returning false like PIN and biometrics.
this.invalidSecret = true;
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
message: this.i18nService.t("userVerificationFailed"),
});
return;
}

View File

@@ -3,8 +3,8 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";

View File

@@ -5,7 +5,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -16,6 +15,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";

View File

@@ -3,7 +3,6 @@ import { BehaviorSubject, filter, firstValueFrom, timeout, Observable } from "rx
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -16,6 +15,7 @@ import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";

View File

@@ -5,12 +5,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";

View File

@@ -3,12 +3,12 @@ import { BehaviorSubject, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";

View File

@@ -3,7 +3,7 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";

View File

@@ -3,11 +3,11 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";

View File

@@ -4,13 +4,13 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogin.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";

View File

@@ -13,10 +13,10 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";

View File

@@ -20,4 +20,5 @@ export enum PolicyType {
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client
BlockClaimedDomainAccountCreation = 19, // Prevents users from creating personal accounts using email addresses from verified domains
}

View File

@@ -32,6 +32,7 @@ describe("Organization", () => {
useSecretsManager: true,
usePasswordManager: true,
useActivateAutofillPolicy: false,
useAutomaticUserConfirmation: false,
selfHost: false,
usersGetPremium: false,
seats: 10,
@@ -179,4 +180,118 @@ describe("Organization", () => {
expect(organization.canManageDeviceApprovals).toBe(true);
});
});
describe("canEnableAutoConfirmPolicy", () => {
it("should return false when user cannot manage users or policies", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = false;
data.permissions.managePolicies = false;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user can manage users but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.Admin;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user has manageUsers permission but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user can manage policies but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.Admin;
data.usePolicies = true;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user has managePolicies permission but usePolicies is false", () => {
data.type = OrganizationUserType.User;
data.permissions.managePolicies = true;
data.usePolicies = false;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return true when admin has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Admin;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when owner has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Owner;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has manageUsers permission and useAutomaticUserConfirmation is enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has managePolicies permission, usePolicies is true, and useAutomaticUserConfirmation is enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.managePolicies = true;
data.usePolicies = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has both manageUsers and managePolicies permissions with useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.permissions.managePolicies = true;
data.usePolicies = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return false when provider user has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Owner;
data.isProviderUser = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
});
});

View File

@@ -310,6 +310,14 @@ export class Organization {
return this.isAdmin || this.permissions.manageResetPassword;
}
get canEnableAutoConfirmPolicy() {
return (
(this.canManageUsers || this.canManagePolicies) &&
this.useAutomaticUserConfirmation &&
!this.isProviderUser
);
}
get canManageDeviceApprovals() {
return (
(this.isAdmin || this.permissions.manageResetPassword) &&

View File

@@ -1,60 +0,0 @@
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
export interface TwoFactorProviderDetails {
type: TwoFactorProviderType;
name: string;
description: string;
priority: number;
sort: number;
premium: boolean;
}
export abstract class TwoFactorService {
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
}

View File

@@ -13,7 +13,7 @@ export abstract class SendTokenService {
/**
* Attempts to retrieve a {@link SendAccessToken} for the given sendId.
* If the access token is found in session storage and is not expired, then it returns the token.
* If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error.
* If the access token found in session storage is expired, then it returns a {@link TryGetSendAccessTokenError} expired error and clears the token from storage so that a subsequent call can attempt to retrieve a new token.
* If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view).
* If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it.
* If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required.

View File

@@ -1,212 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { I18nService } from "../../platform/abstractions/i18n.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 {
TwoFactorProviderDetails,
TwoFactorService as TwoFactorServiceAbstraction,
} from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactorProviderDetails>> =
{
[TwoFactorProviderType.Authenticator]: {
type: TwoFactorProviderType.Authenticator,
name: null as string,
description: null as string,
priority: 1,
sort: 2,
premium: false,
},
[TwoFactorProviderType.Yubikey]: {
type: TwoFactorProviderType.Yubikey,
name: null as string,
description: null as string,
priority: 3,
sort: 4,
premium: true,
},
[TwoFactorProviderType.Duo]: {
type: TwoFactorProviderType.Duo,
name: "Duo",
description: null as string,
priority: 2,
sort: 5,
premium: true,
},
[TwoFactorProviderType.OrganizationDuo]: {
type: TwoFactorProviderType.OrganizationDuo,
name: "Duo (Organization)",
description: null as string,
priority: 10,
sort: 6,
premium: false,
},
[TwoFactorProviderType.Email]: {
type: TwoFactorProviderType.Email,
name: null as string,
description: null as string,
priority: 0,
sort: 1,
premium: false,
},
[TwoFactorProviderType.WebAuthn]: {
type: TwoFactorProviderType.WebAuthn,
name: null as string,
description: null as string,
priority: 4,
sort: 3,
premium: false,
},
};
// 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 {
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
) {}
init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle");
TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDescV2");
TwoFactorProviders[TwoFactorProviderType.Authenticator].name =
this.i18nService.t("authenticatorAppTitle");
TwoFactorProviders[TwoFactorProviderType.Authenticator].description =
this.i18nService.t("authenticatorAppDescV2");
TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDescV2");
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name =
"Duo (" + this.i18nService.t("organization") + ")";
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description =
this.i18nService.t("duoOrganizationDesc");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].description =
this.i18nService.t("webAuthnDesc");
TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitleV2");
TwoFactorProviders[TwoFactorProviderType.Yubikey].description =
this.i18nService.t("yubiKeyDesc");
}
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (data == null) {
return providers;
}
if (
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
return;
}
providerType = type;
providerPriority = provider.priority;
}
});
return providerType;
}
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./two-factor-api.service";
export * from "./two-factor.service";

View File

@@ -0,0 +1,497 @@
import { ListResponse } from "../../../models/response/list.response";
import { KeyDefinition, TWO_FACTOR_MEMORY } from "../../../platform/state";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "../../models/request/disable-two-factor-authenticator.request";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { TwoFactorEmailRequest } from "../../models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../../models/request/two-factor-provider.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../../models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../../models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../../models/request/update-two-factor-email.request";
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../../models/request/update-two-factor-web-authn-delete.request";
import { UpdateTwoFactorWebAuthnRequest } from "../../models/request/update-two-factor-web-authn.request";
import { UpdateTwoFactorYubikeyOtpRequest } from "../../models/request/update-two-factor-yubikey-otp.request";
import { IdentityTwoFactorResponse } from "../../models/response/identity-two-factor.response";
import { TwoFactorAuthenticatorResponse } from "../../models/response/two-factor-authenticator.response";
import { TwoFactorDuoResponse } from "../../models/response/two-factor-duo.response";
import { TwoFactorEmailResponse } from "../../models/response/two-factor-email.response";
import { TwoFactorProviderResponse } from "../../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../../models/response/two-factor-recover.response";
import {
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "../../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../../models/response/two-factor-yubi-key.response";
/**
* Metadata and display information for a two-factor authentication provider.
* Used by UI components to render provider selection and configuration screens.
*/
export interface TwoFactorProviderDetails {
/** The unique identifier for this provider type. */
type: TwoFactorProviderType;
/**
* Display name for the provider, localized via {@link TwoFactorService.init}.
* Examples: "Authenticator App", "Email", "YubiKey".
*/
name: string | null;
/**
* User-facing description explaining what this provider is and how it works.
* Localized via {@link TwoFactorService.init}.
*/
description: string | null;
/**
* Selection priority during login when multiple providers are available.
* Higher values are preferred. Used to determine the default provider.
* Range: 0 (lowest) to 10 (highest).
*/
priority: number;
/**
* Display order in provider lists within settings UI.
* Lower values appear first (1 = first position).
*/
sort: number;
/**
* Whether this provider requires an active premium subscription.
* Premium providers: Duo (personal), YubiKey.
* Organization providers (e.g., OrganizationDuo) do not require personal premium.
*/
premium: boolean;
}
/**
* Registry of all supported two-factor authentication providers with their metadata.
* Strings (name, description) are initialized as null and populated with localized
* translations when {@link TwoFactorService.init} is called during application startup.
*
* @remarks
* This constant is mutated during initialization. Components should not access it before
* the service's init() method has been called.
*
* @example
* ```typescript
* // During app init
* twoFactorService.init();
*
* // In components
* const authenticator = TwoFactorProviders[TwoFactorProviderType.Authenticator];
* console.log(authenticator.name); // "Authenticator App" (localized)
* ```
*/
export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactorProviderDetails>> =
{
[TwoFactorProviderType.Authenticator]: {
type: TwoFactorProviderType.Authenticator,
name: null,
description: null,
priority: 1,
sort: 2,
premium: false,
},
[TwoFactorProviderType.Yubikey]: {
type: TwoFactorProviderType.Yubikey,
name: null,
description: null,
priority: 3,
sort: 4,
premium: true,
},
[TwoFactorProviderType.Duo]: {
type: TwoFactorProviderType.Duo,
name: "Duo",
description: null,
priority: 2,
sort: 5,
premium: true,
},
[TwoFactorProviderType.OrganizationDuo]: {
type: TwoFactorProviderType.OrganizationDuo,
name: "Duo (Organization)",
description: null,
priority: 10,
sort: 6,
premium: false,
},
[TwoFactorProviderType.Email]: {
type: TwoFactorProviderType.Email,
name: null,
description: null,
priority: 0,
sort: 1,
premium: false,
},
[TwoFactorProviderType.WebAuthn]: {
type: TwoFactorProviderType.WebAuthn,
name: null,
description: null,
priority: 4,
sort: 3,
premium: false,
},
};
// 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 abstract class TwoFactorService {
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
/**
* Gets the enabled two-factor providers for the current user from the API.
* Used for settings management.
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
*/
abstract getEnabledTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>>;
/**
* Gets the enabled two-factor providers for an organization from the API.
* Requires organization administrator permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
*/
abstract getTwoFactorOrganizationProviders(
organizationId: string,
): Promise<ListResponse<TwoFactorProviderResponse>>;
/**
* Gets the authenticator (TOTP) two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the authenticator configuration including the secret key.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorAuthenticator(
request: SecretVerificationRequest,
): Promise<TwoFactorAuthenticatorResponse>;
/**
* Gets the email two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the email two-factor configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse>;
/**
* Gets the Duo two-factor configuration for the current user from the API.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse>;
/**
* Gets the Duo two-factor configuration for an organization from the API.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the organization Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorOrganizationDuo(
organizationId: string,
request: SecretVerificationRequest,
): Promise<TwoFactorDuoResponse>;
/**
* Gets the YubiKey OTP two-factor configuration for the current user from the API.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the YubiKey configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorYubiKey(
request: SecretVerificationRequest,
): Promise<TwoFactorYubiKeyResponse>;
/**
* Gets the WebAuthn (FIDO2) two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to authentication.
* @returns A promise that resolves to the WebAuthn configuration including registered credentials.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorWebAuthn(
request: SecretVerificationRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Gets a WebAuthn challenge for registering a new WebAuthn credential from the API.
* This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge
* required for credential creation. The challenge is used by the browser's WebAuthn API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the credential creation options containing the challenge.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorWebAuthnChallenge(
request: SecretVerificationRequest,
): Promise<ChallengeResponse>;
/**
* Gets the recovery code configuration for the current user from the API.
* The recovery code should be stored securely by the user.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param verification The verification information to prove authentication.
* @returns A promise that resolves to the recovery code configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorRecover(
request: SecretVerificationRequest,
): Promise<TwoFactorRecoverResponse>;
/**
* Enables or updates the authenticator (TOTP) two-factor provider.
* Validates the provided token against the shared secret before enabling.
* The token must be generated by an authenticator app using the secret key.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorAuthenticatorRequest} to prove authentication.
* @returns A promise that resolves to the updated authenticator configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorAuthenticator(
request: UpdateTwoFactorAuthenticatorRequest,
): Promise<TwoFactorAuthenticatorResponse>;
/**
* Disables the authenticator (TOTP) two-factor provider for the current user.
* Requires user verification token to confirm the operation.
* Used for settings management.
*
* @param request The {@link DisableTwoFactorAuthenticatorRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Enables or updates the email two-factor provider for the current user.
* Validates the email verification token sent via postTwoFactorEmailSetup before enabling.
* The token must match the code sent to the specified email address.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves to the updated email two-factor configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse>;
/**
* Enables or updates the Duo two-factor provider for the current user.
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorDuoRequest} to prove authentication.
* @returns A promise that resolves to the updated Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse>;
/**
* Enables or updates the Duo two-factor provider for an organization.
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link UpdateTwoFactorDuoRequest} to prove authentication.
* @returns A promise that resolves to the updated organization Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorOrganizationDuo(
organizationId: string,
request: UpdateTwoFactorDuoRequest,
): Promise<TwoFactorDuoResponse>;
/**
* Enables or updates the YubiKey OTP two-factor provider for the current user.
* Validates each provided YubiKey by testing an OTP from the device.
* Supports up to 5 YubiKey devices. Empty key slots are allowed.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorYubikeyOtpRequest} to prove authentication.
* @returns A promise that resolves to the updated YubiKey configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorYubiKey(
request: UpdateTwoFactorYubikeyOtpRequest,
): Promise<TwoFactorYubiKeyResponse>;
/**
* Registers a new WebAuthn (FIDO2) credential for two-factor authentication for the current user.
* Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow.
* The device response contains the signed challenge from the authenticator device.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorWebAuthnRequest} to prove authentication.
* @returns A promise that resolves to the updated WebAuthn configuration with the new credential.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Removes a specific WebAuthn (FIDO2) credential from the user's account.
* The credential will no longer be usable for two-factor authentication.
* Other registered WebAuthn credentials remain active.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorWebAuthnDeleteRequest} to prove authentication.
* @returns A promise that resolves to the updated WebAuthn configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract deleteTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnDeleteRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Disables a specific two-factor provider for the current user.
* The provider will no longer be required or usable for authentication.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link TwoFactorProviderRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorDisable(
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Disables a specific two-factor provider for an organization.
* The provider will no longer be available for organization members.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link TwoFactorProviderRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorOrganizationDisable(
organizationId: string,
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Initiates email two-factor setup by sending a verification code to the specified email address.
* This is the first step in enabling email two-factor authentication.
* The verification code must be provided to putTwoFactorEmail to complete setup.
* Only used during initial configuration, not during login flows.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link TwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves when the verification email has been sent.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any>;
/**
* Sends a two-factor authentication code via email during the login flow.
* Supports multiple authentication contexts including standard login, SSO, and passwordless flows.
* This is used to deliver codes during authentication, not during initial setup.
* May be called without authentication for login scenarios.
* Used during authentication flows.
*
* @param request The {@link TwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves when the authentication email has been sent.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any>;
}

View File

@@ -1,2 +1,2 @@
export { TwoFactorApiService } from "./two-factor-api.service";
export { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
export * from "./abstractions";
export * from "./services";

View File

@@ -22,7 +22,7 @@ import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { TwoFactorApiService } from "./two-factor-api.service";
import { TwoFactorApiService } from "../abstractions/two-factor-api.service";
export class DefaultTwoFactorApiService implements TwoFactorApiService {
constructor(private apiService: ApiService) {}

View File

@@ -0,0 +1,279 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { TwoFactorApiService } from "..";
import { ListResponse } from "../../../models/response/list.response";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { Utils } from "../../../platform/misc/utils";
import { GlobalStateProvider } from "../../../platform/state";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "../../models/request/disable-two-factor-authenticator.request";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { TwoFactorEmailRequest } from "../../models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../../models/request/two-factor-provider.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../../models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../../models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../../models/request/update-two-factor-email.request";
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../../models/request/update-two-factor-web-authn-delete.request";
import { UpdateTwoFactorWebAuthnRequest } from "../../models/request/update-two-factor-web-authn.request";
import { UpdateTwoFactorYubikeyOtpRequest } from "../../models/request/update-two-factor-yubikey-otp.request";
import { IdentityTwoFactorResponse } from "../../models/response/identity-two-factor.response";
import { TwoFactorAuthenticatorResponse } from "../../models/response/two-factor-authenticator.response";
import { TwoFactorDuoResponse } from "../../models/response/two-factor-duo.response";
import { TwoFactorEmailResponse } from "../../models/response/two-factor-email.response";
import { TwoFactorProviderResponse } from "../../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../../models/response/two-factor-recover.response";
import {
TwoFactorWebAuthnResponse,
ChallengeResponse,
} from "../../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../../models/response/two-factor-yubi-key.response";
import {
PROVIDERS,
SELECTED_PROVIDER,
TwoFactorProviderDetails,
TwoFactorProviders,
TwoFactorService as TwoFactorServiceAbstraction,
} from "../abstractions/two-factor.service";
export class DefaultTwoFactorService implements TwoFactorServiceAbstraction {
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
private twoFactorApiService: TwoFactorApiService,
) {}
init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle");
TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDescV2");
TwoFactorProviders[TwoFactorProviderType.Authenticator].name =
this.i18nService.t("authenticatorAppTitle");
TwoFactorProviders[TwoFactorProviderType.Authenticator].description =
this.i18nService.t("authenticatorAppDescV2");
TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDescV2");
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name =
"Duo (" + this.i18nService.t("organization") + ")";
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description =
this.i18nService.t("duoOrganizationDesc");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].description =
this.i18nService.t("webAuthnDesc");
TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitleV2");
TwoFactorProviders[TwoFactorProviderType.Yubikey].description =
this.i18nService.t("yubiKeyDesc");
}
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (data == null) {
return providers;
}
if (
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
return;
}
providerType = type;
providerPriority = provider.priority;
}
});
return providerType;
}
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
getEnabledTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>> {
return this.twoFactorApiService.getTwoFactorProviders();
}
getTwoFactorOrganizationProviders(
organizationId: string,
): Promise<ListResponse<TwoFactorProviderResponse>> {
return this.twoFactorApiService.getTwoFactorOrganizationProviders(organizationId);
}
getTwoFactorAuthenticator(
request: SecretVerificationRequest,
): Promise<TwoFactorAuthenticatorResponse> {
return this.twoFactorApiService.getTwoFactorAuthenticator(request);
}
getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse> {
return this.twoFactorApiService.getTwoFactorEmail(request);
}
getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.getTwoFactorDuo(request);
}
getTwoFactorOrganizationDuo(
organizationId: string,
request: SecretVerificationRequest,
): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.getTwoFactorOrganizationDuo(organizationId, request);
}
getTwoFactorYubiKey(request: SecretVerificationRequest): Promise<TwoFactorYubiKeyResponse> {
return this.twoFactorApiService.getTwoFactorYubiKey(request);
}
getTwoFactorWebAuthn(request: SecretVerificationRequest): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.getTwoFactorWebAuthn(request);
}
getTwoFactorWebAuthnChallenge(request: SecretVerificationRequest): Promise<ChallengeResponse> {
return this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
}
getTwoFactorRecover(request: SecretVerificationRequest): Promise<TwoFactorRecoverResponse> {
return this.twoFactorApiService.getTwoFactorRecover(request);
}
putTwoFactorAuthenticator(
request: UpdateTwoFactorAuthenticatorRequest,
): Promise<TwoFactorAuthenticatorResponse> {
return this.twoFactorApiService.putTwoFactorAuthenticator(request);
}
deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.deleteTwoFactorAuthenticator(request);
}
putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse> {
return this.twoFactorApiService.putTwoFactorEmail(request);
}
putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.putTwoFactorDuo(request);
}
putTwoFactorOrganizationDuo(
organizationId: string,
request: UpdateTwoFactorDuoRequest,
): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.putTwoFactorOrganizationDuo(organizationId, request);
}
putTwoFactorYubiKey(
request: UpdateTwoFactorYubikeyOtpRequest,
): Promise<TwoFactorYubiKeyResponse> {
return this.twoFactorApiService.putTwoFactorYubiKey(request);
}
putTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnRequest,
): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.putTwoFactorWebAuthn(request);
}
deleteTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnDeleteRequest,
): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.deleteTwoFactorWebAuthn(request);
}
putTwoFactorDisable(request: TwoFactorProviderRequest): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.putTwoFactorDisable(request);
}
putTwoFactorOrganizationDisable(
organizationId: string,
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.putTwoFactorOrganizationDisable(organizationId, request);
}
postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any> {
return this.twoFactorApiService.postTwoFactorEmailSetup(request);
}
postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any> {
return this.twoFactorApiService.postTwoFactorEmail(request);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./default-two-factor-api.service";
export * from "./default-two-factor.service";

View File

@@ -25,6 +25,10 @@ export abstract class BillingApiServiceAbstraction {
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNextSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;

View File

@@ -62,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNextSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/vnext/self-host/metadata",
null,
true,
true,
);
return new OrganizationBillingMetadataResponse(r);
}
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, true, true);
return new ListResponse(r, PlanResponse);

View File

@@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { newGuid } from "@bitwarden/guid";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
@@ -15,6 +16,7 @@ describe("DefaultOrganizationMetadataService", () => {
let service: DefaultOrganizationMetadataService;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let configService: jest.Mocked<ConfigService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let featureFlagSubject: BehaviorSubject<boolean>;
const mockOrganizationId = newGuid() as OrganizationId;
@@ -33,11 +35,17 @@ describe("DefaultOrganizationMetadataService", () => {
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
platformUtilsService = mock<PlatformUtilsService>();
featureFlagSubject = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
platformUtilsService.isSelfHost.mockReturnValue(false);
service = new DefaultOrganizationMetadataService(billingApiService, configService);
service = new DefaultOrganizationMetadataService(
billingApiService,
configService,
platformUtilsService,
);
});
afterEach(() => {
@@ -142,6 +150,24 @@ describe("DefaultOrganizationMetadataService", () => {
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
mockResponse,
);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
});
describe("shareReplay behavior", () => {

View File

@@ -1,6 +1,7 @@
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
@@ -17,6 +18,7 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
) {}
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
@@ -67,7 +69,9 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
return featureFlagEnabled
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
? this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}

View File

@@ -13,6 +13,7 @@ export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "block-claimed-domain-account-creation",
/* Auth */
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
@@ -91,6 +92,7 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,

View File

@@ -689,6 +689,32 @@ describe("Utils Service", () => {
});
});
describe("invalidUrlPatterns", () => {
it("should return false if no invalid patterns are found", () => {
const urlString = "https://www.example.com/api/my/account/status";
const actual = Utils.invalidUrlPatterns(urlString);
expect(actual).toBe(false);
});
it("should return true if an invalid pattern is found", () => {
const urlString = "https://www.example.com/api/%2e%2e/secret";
const actual = Utils.invalidUrlPatterns(urlString);
expect(actual).toBe(true);
});
it("should return true if an invalid pattern is found in a param", () => {
const urlString = "https://www.example.com/api/history?someToken=../secret";
const actual = Utils.invalidUrlPatterns(urlString);
expect(actual).toBe(true);
});
});
describe("getUrl", () => {
it("assumes a http protocol if no protocol is specified", () => {
const urlString = "www.exampleapp.com.au:4000";

View File

@@ -612,6 +612,55 @@ export class Utils {
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
}
/**
* Validates an url checking against invalid patterns
* @param url
* @returns true if invalid patterns found, false if safe
*/
static invalidUrlPatterns(url: string): boolean {
const invalidUrlPatterns = ["..", "%2e", "\\", "%5c"];
const decodedUrl = decodeURIComponent(url.toLocaleLowerCase());
// Check URL for invalidUrl patterns across entire URL
if (invalidUrlPatterns.some((p) => decodedUrl.includes(p))) {
return true;
}
// Check for additional invalid patterns inside URL params
if (decodedUrl.includes("?")) {
const hasInvalidParams = this.validateQueryParameters(decodedUrl);
if (hasInvalidParams) {
return true;
}
}
return false;
}
/**
* Validates query parameters for additional invalid patterns
* @param url - The URL containing query parameters
* @returns true if invalid patterns found, false if safe
*/
private static validateQueryParameters(url: string): boolean {
try {
let queryString: string;
if (url.includes("?")) {
queryString = url.split("?")[1];
} else {
return false;
}
const paramInvalidPatterns = ["/", "%2f", "#", "%23"];
return paramInvalidPatterns.some((p) => queryString.includes(p));
} catch (error) {
throw new Error(`Error validating query parameters: ${error}`);
}
}
private static isMobile(win: Window) {
let mobile = false;
((a) => {

View File

@@ -1589,8 +1589,16 @@ export class ApiService implements ApiServiceAbstraction {
);
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
// Prevent directory traversal from malicious paths
const pathParts = path.split("?");
// Check for path traversal patterns from any URL.
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
if (isInvalidUrl) {
throw new Error("The request URL contains dangerous patterns.");
}
// Prevent directory traversal from malicious paths
const requestUrl =
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");

View File

@@ -6,6 +6,9 @@ import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
abstract isCipherSearching$: Observable<boolean>;
abstract isSendSearching$: Observable<boolean>;
abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>;
abstract clearIndex(userId: UserId): Promise<void>;

View File

@@ -94,16 +94,16 @@ export class IdentityView extends ItemView implements SdkIdentityView {
this.lastName != null
) {
let name = "";
if (this.title != null) {
if (!Utils.isNullOrWhitespace(this.title)) {
name += this.title + " ";
}
if (this.firstName != null) {
if (!Utils.isNullOrWhitespace(this.firstName)) {
name += this.firstName + " ";
}
if (this.middleName != null) {
if (!Utils.isNullOrWhitespace(this.middleName)) {
name += this.middleName + " ";
}
if (this.lastName != null) {
if (!Utils.isNullOrWhitespace(this.lastName)) {
name += this.lastName;
}
return name.trim();
@@ -130,14 +130,20 @@ export class IdentityView extends ItemView implements SdkIdentityView {
}
get fullAddressPart2(): string | undefined {
if (this.city == null && this.state == null && this.postalCode == null) {
const hasCity = !Utils.isNullOrWhitespace(this.city);
const hasState = !Utils.isNullOrWhitespace(this.state);
const hasPostalCode = !Utils.isNullOrWhitespace(this.postalCode);
if (!hasCity && !hasState && !hasPostalCode) {
return undefined;
}
const city = this.city || "-";
const city = hasCity ? this.city : "-";
const state = this.state;
const postalCode = this.postalCode || "-";
const postalCode = hasPostalCode ? this.postalCode : "-";
let addressPart2 = city;
if (!Utils.isNullOrWhitespace(state)) {
if (hasState) {
addressPart2 += ", " + state;
}
addressPart2 += ", " + postalCode;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as lunr from "lunr";
import { Observable, firstValueFrom, map } from "rxjs";
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
@@ -81,6 +81,12 @@ export class SearchService implements SearchServiceAbstraction {
private readonly defaultSearchableMinLength: number = 2;
private searchableMinLength: number = this.defaultSearchableMinLength;
private _isCipherSearching$ = new BehaviorSubject<boolean>(false);
isCipherSearching$: Observable<boolean> = this._isCipherSearching$.asObservable();
private _isSendSearching$ = new BehaviorSubject<boolean>(false);
isSendSearching$: Observable<boolean> = this._isSendSearching$.asObservable();
constructor(
private logService: LogService,
private i18nService: I18nService,
@@ -223,6 +229,7 @@ export class SearchService implements SearchServiceAbstraction {
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: C[],
): Promise<C[]> {
this._isCipherSearching$.next(true);
const results: C[] = [];
const searchStartTime = performance.now();
if (query != null) {
@@ -243,6 +250,7 @@ export class SearchService implements SearchServiceAbstraction {
}
if (!(await this.isSearchable(userId, query))) {
this._isCipherSearching$.next(false);
return ciphers;
}
@@ -258,6 +266,7 @@ export class SearchService implements SearchServiceAbstraction {
// Fall back to basic search if index is not available
const basicResults = this.searchCiphersBasic(ciphers, query);
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
this._isCipherSearching$.next(false);
return basicResults;
}
@@ -293,6 +302,7 @@ export class SearchService implements SearchServiceAbstraction {
});
}
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
this._isCipherSearching$.next(false);
return results;
}
@@ -335,8 +345,10 @@ export class SearchService implements SearchServiceAbstraction {
}
searchSends(sends: SendView[], query: string) {
this._isSendSearching$.next(true);
query = SearchService.normalizeSearchQuery(query.trim().toLocaleLowerCase());
if (query === null) {
this._isSendSearching$.next(false);
return sends;
}
const sendsMatched: SendView[] = [];
@@ -359,6 +371,7 @@ export class SearchService implements SearchServiceAbstraction {
lowPriorityMatched.push(s);
}
});
this._isSendSearching$.next(false);
return sendsMatched.concat(lowPriorityMatched);
}

View File

@@ -0,0 +1,109 @@
import { BehaviorSubject } from "rxjs";
import { skeletonLoadingDelay } from "./skeleton-loading.operator";
describe("skeletonLoadingDelay", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it("returns false immediately when starting with false", () => {
const source$ = new BehaviorSubject<boolean>(false);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
expect(results).toEqual([false]);
});
it("waits 1 second before returning true when starting with true", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
expect(results).toEqual([]);
jest.advanceTimersByTime(999);
expect(results).toEqual([]);
jest.advanceTimersByTime(1);
expect(results).toEqual([true]);
});
it("cancels if source becomes false before show delay completes", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(500);
source$.next(false);
expect(results).toEqual([false]);
jest.advanceTimersByTime(1000);
expect(results).toEqual([false]);
});
it("delays hiding if minimum display time has not elapsed", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(1000);
expect(results).toEqual([true]);
source$.next(false);
expect(results).toEqual([true]);
jest.advanceTimersByTime(1000);
expect(results).toEqual([true, false]);
});
it("handles rapid true->false->true transitions", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(500);
expect(results).toEqual([]);
source$.next(false);
expect(results).toEqual([false]);
source$.next(true);
jest.advanceTimersByTime(999);
expect(results).toEqual([false]);
jest.advanceTimersByTime(1);
expect(results).toEqual([false, true]);
});
it("allows for custom timings", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay(1000, 2000)).subscribe((value) => results.push(value));
jest.advanceTimersByTime(1000);
expect(results).toEqual([true]);
source$.next(false);
jest.advanceTimersByTime(1999);
expect(results).toEqual([true]);
jest.advanceTimersByTime(1);
expect(results).toEqual([true, false]);
});
});

View File

@@ -0,0 +1,59 @@
import { defer, Observable, of, timer } from "rxjs";
import { map, switchMap, tap } from "rxjs/operators";
/**
* RxJS operator that adds skeleton loading delay behavior.
*
* - Waits 1 second before showing (prevents flashing for quick loads)
* - Ensures skeleton stays visible for at least 1 second once shown regardless of the source observable emissions
* - After the minimum display time, if the source is still true, continues to emit true until the source becomes false
* - False can only be emitted either:
* - Immediately when the source emits false before the skeleton is shown
* - After the minimum display time has passed once the skeleton is shown
*/
export function skeletonLoadingDelay(
showDelay = 1000,
minDisplayTime = 1000,
): (source: Observable<boolean>) => Observable<boolean> {
return (source: Observable<boolean>) => {
return defer(() => {
let skeletonShownAt: number | null = null;
return source.pipe(
switchMap((shouldShow): Observable<boolean> => {
if (shouldShow) {
if (skeletonShownAt !== null) {
return of(true); // Already shown, continue showing
}
// Wait for delay, then mark the skeleton as shown and emit true
return timer(showDelay).pipe(
tap(() => {
skeletonShownAt = Date.now();
}),
map(() => true),
);
} else {
if (skeletonShownAt === null) {
// Skeleton not shown yet, can emit false immediately
return of(false);
}
// Skeleton shown, ensure minimum display time has passed
const elapsedTime = Date.now() - skeletonShownAt;
const remainingTime = Math.max(0, minDisplayTime - elapsedTime);
// Wait for remaining time to ensure minimum display time
return timer(remainingTime).pipe(
tap(() => {
// Reset the shown timestamp
skeletonShownAt = null;
}),
map(() => false),
);
}
}),
);
});
};
}

View File

@@ -5,19 +5,19 @@ import { By } from "@angular/platform-browser";
import { ButtonModule } from "./index";
describe("Button", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let fixture: ComponentFixture<TestAppComponent>;
let testAppComponent: TestAppComponent;
let buttonDebugElement: DebugElement;
let disabledButtonDebugElement: DebugElement;
let linkDebugElement: DebugElement;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [TestApp],
imports: [TestAppComponent],
});
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture = TestBed.createComponent(TestAppComponent);
testAppComponent = fixture.debugElement.componentInstance;
buttonDebugElement = fixture.debugElement.query(By.css("button"));
disabledButtonDebugElement = fixture.debugElement.query(By.css("button#disabled"));
@@ -86,7 +86,7 @@ describe("Button", () => {
`,
imports: [ButtonModule],
})
class TestApp {
class TestAppComponent {
buttonType?: string;
block?: boolean;
disabled?: boolean;

View File

@@ -27,7 +27,7 @@ const mockI18nService = {
describe("ChipSelectComponent", () => {
let component: ChipSelectComponent<string>;
let fixture: ComponentFixture<TestApp>;
let fixture: ComponentFixture<TestAppComponent>;
const testOptions: ChipSelectOption<string>[] = [
{ label: "Option 1", value: "opt1", icon: "bwi-folder" },
@@ -58,11 +58,11 @@ describe("ChipSelectComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestApp, NoopAnimationsModule],
imports: [TestAppComponent, NoopAnimationsModule],
providers: [{ provide: I18nService, useValue: mockI18nService }],
}).compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture = TestBed.createComponent(TestAppComponent);
fixture.detectChanges();
component = fixture.debugElement.query(By.directive(ChipSelectComponent)).componentInstance;
@@ -468,7 +468,7 @@ describe("ChipSelectComponent", () => {
imports: [ChipSelectComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestApp {
class TestAppComponent {
readonly options = signal<ChipSelectOption<string>[]>([
{ label: "Option 1", value: "opt1", icon: "bwi-folder" },
{ label: "Option 2", value: "opt2" },

View File

@@ -20,7 +20,7 @@ import { compareValues } from "@bitwarden/common/platform/misc/compare-values";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { MenuComponent, MenuItemDirective, MenuModule, MenuTriggerForDirective } from "../menu";
import { MenuComponent, MenuItemComponent, MenuModule, MenuTriggerForDirective } from "../menu";
import { Option } from "../select/option";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
@@ -51,7 +51,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
private readonly cdr = inject(ChangeDetectorRef);
readonly menu = viewChild(MenuComponent);
readonly menuItems = viewChildren(MenuItemDirective);
readonly menuItems = viewChildren(MenuItemComponent);
readonly chipSelectButton = viewChild<ElementRef<HTMLButtonElement>>("chipSelectButton");
readonly menuTrigger = viewChild(MenuTriggerForDirective);

View File

@@ -47,7 +47,7 @@ class StoryDialogComponent {
}
openDialogNonDismissable() {
this.dialogService.open(NonDismissableContent, {
this.dialogService.open(NonDismissableContentComponent, {
data: {
animal: "panda",
},
@@ -117,7 +117,7 @@ class StoryDialogContentComponent {
`,
imports: [DialogModule, ButtonModule],
})
class NonDismissableContent {
class NonDismissableContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,

View File

@@ -33,7 +33,7 @@ class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
openSimpleDialog() {
this.dialogService.open(SimpleDialogContent, {
this.dialogService.open(SimpleDialogContentComponent, {
data: {
animal: "panda",
},
@@ -42,7 +42,7 @@ class StoryDialogComponent {
}
openNonDismissableWithPrimaryButtonDialog() {
this.dialogService.open(NonDismissableWithPrimaryButtonContent, {
this.dialogService.open(NonDismissableWithPrimaryButtonContentComponent, {
data: {
animal: "panda",
},
@@ -52,7 +52,7 @@ class StoryDialogComponent {
}
openNonDismissableWithNoButtonsDialog() {
this.dialogService.open(NonDismissableWithNoButtonsContent, {
this.dialogService.open(NonDismissableWithNoButtonsContentComponent, {
data: {
animal: "panda",
},
@@ -83,7 +83,7 @@ class StoryDialogComponent {
`,
imports: [ButtonModule, DialogModule],
})
class SimpleDialogContent {
class SimpleDialogContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
@@ -114,7 +114,7 @@ class SimpleDialogContent {
`,
imports: [ButtonModule, DialogModule],
})
class NonDismissableWithPrimaryButtonContent {
class NonDismissableWithPrimaryButtonContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
@@ -140,7 +140,7 @@ class NonDismissableWithPrimaryButtonContent {
`,
imports: [ButtonModule, DialogModule],
})
class NonDismissableWithNoButtonsContent {
class NonDismissableWithNoButtonsContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,

View File

@@ -2,10 +2,10 @@ import { NgModule } from "@angular/core";
import { FormControlComponent } from "./form-control.component";
import { BitHintComponent } from "./hint.component";
import { BitLabel } from "./label.component";
import { BitLabelComponent } from "./label.component";
@NgModule({
imports: [BitLabel, FormControlComponent, BitHintComponent],
exports: [FormControlComponent, BitLabel, BitHintComponent],
imports: [BitLabelComponent, FormControlComponent, BitHintComponent],
exports: [FormControlComponent, BitLabelComponent, BitHintComponent],
})
export class FormControlModule {}

View File

@@ -17,7 +17,7 @@ let nextId = 0;
"[id]": "id()",
},
})
export class BitLabel {
export class BitLabelComponent {
constructor(
private elementRef: ElementRef<HTMLInputElement>,
@Optional() private parentFormControl: FormControlComponent,

View File

@@ -16,7 +16,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
},
imports: [I18nPipe],
})
export class BitErrorSummary {
export class BitErrorSummaryComponent {
readonly formGroup = input<UntypedFormGroup>();
get errorCount(): number {

View File

@@ -16,7 +16,7 @@ import {
import { I18nPipe } from "@bitwarden/ui-common";
import { BitHintComponent } from "../form-control/hint.component";
import { BitLabel } from "../form-control/label.component";
import { BitLabelComponent } from "../form-control/label.component";
import { inputBorderClasses } from "../input/input.directive";
import { BitErrorComponent } from "./error.component";
@@ -32,7 +32,7 @@ import { BitFormFieldControl } from "./form-field-control";
export class BitFormFieldComponent implements AfterContentChecked {
readonly input = contentChild.required(BitFormFieldControl);
readonly hint = contentChild(BitHintComponent);
readonly label = contentChild(BitLabel);
readonly label = contentChild(BitLabelComponent);
readonly prefixContainer = viewChild<ElementRef<HTMLDivElement>>("prefixContainer");
readonly suffixContainer = viewChild<ElementRef<HTMLDivElement>>("suffixContainer");

View File

@@ -4,7 +4,7 @@ import { FormControlModule } from "../form-control";
import { InputModule } from "../input/input.module";
import { MultiSelectModule } from "../multi-select/multi-select.module";
import { BitErrorSummary } from "./error-summary.component";
import { BitErrorSummaryComponent } from "./error-summary.component";
import { BitErrorComponent } from "./error.component";
import { BitFormFieldComponent } from "./form-field.component";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
@@ -18,7 +18,7 @@ import { BitSuffixDirective } from "./suffix.directive";
MultiSelectModule,
BitErrorComponent,
BitErrorSummary,
BitErrorSummaryComponent,
BitFormFieldComponent,
BitPasswordInputToggleDirective,
BitPrefixDirective,
@@ -30,7 +30,7 @@ import { BitSuffixDirective } from "./suffix.directive";
MultiSelectModule,
BitErrorComponent,
BitErrorSummary,
BitErrorSummaryComponent,
BitFormFieldComponent,
BitPasswordInputToggleDirective,
BitPrefixDirective,

View File

@@ -25,7 +25,7 @@ const commonStyles = [
"tw-leading-none",
"tw-px-0",
"tw-py-0.5",
"tw-font-medium",
"tw-font-semibold",
"tw-bg-transparent",
"tw-border-0",
"tw-border-none",

View File

@@ -1,5 +1,5 @@
export * from "./menu.module";
export * from "./menu.component";
export * from "./menu-trigger-for.directive";
export * from "./menu-item.directive";
export * from "./menu-item.component";
export * from "./menu-divider.component";

View File

@@ -10,7 +10,7 @@ import { Component, ElementRef, HostBinding, Input } from "@angular/core";
templateUrl: "menu-item.component.html",
imports: [NgClass],
})
export class MenuItemDirective implements FocusableOption {
export class MenuItemComponent implements FocusableOption {
@HostBinding("class") classList = [
"tw-block",
"tw-w-full",

View File

@@ -7,7 +7,7 @@ import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
import { MenuModule } from "./index";
describe("Menu", () => {
let fixture: ComponentFixture<TestApp>;
let fixture: ComponentFixture<TestAppComponent>;
const getMenuTriggerDirective = () => {
const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective));
return buttonDebugElement.injector.get(MenuTriggerForDirective);
@@ -18,12 +18,12 @@ describe("Menu", () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [TestApp],
imports: [TestAppComponent],
});
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture = TestBed.createComponent(TestAppComponent);
fixture.detectChanges();
});
@@ -82,4 +82,4 @@ describe("Menu", () => {
`,
imports: [MenuModule],
})
class TestApp {}
class TestAppComponent {}

View File

@@ -10,7 +10,7 @@ import {
contentChildren,
} from "@angular/core";
import { MenuItemDirective } from "./menu-item.directive";
import { MenuItemComponent } from "./menu-item.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -25,8 +25,8 @@ export class MenuComponent implements AfterContentInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() closed = new EventEmitter<void>();
readonly menuItems = contentChildren(MenuItemDirective, { descendants: true });
keyManager?: FocusKeyManager<MenuItemDirective>;
readonly menuItems = contentChildren(MenuItemComponent, { descendants: true });
keyManager?: FocusKeyManager<MenuItemComponent>;
readonly ariaRole = input<"menu" | "dialog">("menu");

View File

@@ -1,12 +1,12 @@
import { NgModule } from "@angular/core";
import { MenuDividerComponent } from "./menu-divider.component";
import { MenuItemDirective } from "./menu-item.directive";
import { MenuItemComponent } from "./menu-item.component";
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
import { MenuComponent } from "./menu.component";
@NgModule({
imports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
exports: [MenuComponent, MenuTriggerForDirective, MenuItemDirective, MenuDividerComponent],
imports: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent],
exports: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent],
})
export class MenuModule {}

View File

@@ -11,19 +11,19 @@ import { RadioButtonComponent } from "./radio-button.component";
import { RadioButtonModule } from "./radio-button.module";
describe("RadioGroupComponent", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let fixture: ComponentFixture<TestAppComponent>;
let testAppComponent: TestAppComponent;
let buttonElements: RadioButtonComponent[];
let radioButtons: HTMLInputElement[];
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [TestApp],
imports: [TestAppComponent],
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
});
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture = TestBed.createComponent(TestAppComponent);
fixture.detectChanges();
testAppComponent = fixture.debugElement.componentInstance;
buttonElements = fixture.debugElement
@@ -76,6 +76,6 @@ describe("RadioGroupComponent", () => {
`,
imports: [FormsModule, RadioButtonModule],
})
class TestApp {
class TestAppComponent {
selected?: string;
}

View File

@@ -4,7 +4,7 @@ import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitLabel } from "../form-control/label.component";
import { BitLabelComponent } from "../form-control/label.component";
let nextId = 0;
@@ -32,7 +32,7 @@ export class RadioGroupComponent implements ControlValueAccessor {
readonly id = input(`bit-radio-group-${nextId++}`);
@HostBinding("class") classList = ["tw-block", "tw-mb-4"];
protected readonly label = contentChild(BitLabel);
protected readonly label = contentChild(BitLabelComponent);
constructor(@Optional() @Self() private ngControl?: NgControl) {
if (ngControl != null) {

View File

@@ -134,7 +134,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
</form>
`,
})
export class KitchenSinkForm {
export class KitchenSinkFormComponent {
constructor(
public dialogService: DialogService,
public formBuilder: FormBuilder,

View File

@@ -4,9 +4,9 @@ import { Component, signal } from "@angular/core";
import { DialogService } from "../../../dialog";
import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
import { KitchenSinkForm } from "./kitchen-sink-form.component";
import { KitchenSinkTable } from "./kitchen-sink-table.component";
import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
import { KitchenSinkFormComponent } from "./kitchen-sink-form.component";
import { KitchenSinkTableComponent } from "./kitchen-sink-table.component";
import { KitchenSinkToggleListComponent } from "./kitchen-sink-toggle-list.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -83,7 +83,7 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
</bit-dialog>
`,
})
class KitchenSinkDialog {
class KitchenSinkDialogComponent {
constructor(public dialogRef: DialogRef) {}
}
@@ -91,7 +91,12 @@ class KitchenSinkDialog {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-tab-main",
imports: [KitchenSinkSharedModule, KitchenSinkTable, KitchenSinkToggleList, KitchenSinkForm],
imports: [
KitchenSinkSharedModule,
KitchenSinkTableComponent,
KitchenSinkToggleListComponent,
KitchenSinkFormComponent,
],
template: `
<bit-banner bannerType="info"> Kitchen Sink test zone </bit-banner>
@@ -182,11 +187,11 @@ export class KitchenSinkMainComponent {
protected readonly drawerOpen = signal(false);
openDialog() {
this.dialogService.open(KitchenSinkDialog);
this.dialogService.open(KitchenSinkDialogComponent);
}
openDrawer() {
this.dialogService.openDrawer(KitchenSinkDialog);
this.dialogService.openDrawer(KitchenSinkDialogComponent);
}
navItems = [

View File

@@ -57,4 +57,4 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
</bit-table>
`,
})
export class KitchenSinkTable {}
export class KitchenSinkTableComponent {}

View File

@@ -28,7 +28,7 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
}
`,
})
export class KitchenSinkToggleList {
export class KitchenSinkToggleListComponent {
selectedToggle: "all" | "large" | "small" = "all";
companyList = [

View File

@@ -19,10 +19,10 @@ import { I18nMockService } from "../../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../storybook-decorators";
import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component";
import { KitchenSinkForm } from "./components/kitchen-sink-form.component";
import { KitchenSinkFormComponent } from "./components/kitchen-sink-form.component";
import { KitchenSinkMainComponent } from "./components/kitchen-sink-main.component";
import { KitchenSinkTable } from "./components/kitchen-sink-table.component";
import { KitchenSinkToggleList } from "./components/kitchen-sink-toggle-list.component";
import { KitchenSinkTableComponent } from "./components/kitchen-sink-table.component";
import { KitchenSinkToggleListComponent } from "./components/kitchen-sink-toggle-list.component";
import { KitchenSinkSharedModule } from "./kitchen-sink-shared.module";
export default {
@@ -33,10 +33,10 @@ export default {
moduleMetadata({
imports: [
KitchenSinkSharedModule,
KitchenSinkForm,
KitchenSinkFormComponent,
KitchenSinkMainComponent,
KitchenSinkTable,
KitchenSinkToggleList,
KitchenSinkTableComponent,
KitchenSinkToggleListComponent,
],
}),
applicationConfig({

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { BitLabel } from "../form-control/label.component";
import { BitLabelComponent } from "../form-control/label.component";
import { SwitchComponent } from "./switch.component";
@@ -16,7 +16,7 @@ describe("SwitchComponent", () => {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "test-host",
imports: [FormsModule, BitLabel, ReactiveFormsModule, SwitchComponent],
imports: [FormsModule, BitLabelComponent, ReactiveFormsModule, SwitchComponent],
template: `
<form [formGroup]="formObj">
<bit-switch formControlName="switch">
@@ -77,7 +77,7 @@ describe("SwitchComponent", () => {
selector: "test-selected-host",
template: `<bit-switch [selected]="checked"><bit-label>Element</bit-label></bit-switch>`,
standalone: true,
imports: [SwitchComponent, BitLabel],
imports: [SwitchComponent, BitLabelComponent],
})
class TestSelectedHostComponent {
checked = false;

View File

@@ -14,7 +14,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { AriaDisableDirective } from "../a11y";
import { FormControlModule } from "../form-control/form-control.module";
import { BitHintComponent } from "../form-control/hint.component";
import { BitLabel } from "../form-control/label.component";
import { BitLabelComponent } from "../form-control/label.component";
let nextId = 0;
@@ -43,7 +43,7 @@ let nextId = 0;
})
export class SwitchComponent implements ControlValueAccessor, AfterViewInit {
private el = inject(ElementRef<HTMLButtonElement>);
private readonly label = contentChild.required(BitLabel);
private readonly label = contentChild.required(BitLabelComponent);
/**
* Model signal for selected state binding when used outside of a form

View File

@@ -6,18 +6,18 @@ import { ToggleGroupModule } from "./toggle-group.module";
import { ToggleComponent } from "./toggle.component";
describe("Button", () => {
let fixture: ComponentFixture<TestApp>;
let testAppComponent: TestApp;
let fixture: ComponentFixture<TestAppComponent>;
let testAppComponent: TestAppComponent;
let buttonElements: ToggleComponent<unknown>[];
let radioButtons: HTMLInputElement[];
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [TestApp],
imports: [TestAppComponent],
});
await TestBed.compileComponents();
fixture = TestBed.createComponent(TestApp);
fixture = TestBed.createComponent(TestAppComponent);
testAppComponent = fixture.debugElement.componentInstance;
buttonElements = fixture.debugElement
.queryAll(By.css("bit-toggle"))
@@ -66,6 +66,6 @@ describe("Button", () => {
`,
imports: [ToggleGroupModule],
})
class TestApp {
class TestAppComponent {
selected?: string;
}

View File

@@ -27,42 +27,42 @@ const typographyProps: TypographyData[] = [
{
id: "h1",
typography: "h1",
weight: "Regular",
weight: "Medium",
size: 30,
lineHeight: "150%",
},
{
id: "h2",
typography: "h2",
weight: "Regular",
weight: "Medium",
size: 24,
lineHeight: "150%",
},
{
id: "h3",
typography: "h3",
weight: "Regular",
weight: "Medium",
size: 20,
lineHeight: "150%",
},
{
id: "h4",
typography: "h4",
weight: "Regular",
weight: "Medium",
size: 18,
lineHeight: "150%",
},
{
id: "h5",
typography: "h5",
weight: "Regular",
weight: "Medium",
size: 16,
lineHeight: "150%",
},
{
id: "h6",
typography: "h6",
weight: "Regular",
weight: "Medium",
size: 14,
lineHeight: "150%",
},

View File

@@ -1,4 +1,11 @@
import requiredUsing from "./required-using.mjs";
import noEnums from "./no-enums.mjs";
import noPageScriptUrlLeakage from "./no-page-script-url-leakage.mjs";
export default { rules: { "required-using": requiredUsing, "no-enums": noEnums } };
export default {
rules: {
"required-using": requiredUsing,
"no-enums": noEnums,
"no-page-script-url-leakage": noPageScriptUrlLeakage,
},
};

View File

@@ -0,0 +1,115 @@
/**
* @fileoverview ESLint rule to prevent page script URL leakage vulnerabilities
* @description This rule detects the specific security vulnerability where DOM script elements
* receive extension URLs through chrome.runtime.getURL() or browser.runtime.getURL() calls.
* This pattern exposes predictable extension URLs to web pages, enabling fingerprinting attacks.
*/
export const errorMessage =
"Script injection with extension URL exposes asset urls. Use secure page script registration instead.";
/**
* Checks if a node is a call to chrome.runtime.getURL() or browser.runtime.getURL()
* @param {Object} node - The AST node to check
* @returns {boolean} True if the node is an extension URL call
*/
function isExtensionURLCall(node) {
return (
node &&
node.type === "CallExpression" &&
node.callee &&
node.callee.type === "MemberExpression" &&
node.callee.object &&
node.callee.object.type === "MemberExpression" &&
node.callee.object.object &&
["chrome", "browser"].includes(node.callee.object.object.name) &&
node.callee.object.property &&
node.callee.object.property.name === "runtime" &&
node.callee.property &&
node.callee.property.name === "getURL"
);
}
/**
* Checks if a node is a call to createElement("script")
* @param {Object} node - The AST node to check
* @returns {boolean} True if the node creates a script element
*/
function isScriptCreation(node) {
return (
node &&
node.type === "CallExpression" &&
node.callee &&
node.callee.type === "MemberExpression" &&
node.callee.property &&
node.callee.property.name === "createElement" &&
node.arguments &&
node.arguments.length === 1 &&
node.arguments[0] &&
node.arguments[0].type === "Literal" &&
node.arguments[0].value === "script"
);
}
export default {
meta: {
type: "problem",
docs: {
description: "Prevent page script URL leakage through extension runtime.getURL calls",
category: "Security",
recommended: true,
},
schema: [],
messages: {
pageScriptUrlLeakage: errorMessage,
},
},
create(context) {
const scriptVariables = new Set();
return {
// Track createElement("script") calls to identify script variables
VariableDeclarator(node) {
if (node.init && isScriptCreation(node.init) && node.id && node.id.name) {
scriptVariables.add(node.id.name);
}
},
// Track assignments where script elements are created
AssignmentExpression(node) {
// Track script element creation: variable = document.createElement("script")
if (
node.operator === "=" &&
node.left &&
node.left.type === "Identifier" &&
isScriptCreation(node.right)
) {
scriptVariables.add(node.left.name);
}
// Check for script.src = extension URL pattern
if (
node.operator === "=" &&
node.left &&
node.left.type === "MemberExpression" &&
node.left.property &&
node.left.property.name === "src" &&
isExtensionURLCall(node.right)
) {
// Only flag if this is a script element assignment
if (
node.left.object &&
node.left.object.type === "Identifier" &&
scriptVariables.has(node.left.object.name)
) {
context.report({
node: node.right,
messageId: "pageScriptUrlLeakage",
});
}
}
},
};
},
};

View File

@@ -0,0 +1,151 @@
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule, { errorMessage } from "./no-page-script-url-leakage.mjs";
const ruleTester = new RuleTester({
languageOptions: {
parserOptions: {
project: [__dirname + "/../tsconfig.spec.json"],
projectService: {
allowDefaultProject: ["*.ts*"],
},
tsconfigRootDir: __dirname + "/..",
},
},
});
ruleTester.run("no-page-script-url-leakage", rule.default, {
valid: [
{
name: "Non-script element with extension URL (iframe)",
code: `
const iframe = document.createElement("iframe");
iframe.src = chrome.runtime.getURL("popup.html");
`,
},
{
name: "Non-script element with extension URL (img)",
code: `
const img = document.createElement("img");
img.src = chrome.runtime.getURL("icon.png");
`,
},
{
name: "Script element with non-extension URL",
code: `
const script = document.createElement("script");
script.src = "https://example.com/script.js";
`,
},
{
name: "Extension URL call without DOM assignment",
code: `
const url = chrome.runtime.getURL("assets/icon.png");
console.log(url);
`,
},
{
name: "Browser runtime call without DOM assignment",
code: `
const url = browser.runtime.getURL("content/style.css");
fetch(url);
`,
},
{
name: "Script assignment with variable not from createElement",
code: `
const script = getSomeScriptElement();
script.src = chrome.runtime.getURL("script.js");
`,
},
{
name: "Assignment to different property",
code: `
const script = document.createElement("script");
script.type = "text/javascript";
`,
},
],
invalid: [
{
name: "Script element with chrome.runtime.getURL - variable declaration",
code: `
const script = document.createElement("script");
script.src = chrome.runtime.getURL("content/script.js");
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Script element with browser.runtime.getURL - variable declaration",
code: `
const script = document.createElement("script");
script.src = browser.runtime.getURL("content/script.js");
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Script element with chrome.runtime.getURL - assignment expression",
code: `
let script;
script = document.createElement("script");
script.src = chrome.runtime.getURL("page-script.js");
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Script element with browser.runtime.getURL - assignment expression",
code: `
let element;
element = document.createElement("script");
element.src = browser.runtime.getURL("fido2-page-script.js");
`,
errors: [
{
message: errorMessage,
},
],
},
{
name: "Multiple script elements with different variable names",
code: `
const scriptA = document.createElement("script");
const scriptB = document.createElement("script");
scriptA.src = chrome.runtime.getURL("script-a.js");
scriptB.src = browser.runtime.getURL("script-b.js");
`,
errors: [
{
message: errorMessage,
},
{
message: errorMessage,
},
],
},
{
name: "Real-world pattern that prompted creation of this lint rule",
code: `
const script = globalThis.document.createElement("script");
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
script.async = false;
`,
errors: [
{
message: errorMessage,
},
],
},
],
});

View File

@@ -1,6 +1,3 @@
<bit-callout type="info" *ngIf="importBlockedByPolicy">
{{ "personalOwnershipPolicyInEffectImports" | i18n }}
</bit-callout>
<bit-callout
[title]="'restrictCardTypeImport' | i18n"
type="info"

View File

@@ -154,13 +154,13 @@ describe("CipherFormComponent", () => {
expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull();
});
it("clears archiveDate on updatedCipherView", async () => {
it("does not clear archiveDate on updatedCipherView", async () => {
cipherView.archivedDate = new Date();
decryptCipher.mockResolvedValue(cipherView);
await component.ngOnInit();
expect(component["updatedCipherView"]?.archivedDate).toBeNull();
expect(component["updatedCipherView"]?.archivedDate).toBe(cipherView.archivedDate);
});
});

View File

@@ -281,7 +281,6 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
if (this.config.mode === "clone") {
this.updatedCipherView.id = null;
this.updatedCipherView.archivedDate = null;
if (this.updatedCipherView.login) {
this.updatedCipherView.login.fido2Credentials = null;

View File

@@ -5,7 +5,7 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components";
import { BitIconButtonComponent, MenuItemComponent } from "@bitwarden/components";
import { CopyCipherFieldService } from "@bitwarden/vault";
import { CopyCipherFieldDirective } from "./copy-cipher-field.directive";
@@ -83,7 +83,7 @@ describe("CopyCipherFieldDirective", () => {
});
it("updates menuItemDirective disabled state", async () => {
const menuItemDirective = {
const menuItemComponent = {
disabled: false,
};
@@ -91,14 +91,14 @@ describe("CopyCipherFieldDirective", () => {
copyFieldService as unknown as CopyCipherFieldService,
mockAccountService,
mockCipherService,
menuItemDirective as unknown as MenuItemDirective,
menuItemComponent as unknown as MenuItemComponent,
);
copyCipherFieldDirective.action = "totp";
await copyCipherFieldDirective.ngOnChanges();
expect(menuItemDirective.disabled).toBe(true);
expect(menuItemComponent.disabled).toBe(true);
});
});

View File

@@ -10,7 +10,7 @@ import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components";
import { MenuItemComponent, BitIconButtonComponent } from "@bitwarden/components";
import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault";
/**
@@ -47,7 +47,7 @@ export class CopyCipherFieldDirective implements OnChanges {
private copyCipherFieldService: CopyCipherFieldService,
private accountService: AccountService,
private cipherService: CipherService,
@Optional() private menuItemDirective?: MenuItemDirective,
@Optional() private menuItemComponent?: MenuItemComponent,
@Optional() private iconButtonComponent?: BitIconButtonComponent,
) {}
@@ -60,7 +60,7 @@ export class CopyCipherFieldDirective implements OnChanges {
*/
@HostBinding("class.tw-hidden")
private get hidden() {
return this.disabled && this.menuItemDirective;
return this.disabled && this.menuItemComponent;
}
@HostListener("click")
@@ -87,8 +87,8 @@ export class CopyCipherFieldDirective implements OnChanges {
}
// If the directive is used on a menu item, update the menu item to prevent keyboard navigation
if (this.menuItemDirective) {
this.menuItemDirective.disabled = this.disabled ?? false;
if (this.menuItemComponent) {
this.menuItemComponent.disabled = this.disabled ?? false;
}
}