1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

Merge branch 'main' into km/replace-encstring-with-unsigned-shared-key

This commit is contained in:
Bernd Schoolmann
2025-12-12 18:34:10 +01:00
committed by GitHub
330 changed files with 8886 additions and 4437 deletions

View File

@@ -3,9 +3,7 @@ import { DeviceManagementComponentServiceAbstraction } from "./device-management
/**
* Default implementation of the device management component service
*/
export class DefaultDeviceManagementComponentService
implements DeviceManagementComponentServiceAbstraction
{
export class DefaultDeviceManagementComponentService implements DeviceManagementComponentServiceAbstraction {
/**
* Show header information in web client
*/

View File

@@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import {
Account,
AccountInfo,
AccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -18,6 +14,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { authGuard } from "./auth.guard";
@@ -38,16 +35,13 @@ describe("AuthGuard", () => {
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(
{
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
} as AccountInfo,
{ id: "test-id" as UserId },
),
);
activeAccountSubject.next({
id: "test-id" as UserId,
...mockAccountInfoWith({
name: "Test User 1",
email: "test@email.com",
}),
});
if (featureFlag) {
configService.getFeatureFlag.mockResolvedValue(true);

View File

@@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import {
Account,
AccountInfo,
AccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -20,6 +16,7 @@ import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -68,16 +65,13 @@ describe("lockGuard", () => {
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(
{
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
} as AccountInfo,
{ id: "test-id" as UserId },
),
);
activeAccountSubject.next({
id: "test-id" as UserId,
...mockAccountInfoWith({
name: "Test User 1",
email: "test@email.com",
}),
});
const testBed = TestBed.configureTestingModule({
imports: [

View File

@@ -7,6 +7,7 @@ import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.g
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard";
@@ -14,9 +15,10 @@ import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.
describe("redirectToVaultIfUnlockedGuard", () => {
const activeUser: Account = {
id: "userId" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
};
const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => {

View File

@@ -9,6 +9,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -17,9 +18,10 @@ import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
describe("tdeDecryptionRequiredGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
};
const setup = (

View File

@@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
@@ -18,9 +19,10 @@ import { unauthGuardFn } from "./unauth.guard";
describe("UnauthGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
}),
};
const setup = (

View File

@@ -3,9 +3,7 @@ import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval
/**
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
*/
export class DefaultLoginApprovalDialogComponentService
implements LoginApprovalDialogComponentServiceAbstraction
{
export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction {
/**
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
* @returns

View File

@@ -11,6 +11,7 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -48,10 +49,11 @@ describe("LoginApprovalDialogComponent", () => {
validationService = mock<ValidationService>();
accountService.activeAccount$ = of({
email: testEmail,
id: "test-user-id" as UserId,
emailVerified: true,
name: null,
...mockAccountInfoWith({
email: testEmail,
name: null,
}),
});
await TestBed.configureTestingModule({

View File

@@ -8,6 +8,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -26,9 +27,11 @@ describe("DefaultChangePasswordService", () => {
const user: Account = {
id: userId,
email: "email",
emailVerified: false,
name: "name",
...mockAccountInfoWith({
email: "email",
name: "name",
emailVerified: false,
}),
};
const passwordInputResult: PasswordInputResult = {

View File

@@ -14,10 +14,11 @@ import { BadgeModule } from "@bitwarden/components";
type="button"
*appNotPremium
bitBadge
variant="success"
[variant]="'primary'"
class="!tw-text-primary-600 !tw-border-primary-600"
(click)="promptForPremium($event)"
>
{{ "premium" | i18n }}
<i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
</button>
`,
imports: [BadgeModule, JslibModule],

View File

@@ -29,7 +29,7 @@ export default {
provide: I18nService,
useFactory: () => {
return new I18nMockService({
premium: "Premium",
upgrade: "Upgrade",
});
},
},

View File

@@ -1,26 +0,0 @@
<bit-callout [icon]="icon" [title]="title" [type]="$any(type)" [useAlertRole]="useAlertRole">
<div class="tw-pl-7 tw-m-0" *ngIf="enforcedPolicyOptions">
{{ enforcedPolicyMessage }}
<ul>
<li *ngIf="enforcedPolicyOptions?.minComplexity > 0">
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
</li>
<li *ngIf="enforcedPolicyOptions?.minLength > 0">
{{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireUpper">
{{ "policyInEffectUppercase" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireLower">
{{ "policyInEffectLowercase" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireNumbers">
{{ "policyInEffectNumbers" | i18n }}
</li>
<li *ngIf="enforcedPolicyOptions?.requireSpecial">
{{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }}
</li>
</ul>
</div>
<ng-content></ng-content>
</bit-callout>

View File

@@ -1,70 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CalloutTypes } from "@bitwarden/components";
/**
* @deprecated use the CL's `CalloutComponent` instead
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-callout",
templateUrl: "callout.component.html",
standalone: false,
})
export class DeprecatedCalloutComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() type: CalloutTypes = "info";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() icon: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() enforcedPolicyMessage: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() useAlertRole = false;
calloutStyle: string;
constructor(private i18nService: I18nService) {}
ngOnInit() {
this.calloutStyle = this.type;
if (this.enforcedPolicyMessage === undefined) {
this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect");
}
}
getPasswordScoreAlertDisplay() {
if (this.enforcedPolicyOptions == null) {
return "";
}
let str: string;
switch (this.enforcedPolicyOptions.minComplexity) {
case 4:
str = this.i18nService.t("strong");
break;
case 3:
str = this.i18nService.t("good");
break;
default:
str = this.i18nService.t("weak");
break;
}
return str + " (" + this.enforcedPolicyOptions.minComplexity + ")";
}
}

View File

@@ -26,7 +26,6 @@ import {
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
import { NotPremiumDirective } from "./billing/directives/not-premium.directive";
import { DeprecatedCalloutComponent } from "./components/callout.component";
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
import { ApiActionDirective } from "./directives/api-action.directive";
import { BoxRowDirective } from "./directives/box-row.directive";
@@ -86,7 +85,6 @@ import { IconComponent } from "./vault/components/icon.component";
A11yInvalidDirective,
ApiActionDirective,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,
CreditCardNumberPipe,
EllipsisPipe,
@@ -115,7 +113,6 @@ import { IconComponent } from "./vault/components/icon.component";
AutofocusDirective,
ToastModule,
BoxRowDirective,
DeprecatedCalloutComponent,
CopyTextDirective,
CreditCardNumberPipe,
EllipsisPipe,

View File

@@ -2,14 +2,13 @@ import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { FakeAccountService } from "@bitwarden/common/spec";
import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -22,17 +21,15 @@ import { PromptMigrationPasswordComponent } from "./prompt-migration-password.co
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts: Record<UserId, AccountInfo> = {
[SomeUser]: {
const accounts = {
[SomeUser]: mockAccountInfoWith({
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
}),
[AnotherUser]: mockAccountInfoWith({
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
}),
};
describe("DefaultEncryptedMigrationsSchedulerService", () => {

View File

@@ -38,16 +38,14 @@ export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
},
);
const DISMISS_TIME_HOURS = 24;
const VAULT_ROUTE = "/vault";
const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"];
/**
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
*/
export class DefaultEncryptedMigrationsSchedulerService
implements EncryptedMigrationsSchedulerService
{
export class DefaultEncryptedMigrationsSchedulerService implements EncryptedMigrationsSchedulerService {
isMigrating = false;
url$: Observable<string>;
@@ -87,7 +85,7 @@ export class DefaultEncryptedMigrationsSchedulerService
]).pipe(
filter(
([authStatus, _date, url]) =>
authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE,
authStatus === AuthenticationStatus.Unlocked && VAULT_ROUTES.includes(url),
),
concatMap(() => this.runMigrationsIfNeeded(userId)),
),

View File

@@ -184,7 +184,9 @@ import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { DefaultKeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/services/default-key-connector-api.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
@@ -950,7 +952,7 @@ const safeProviders: SafeProvider[] = [
deps: [
FolderServiceAbstraction,
CipherServiceAbstraction,
PinServiceAbstraction,
KeyGenerationService,
KeyService,
EncryptService,
CryptoFunctionServiceAbstraction,
@@ -970,7 +972,7 @@ const safeProviders: SafeProvider[] = [
deps: [
CipherServiceAbstraction,
VaultExportApiService,
PinServiceAbstraction,
KeyGenerationService,
KeyService,
EncryptService,
CryptoFunctionServiceAbstraction,
@@ -1355,16 +1357,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: PinServiceAbstraction,
useClass: PinService,
deps: [
AccountServiceAbstraction,
EncryptService,
KdfConfigService,
KeyGenerationService,
LogService,
KeyService,
SdkService,
PinStateServiceAbstraction,
],
deps: [EncryptService, LogService, KeyService, SdkService, PinStateServiceAbstraction],
}),
safeProvider({
provide: WebAuthnLoginPrfKeyServiceAbstraction,
@@ -1835,6 +1828,11 @@ const safeProviders: SafeProvider[] = [
useClass: IpcSessionRepository,
deps: [StateProvider],
}),
safeProvider({
provide: KeyConnectorApiService,
useClass: DefaultKeyConnectorApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: PremiumInterestStateService,
useClass: NoopPremiumInterestStateService,

View File

@@ -1,9 +1,5 @@
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
<div
class="tw-flex tw-justify-center tw-items-center"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
aria-hidden="true"
>
<div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
<ng-container *ngIf="data$ | async as data">
@if (data.imageEnabled && data.image) {
<img
@@ -16,7 +12,7 @@
'tw-invisible tw-absolute': !imageLoaded(),
'tw-size-6': !coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
[ngStyle]="iconStyle()"
(load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)"
/>
@@ -28,7 +24,7 @@
'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
[ngStyle]="iconStyle()"
>
<i
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
@@ -36,6 +32,7 @@
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
fontSize: size() ? size() + 'px' : null,
}"
></i>
</div>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
combineLatest,
@@ -32,8 +32,32 @@ export class IconComponent {
*/
readonly coloredIcon = input<boolean>(false);
/**
* Optional custom size for the icon in pixels.
* When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
* If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
* This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
* Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
*/
readonly size = input<number>();
readonly imageLoaded = signal(false);
/**
* Computed style object for icon dimensions.
* Centralizes the sizing logic to avoid repetition in the template.
*/
protected readonly iconStyle = computed(() => {
if (this.coloredIcon()) {
return { width: "36px", height: "36px" };
}
const size = this.size();
if (size) {
return { width: size + "px", height: size + "px" };
}
return {};
});
protected data$: Observable<CipherIconDetails>;
constructor(

View File

@@ -33,19 +33,27 @@ export class DefaultLoginComponentService implements LoginComponentService {
*/
async redirectToSsoLogin(email: string): Promise<void | null> {
// Set the state that we'll need to verify the SSO login when we get the code back
const [state, codeChallenge] = await this.setSsoPreLoginState();
// Set the email address in state. This is used in 2 places:
// 1. On the web client, on the SSO component we need the email address to look up
// the org SSO identifier. The email address is passed via query param for the other clients.
// 2. On all clients, after authentication on the originating client the SSO component
// will need to look up 2FA Remember token by email.
await this.ssoLoginService.setSsoEmail(email);
const [state, codeChallenge] = await this.setSsoPreLoginState(email);
// Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service.
await this.redirectToSso(email, state, codeChallenge);
}
/**
* Redirects the user to the SSO login page, either via route or in a new browser window.
* @param email The email address of the user attempting to log in
*/
async redirectToSsoLoginWithOrganizationSsoIdentifier(
email: string,
orgSsoIdentifier: string,
): Promise<void | null> {
// Set the state that we'll need to verify the SSO login when we get the code back
const [state, codeChallenge] = await this.setSsoPreLoginState(email);
// Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service.
await this.redirectToSso(email, state, codeChallenge, orgSsoIdentifier);
}
/**
* No-op implementation of redirectToSso
*/
@@ -53,6 +61,7 @@ export class DefaultLoginComponentService implements LoginComponentService {
email: string,
state: string,
codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> {
return;
}
@@ -65,9 +74,9 @@ export class DefaultLoginComponentService implements LoginComponentService {
}
/**
* Sets the state required for verifying SSO login after completion
* Set the state that we'll need to verify the SSO login when we get the authorization code back
*/
private async setSsoPreLoginState(): Promise<[string, string]> {
private async setSsoPreLoginState(email: string): Promise<[string, string]> {
// Generate SSO params
const passwordOptions: any = {
type: "password",
@@ -93,6 +102,13 @@ export class DefaultLoginComponentService implements LoginComponentService {
await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
// Set the email address in state. This is used in 2 places:
// 1. On the web client, on the SSO component we need the email address to look up
// the org SSO identifier. The email address is passed via query param for the other clients.
// 2. On all clients, after authentication on the originating client the SSO component
// will need to look up 2FA Remember token by email.
await this.ssoLoginService.setSsoEmail(email);
return [state, codeChallenge];
}
}

View File

@@ -35,6 +35,14 @@ export abstract class LoginComponentService {
*/
redirectToSsoLogin: (email: string) => Promise<void | null>;
/**
* Redirects the user to the SSO login page with organization SSO identifier, either via route or in a new browser window.
*/
redirectToSsoLoginWithOrganizationSsoIdentifier: (
email: string,
orgSsoIdentifier: string | null | undefined,
) => Promise<void | null>;
/**
* Shows the back button.
*/

View File

@@ -381,6 +381,24 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
// redirect to SSO if ssoOrganizationIdentifier is present in token response
if (authResult.requiresSso) {
const email = this.formGroup?.value?.email;
if (!email) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("emailRequiredForSsoLogin"),
});
return;
}
await this.loginComponentService.redirectToSsoLoginWithOrganizationSsoIdentifier(
email,
authResult.ssoOrganizationIdentifier,
);
return;
}
// User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);

View File

@@ -1,8 +1,6 @@
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
export class DefaultNewDeviceVerificationComponentService
implements NewDeviceVerificationComponentService
{
export class DefaultNewDeviceVerificationComponentService implements NewDeviceVerificationComponentService {
showBackButton() {
return true;
}

View File

@@ -1,8 +1,6 @@
import { TwoFactorAuthWebAuthnComponentService } from "./two-factor-auth-webauthn-component.service";
export class DefaultTwoFactorAuthWebAuthnComponentService
implements TwoFactorAuthWebAuthnComponentService
{
export class DefaultTwoFactorAuthWebAuthnComponentService implements TwoFactorAuthWebAuthnComponentService {
/**
* Default implementation is to not open in a new tab.
*/

View File

@@ -421,6 +421,7 @@ describe("TwoFactorAuthComponent", () => {
keyConnectorUrl:
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption!
.keyConnectorUrl,
organizationSsoIdentifier: "test-sso-id",
}),
);
const authResult = new AuthResult();

View File

@@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.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";
@@ -49,7 +50,8 @@ import { CacheData } from "../services/login-strategies/login-strategy.state";
type IdentityResponse =
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse;
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse;
export abstract class LoginStrategyData {
tokenRequest:
@@ -128,6 +130,8 @@ export abstract class LoginStrategy {
return [await this.processTokenResponse(response), response];
} else if (response instanceof IdentityDeviceVerificationResponse) {
return [await this.processDeviceVerificationResponse(response), response];
} else if (response instanceof IdentitySsoRequiredResponse) {
return [await this.processSsoRequiredResponse(response), response];
}
throw new Error("Invalid response object.");
@@ -185,6 +189,7 @@ export abstract class LoginStrategy {
name: accountInformation.name,
email: accountInformation.email ?? "",
emailVerified: accountInformation.email_verified ?? false,
creationDate: undefined, // We don't get a creation date in the token. See https://bitwarden.atlassian.net/browse/PM-29551 for consolidation plans.
});
// User env must be seeded from currently set env before switching to the account
@@ -398,4 +403,19 @@ export abstract class LoginStrategy {
result.requiresDeviceVerification = true;
return result;
}
/**
* Handles the response from the server when a SSO Authentication is required.
* It hydrates the AuthResult with the SSO organization identifier.
*
* @param {IdentitySsoRequiredResponse} response - The response from the server indicating that SSO is required.
* @returns {Promise<AuthResult>} - A promise that resolves to an AuthResult object
*/
protected async processSsoRequiredResponse(
response: IdentitySsoRequiredResponse,
): Promise<AuthResult> {
const result = new AuthResult();
result.ssoOrganizationIdentifier = response.ssoOrganizationIdentifier;
return result;
}
}

View File

@@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.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 { HashPurpose } from "@bitwarden/common/platform/enums";
@@ -165,14 +166,20 @@ export class PasswordLoginStrategy extends LoginStrategy {
identityResponse:
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse,
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse,
credentials: PasswordLoginCredentials,
authResult: AuthResult,
): Promise<void> {
// TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the
// IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse
// If the response is a device verification response, we don't need to evaluate the password
if (identityResponse instanceof IdentityDeviceVerificationResponse) {
// If SSO is required, we also do not evaluate the password here, since the user needs to first
// authenticate with their SSO IdP Provider
if (
identityResponse instanceof IdentityDeviceVerificationResponse ||
identityResponse instanceof IdentitySsoRequiredResponse
) {
return;
}

View File

@@ -8,7 +8,7 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { mockAccountServiceWith, mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -79,17 +79,21 @@ describe("DefaultLockService", () => {
);
it("locks the active account last", async () => {
await accountService.addAccount(mockUser2, {
name: "name2",
email: "email2@example.com",
emailVerified: false,
});
await accountService.addAccount(
mockUser2,
mockAccountInfoWith({
name: "name2",
email: "email2@example.com",
}),
);
await accountService.addAccount(mockUser3, {
name: "name3",
email: "name3@example.com",
emailVerified: false,
});
await accountService.addAccount(
mockUser3,
mockAccountInfoWith({
name: "name3",
email: "name3@example.com",
}),
);
const lockSpy = jest.spyOn(sut, "lock").mockResolvedValue(undefined);

View File

@@ -8,9 +8,9 @@ export class SsoUrlService {
* @param webAppUrl The URL of the web app
* @param clientType The client type that is initiating SSO, which will drive how the response is handled
* @param redirectUri The redirect URI or callback that will receive the SSO code after authentication
* @param state A state value that will be peristed through the SSO flow
* @param state A state value that will be persisted through the SSO flow
* @param codeChallenge A challenge value that will be used to verify the SSO code after authentication
* @param email The optional email adddress of the user initiating SSO, which will be used to look up the org SSO identifier
* @param email The optional email address of the user initiating SSO, which will be used to look up the org SSO identifier
* @param orgSsoIdentifier The optional SSO identifier of the org that is initiating SSO
* @returns The URL for redirecting users to the web app SSO component
*/

View File

@@ -19,9 +19,7 @@ export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition<UserDecryptionOptio
},
);
export class UserDecryptionOptionsService
implements InternalUserDecryptionOptionsServiceAbstraction
{
export class UserDecryptionOptionsService implements InternalUserDecryptionOptionsServiceAbstraction {
constructor(private singleUserStateProvider: SingleUserStateProvider) {}
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {

View File

@@ -6,19 +6,26 @@ import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid";
/**
* Creates a mock AccountInfo object with sensible defaults that can be overridden.
* Use this when you need just an AccountInfo object in tests.
*/
export function mockAccountInfoWith(info: Partial<AccountInfo> = {}): AccountInfo {
return {
name: "name",
email: "email",
emailVerified: true,
creationDate: "2024-01-01T00:00:00.000Z",
...info,
};
}
export function mockAccountServiceWith(
userId: UserId,
info: Partial<AccountInfo> = {},
activity: Record<UserId, Date> = {},
): FakeAccountService {
const fullInfo: AccountInfo = {
...info,
...{
name: "name",
email: "email",
emailVerified: true,
},
};
const fullInfo = mockAccountInfoWith(info);
const fullActivity = { [userId]: new Date(), ...activity };
@@ -104,6 +111,10 @@ export class FakeAccountService implements AccountService {
await this.mock.setAccountEmailVerified(userId, emailVerified);
}
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
await this.mock.setAccountCreationDate(userId, creationDate);
}
async switchAccount(userId: UserId): Promise<void> {
const next =
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
@@ -127,4 +138,5 @@ const loggedOutInfo: AccountInfo = {
name: undefined,
email: "",
emailVerified: false,
creationDate: undefined,
};

View File

@@ -50,6 +50,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response";
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
@@ -140,7 +141,10 @@ export abstract class ApiService {
| UserApiTokenRequest
| WebAuthnLoginTokenRequest,
): Promise<
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse
>;
abstract refreshIdentityToken(userId?: UserId): Promise<any>;

View File

@@ -11,6 +11,7 @@ export class PolicyData {
type: PolicyType;
data: Record<string, string | number | boolean>;
enabled: boolean;
revisionDate: string;
constructor(response?: PolicyResponse) {
if (response == null) {
@@ -22,6 +23,7 @@ export class PolicyData {
this.type = response.type;
this.data = response.data;
this.enabled = response.enabled;
this.revisionDate = response.revisionDate;
}
static fromPolicy(policy: Policy): PolicyData {

View File

@@ -19,6 +19,8 @@ export class Policy extends Domain {
*/
enabled: boolean;
revisionDate: Date;
constructor(obj?: PolicyData) {
super();
if (obj == null) {
@@ -30,6 +32,7 @@ export class Policy extends Domain {
this.type = obj.type;
this.data = obj.data;
this.enabled = obj.enabled;
this.revisionDate = new Date(obj.revisionDate);
}
static fromResponse(response: PolicyResponse): Policy {

View File

@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
data: any;
enabled: boolean;
canToggleState: boolean;
revisionDate: string;
constructor(response: any) {
super(response);
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}

View File

@@ -27,9 +27,7 @@ function buildKeyDefinition<T>(key: string): UserKeyDefinition<T> {
export const AUTO_CONFIRM_FINGERPRINTS = buildKeyDefinition<boolean>("autoConfirmFingerPrints");
export class DefaultOrganizationManagementPreferencesService
implements OrganizationManagementPreferencesService
{
export class DefaultOrganizationManagementPreferencesService implements OrganizationManagementPreferencesService {
constructor(private stateProvider: StateProvider) {}
autoConfirmFingerPrints = this.buildOrganizationManagementPreference(

View File

@@ -83,12 +83,15 @@ describe("PolicyService", () => {
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
revisionDate: expect.any(Date),
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
});
});
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: false,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org2",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
policyData.revisionDate = new Date().toISOString();
return policyData;
}

View File

@@ -2,14 +2,11 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
/**
* Holds information about an account for use in the AccountService
* if more information is added, be sure to update the equality method.
*/
export type AccountInfo = {
email: string;
emailVerified: boolean;
name: string | undefined;
creationDate: string | undefined;
};
export type Account = { id: UserId } & AccountInfo;
@@ -75,6 +72,12 @@ export abstract class AccountService {
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/**
* updates the `accounts$` observable with the creation date for the account.
* @param userId
* @param creationDate
*/
abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "../../../types/guid";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
@@ -18,10 +20,16 @@ export class AuthResult {
email: string;
requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean;
ssoOrganizationIdentifier?: string | null;
// The master-password used in the authentication process
masterPassword: string | null;
get requiresTwoFactor() {
return this.twoFactorProviders != null;
}
// This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach.
get requiresSso() {
return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier);
}
}

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class IdentitySsoRequiredResponse extends BaseResponse {
ssoOrganizationIdentifier: string | null;
constructor(response: any) {
super(response);
this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier");
}
}

View File

@@ -6,6 +6,7 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
import { FakeGlobalState } from "../../../spec/fake-state";
import {
FakeGlobalStateProvider,
@@ -27,7 +28,7 @@ import {
} from "./account.service";
describe("accountInfoEqual", () => {
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
const accountInfo = mockAccountInfoWith();
it("compares nulls", () => {
expect(accountInfoEqual(null, null)).toBe(true);
@@ -64,6 +65,23 @@ describe("accountInfoEqual", () => {
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares creationDate", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares undefined creationDate", () => {
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
const same = { ...accountWithoutCreationDate };
const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" };
expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
});
});
describe("accountService", () => {
@@ -76,7 +94,10 @@ describe("accountService", () => {
let activeAccountIdState: FakeGlobalState<UserId>;
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name", emailVerified: true };
const userInfo = mockAccountInfoWith({
email: "email",
name: "name",
});
beforeEach(() => {
messagingService = mock();
@@ -253,6 +274,56 @@ describe("accountService", () => {
});
});
describe("setCreationDate", () => {
const initialState = { [userId]: userInfo };
beforeEach(() => {
accountsState.stateSubject.next(initialState);
});
it("should update the account with a new creation date", async () => {
const newCreationDate = "2024-12-31T00:00:00.000Z";
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: newCreationDate },
});
});
it("should not update if the creation date is the same", async () => {
await sut.setAccountCreationDate(userId, userInfo.creationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
it("should update from undefined to a defined creation date", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
creationDate: undefined,
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
const newCreationDate = "2024-06-15T12:30:00.000Z";
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate },
});
});
it("should update to a different creation date string format", async () => {
const newCreationDate = "2023-03-15T08:45:30.123Z";
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: newCreationDate },
});
});
});
describe("setAccountVerifyNewDeviceLogin", () => {
const initialState = true;
beforeEach(() => {
@@ -294,6 +365,7 @@ describe("accountService", () => {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
},
});
});

View File

@@ -62,6 +62,7 @@ const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
};
/**
@@ -167,6 +168,10 @@ export class AccountServiceImplementation implements InternalAccountService {
await this.setAccountInfo(userId, { emailVerified });
}
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
await this.setAccountInfo(userId, { creationDate });
}
async clean(userId: UserId) {
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
await this.removeAccountActivity(userId);

View File

@@ -15,6 +15,7 @@ import {
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
@@ -48,14 +49,16 @@ describe("AuthRequestAnsweringService", () => {
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
accountService.activeAccount$ = of({
id: userId,
const accountInfo = mockAccountInfoWith({
email: "user@example.com",
emailVerified: true,
name: "User",
});
accountService.activeAccount$ = of({
id: userId,
...accountInfo,
});
accountService.accounts$ = of({
[userId]: { email: "user@example.com", emailVerified: true, name: "User" },
[userId]: accountInfo,
});
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
of(ForceSetPasswordReason.None),

View File

@@ -10,6 +10,7 @@ import {
makeStaticByteArray,
mockAccountServiceWith,
trackEmissions,
mockAccountInfoWith,
} from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -58,9 +59,10 @@ describe("AuthService", () => {
const accountInfo = {
status: AuthenticationStatus.Unlocked,
id: userId,
email: "email",
emailVerified: false,
name: "name",
...mockAccountInfoWith({
email: "email",
name: "name",
}),
};
beforeEach(() => {
@@ -112,9 +114,10 @@ describe("AuthService", () => {
const accountInfo2 = {
status: AuthenticationStatus.Unlocked,
id: Utils.newGuid() as UserId,
email: "email2",
emailVerified: false,
name: "name2",
...mockAccountInfoWith({
email: "email2",
name: "name2",
}),
};
const emissions = trackEmissions(sut.activeAccountStatus$);
@@ -131,11 +134,13 @@ describe("AuthService", () => {
it("requests auth status for all known users", async () => {
const userId2 = Utils.newGuid() as UserId;
await accountService.addAccount(userId2, {
email: "email2",
emailVerified: false,
name: "name2",
});
await accountService.addAccount(
userId2,
mockAccountInfoWith({
email: "email2",
name: "name2",
}),
);
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
sut.authStatusFor$ = mockFn;

View File

@@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { UserId } from "../../types/guid";
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
import { Account, AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
@@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
const encryptedKey = "encryptedString";
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
const user1AccountInfo: AccountInfo = {
const user1AccountInfo = mockAccountInfoWith({
name: "Test User 1",
email: "test1@email.com",
emailVerified: true,
};
});
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
keyService.userKey$.mockReturnValue(of({ key: "key" } as any));

View File

@@ -22,9 +22,7 @@ import { UserKey } from "../../types/key";
import { AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction";
export class PasswordResetEnrollmentServiceImplementation
implements PasswordResetEnrollmentServiceAbstraction
{
export class PasswordResetEnrollmentServiceImplementation implements PasswordResetEnrollmentServiceAbstraction {
constructor(
protected organizationApiService: OrganizationApiServiceAbstraction,
protected accountService: AccountService,

View File

@@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction {
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters.
// When secure storage is supported, clear the encryption key from secure storage.
// When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped.
if (this.platformSupportsSecureStorage) {
// Always clear the access token key when clearing the access token
// The next set of the access token will create a new access token key
// Always clear the access token key when clearing the access token.
// The next set of the access token will create a new access token key.
await this.clearAccessTokenKey(userId);
}
// Platform doesn't support secure storage, so use state provider implementation
// Clear tokens from disk storage (all platforms)
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, {
shouldUpdate: (previousValue) => previousValue !== null,
});
@@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction {
return null;
}
// When platformSupportsSecureStorage=true, tokens on disk are encrypted and require
// decryption keys from secure storage. When false (e.g., portable builds), tokens are
// stored on disk.
if (this.platformSupportsSecureStorage) {
let accessTokenKey: AccessTokenKey;
try {
@@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction {
) {
return TokenStorageLocation.Memory;
} else {
// Secure storage (e.g., OS credential manager) is preferred when available.
// Desktop portable builds set platformSupportsSecureStorage=false to store tokens
// on disk for portability across machines.
if (useSecureStorage && this.platformSupportsSecureStorage) {
return TokenStorageLocation.SecureStorage;
}

View File

@@ -4,9 +4,7 @@ import { PlatformUtilsService } from "../../../platform/abstractions/platform-ut
import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
export class OrganizationSponsorshipApiService
implements OrganizationSponsorshipApiServiceAbstraction
{
export class OrganizationSponsorshipApiService implements OrganizationSponsorshipApiServiceAbstraction {
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,

View File

@@ -43,6 +43,7 @@ export enum FeatureFlag {
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
/* Tools */
@@ -64,6 +65,7 @@ export enum FeatureFlag {
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -123,6 +125,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
@@ -147,6 +150,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
/* Platform */

View File

@@ -91,4 +91,12 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
return new SymmetricCryptoKey(newKey);
}
async deriveVaultExportKey(
password: string,
salt: string,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey> {
return await this.stretchKey(await this.deriveKeyFromPassword(password, salt, kdfConfig));
}
}

View File

@@ -87,4 +87,19 @@ export abstract class KeyGenerationService {
* @returns 64 byte derived key.
*/
abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>;
/**
* Derives a 64 byte key for encrypting and decrypting vault exports.
*
* @deprecated Do not use this for new use-cases.
* @param password Password to derive the key from.
* @param salt Salt for the key derivation function.
* @param kdfConfig Configuration for the key derivation function.
* @returns 64 byte derived key.
*/
abstract deriveVaultExportKey(
password: string,
salt: string,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey>;
}

View File

@@ -0,0 +1,7 @@
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
export abstract class KeyConnectorApiService {
abstract getConfirmationDetails(
orgSsoIdentifier: string,
): Promise<KeyConnectorConfirmationDetailsResponse>;
}

View File

@@ -1,3 +1,4 @@
export interface KeyConnectorDomainConfirmation {
keyConnectorUrl: string;
organizationSsoIdentifier: string;
}

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "../../../../models/response/base.response";
export class KeyConnectorConfirmationDetailsResponse extends BaseResponse {
organizationName: string;
constructor(response: any) {
super(response);
this.organizationName = this.getResponseProperty("OrganizationName");
}
}

View File

@@ -0,0 +1,54 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "../../../abstractions/api.service";
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
import { DefaultKeyConnectorApiService } from "./default-key-connector-api.service";
describe("DefaultKeyConnectorApiService", () => {
let apiService: MockProxy<ApiService>;
let sut: DefaultKeyConnectorApiService;
beforeEach(() => {
apiService = mock<ApiService>();
sut = new DefaultKeyConnectorApiService(apiService);
});
describe("getConfirmationDetails", () => {
it("encodes orgSsoIdentifier in URL", async () => {
const orgSsoIdentifier = "test org/with special@chars";
const expectedEncodedIdentifier = encodeURIComponent(orgSsoIdentifier);
const mockResponse = {};
apiService.send.mockResolvedValue(mockResponse);
await sut.getConfirmationDetails(orgSsoIdentifier);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
`/accounts/key-connector/confirmation-details/${expectedEncodedIdentifier}`,
null,
true,
true,
);
});
it("returns expected response", async () => {
const orgSsoIdentifier = "test-org";
const expectedOrgName = "example";
const mockResponse = { OrganizationName: expectedOrgName };
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getConfirmationDetails(orgSsoIdentifier);
expect(result).toBeInstanceOf(KeyConnectorConfirmationDetailsResponse);
expect(result.organizationName).toBe(expectedOrgName);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/accounts/key-connector/confirmation-details/test-org",
null,
true,
true,
);
});
});
});

View File

@@ -0,0 +1,20 @@
import { ApiService } from "../../../abstractions/api.service";
import { KeyConnectorApiService } from "../abstractions/key-connector-api.service";
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
export class DefaultKeyConnectorApiService implements KeyConnectorApiService {
constructor(private apiService: ApiService) {}
async getConfirmationDetails(
orgSsoIdentifier: string,
): Promise<KeyConnectorConfirmationDetailsResponse> {
const r = await this.apiService.send(
"GET",
"/accounts/key-connector/confirmation-details/" + encodeURIComponent(orgSsoIdentifier),
null,
true,
true,
);
return new KeyConnectorConfirmationDetailsResponse(r);
}
}

View File

@@ -603,7 +603,10 @@ describe("KeyConnectorService", () => {
const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId);
const data = await firstValueFrom(data$);
expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl });
expect(data).toEqual({
keyConnectorUrl: conversion.keyConnectorUrl,
organizationSsoIdentifier: conversion.organizationId,
});
});
it("should return observable of null value when no data is set", async () => {

View File

@@ -202,9 +202,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
}
requiresDomainConfirmation$(userId: UserId): Observable<KeyConnectorDomainConfirmation | null> {
return this.stateProvider
.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId)
.pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null)));
return this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId).pipe(
map((data) =>
data != null
? {
keyConnectorUrl: data.keyConnectorUrl,
organizationSsoIdentifier: data.organizationId,
}
: null,
),
);
}
private handleKeyConnectorError(e: any) {

View File

@@ -45,14 +45,6 @@ export abstract class PinStateServiceAbstraction {
pinLockType: PinLockType,
): Promise<PasswordProtectedKeyEnvelope | null>;
/**
* Gets the user's legacy PIN-protected UserKey
* @deprecated Use {@link getPinProtectedUserKeyEnvelope} instead. Only for migration support.
* @param userId The user's id
* @throws If the user id is not provided
*/
abstract getLegacyPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null>;
/**
* Sets the PIN state for the user
* @deprecated - This is not a public API. DO NOT USE IT

View File

@@ -13,7 +13,6 @@ import {
PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT,
PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL,
USER_KEY_ENCRYPTED_PIN,
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
} from "./pin.state";
export class PinStateService implements PinStateServiceAbstraction {
@@ -36,9 +35,7 @@ export class PinStateService implements PinStateServiceAbstraction {
assertNonNullish(userId, "userId");
const isPersistentPinSet =
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null ||
// Deprecated
(await this.getLegacyPinKeyEncryptedUserKeyPersistent(userId)) != null;
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
const isPinSet =
(await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) !=
null;
@@ -71,16 +68,6 @@ export class PinStateService implements PinStateServiceAbstraction {
}
}
async getLegacyPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null> {
assertNonNullish(userId, "userId");
return await firstValueFrom(
this.stateProvider
.getUserState$(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, userId)
.pipe(map((value) => (value ? new EncString(value) : null))),
);
}
async setPinState(
userId: UserId,
pinProtectedUserKeyEnvelope: PasswordProtectedKeyEnvelope,
@@ -116,9 +103,6 @@ export class PinStateService implements PinStateServiceAbstraction {
await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, userId);
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId);
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, null, userId);
// Note: This can be deleted after sufficiently many PINs are migrated and the state is removed.
await this.stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, userId);
}
async clearEphemeralPinState(userId: UserId): Promise<void> {

View File

@@ -13,7 +13,6 @@ import {
USER_KEY_ENCRYPTED_PIN,
PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL,
PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT,
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
} from "./pin.state";
describe("PinStateService", () => {
@@ -121,21 +120,6 @@ describe("PinStateService", () => {
expect(result).toBe("PERSISTENT");
});
it("should return 'PERSISTENT' if a legacy pin key encrypted user key (persistent) is found", async () => {
// Arrange
await stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
mockUserKeyEncryptedPin,
mockUserId,
);
// Act
const result = await sut.getPinLockType(mockUserId);
// Assert
expect(result).toBe("PERSISTENT");
});
it("should return 'EPHEMERAL' if only user key encrypted pin is found", async () => {
// Arrange
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId);
@@ -164,7 +148,6 @@ describe("PinStateService", () => {
null,
mockUserId,
);
await stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, mockUserId);
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId);
// Act
@@ -290,45 +273,6 @@ describe("PinStateService", () => {
});
});
describe("getLegacyPinKeyEncryptedUserKeyPersistent()", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test.each([null, undefined])("throws if userId is %p", async (userId) => {
// Act & Assert
await expect(() =>
sut.getLegacyPinKeyEncryptedUserKeyPersistent(userId as any),
).rejects.toThrow("userId is null or undefined.");
});
it("should return EncString when legacy key is set", async () => {
// Arrange
await stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
mockUserKeyEncryptedPin,
mockUserId,
);
// Act
const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
// Assert
expect(result?.encryptedString).toEqual(mockUserKeyEncryptedPin);
});
test.each([null, undefined])("should return null when legacy key is %p", async (value) => {
// Arrange
await stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, value, mockUserId);
// Act
const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
// Assert
expect(result).toBeNull();
});
});
describe("setPinState()", () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -464,22 +408,6 @@ describe("PinStateService", () => {
expect(result).toBeNull();
});
it("clears legacy PIN key encrypted user key persistent", async () => {
// Arrange
await stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
mockUserKeyEncryptedPin,
mockUserId,
);
// Act
await sut.clearPinState(mockUserId);
// Assert
const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
expect(result).toBeNull();
});
it("clears all PIN state when all types are set", async () => {
// Arrange - set up all possible PIN state
await sut.setPinState(
@@ -494,17 +422,11 @@ describe("PinStateService", () => {
mockUserKeyEncryptedPin,
"EPHEMERAL",
);
await stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
mockUserKeyEncryptedPin,
mockUserId,
);
// Verify all state is set before clearing
expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).not.toBeNull();
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).not.toBeNull();
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).not.toBeNull();
expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).not.toBeNull();
// Act
await sut.clearPinState(mockUserId);
@@ -513,7 +435,6 @@ describe("PinStateService", () => {
expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).toBeNull();
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).toBeNull();
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).toBeNull();
expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).toBeNull();
});
it("results in PIN lock type DISABLED after clearing", async () => {
@@ -545,7 +466,6 @@ describe("PinStateService", () => {
expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).toBeNull();
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).toBeNull();
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).toBeNull();
expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).toBeNull();
expect(await sut.getPinLockType(mockUserId)).toBe("DISABLED");
});
});
@@ -623,32 +543,6 @@ describe("PinStateService", () => {
expect(ephemeralResult).toBeNull();
});
it("does not clear legacy PIN key encrypted user key persistent", async () => {
// Arrange - set up ephemeral state and legacy state
await sut.setPinState(
mockUserId,
mockEphemeralEnvelope,
mockUserKeyEncryptedPin,
"EPHEMERAL",
);
await stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
mockUserKeyEncryptedPin,
mockUserId,
);
// Act
await sut.clearEphemeralPinState(mockUserId);
// Assert - legacy PIN should still be present
const legacyResult = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
expect(legacyResult?.encryptedString).toEqual(mockUserKeyEncryptedPin);
// Assert - ephemeral envelope should be cleared
const ephemeralResult = await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL");
expect(ephemeralResult).toBeNull();
});
it("changes PIN lock type from EPHEMERAL to DISABLED when no other PIN state exists", async () => {
// Arrange - set up only ephemeral PIN state
await sut.setPinState(

View File

@@ -1,8 +1,5 @@
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
import { UserId } from "../../types/guid";
import { PinKey, UserKey } from "../../types/key";
import { UserKey } from "../../types/key";
import { PinLockType } from "./pin-lock-type";
@@ -69,10 +66,4 @@ export abstract class PinServiceAbstraction {
* @deprecated This is not deprecated, but only meant to be called by KeyService. DO NOT USE IT.
*/
abstract userUnlocked(userId: UserId): Promise<void>;
/**
* Makes a PinKey from the provided PIN.
* @deprecated - Note: This is currently re-used by vault exports, which is still permitted but should be refactored out to use a different construct.
*/
abstract makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey>;
}

View File

@@ -1,18 +1,15 @@
import { firstValueFrom, map } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { KeyService } from "@bitwarden/key-management";
import { AccountService } from "../../auth/abstractions/account.service";
import { assertNonNullish } from "../../auth/utils";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { LogService } from "../../platform/abstractions/log.service";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../types/guid";
import { PinKey, UserKey } from "../../types/key";
import { KeyGenerationService } from "../crypto";
import { UserKey } from "../../types/key";
import { firstValueFromOrThrow } from "../utils";
import { PinLockType } from "./pin-lock-type";
@@ -21,10 +18,7 @@ import { PinServiceAbstraction } from "./pin.service.abstraction";
export class PinService implements PinServiceAbstraction {
constructor(
private accountService: AccountService,
private encryptService: EncryptService,
private kdfConfigService: KdfConfigService,
private keyGenerationService: KeyGenerationService,
private logService: LogService,
private keyService: KeyService,
private sdkService: SdkService,
@@ -56,19 +50,6 @@ export class PinService implements PinServiceAbstraction {
// On first unlock, set the ephemeral pin envelope, if it is not set yet
const pin = await this.getPin(userId);
await this.setPin(pin, "EPHEMERAL", userId);
} else if ((await this.pinStateService.getPinLockType(userId)) === "PERSISTENT") {
// Encrypted migration for persistent pin unlock to pin envelopes.
// This will be removed at the earliest in 2026.1.0
//
// ----- ENCRYPTION MIGRATION -----
// Pin-key encrypted user-keys are eagerly migrated to the new pin-protected user key envelope format.
if ((await this.pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent(userId)) != null) {
this.logService.info(
"[Pin Service] Migrating legacy PIN key to PinProtectedUserKeyEnvelope",
);
const pin = await this.getPin(userId);
await this.setPin(pin, "PERSISTENT", userId);
}
}
}
@@ -144,86 +125,30 @@ export class PinService implements PinServiceAbstraction {
assertNonNullish(pin, "pin");
assertNonNullish(userId, "userId");
const hasPinProtectedKeyEnvelopeSet =
(await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, "EPHEMERAL")) != null ||
(await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
this.logService.info("[Pin Service] Pin-unlock via PinProtectedUserKeyEnvelope");
if (hasPinProtectedKeyEnvelopeSet) {
this.logService.info("[Pin Service] Pin-unlock via PinProtectedUserKeyEnvelope");
const pinLockType = await this.pinStateService.getPinLockType(userId);
const envelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, pinLockType);
const pinLockType = await this.pinStateService.getPinLockType(userId);
const envelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(
userId,
pinLockType,
try {
// Use the sdk to create an enrollment, not yet persisting it to state
const startTime = performance.now();
const userKeyBytes = await firstValueFrom(
this.sdkService.client$.pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
return sdk.crypto().unseal_password_protected_key_envelope(pin, envelope!);
}),
),
);
this.logService.measure(startTime, "Crypto", "PinService", "UnsealPinEnvelope");
try {
// Use the sdk to create an enrollment, not yet persisting it to state
const startTime = performance.now();
const userKeyBytes = await firstValueFrom(
this.sdkService.client$.pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
return sdk.crypto().unseal_password_protected_key_envelope(pin, envelope!);
}),
),
);
this.logService.measure(startTime, "Crypto", "PinService", "UnsealPinEnvelope");
return new SymmetricCryptoKey(userKeyBytes) as UserKey;
} catch (error) {
this.logService.error(`Failed to unseal pin: ${error}`);
return null;
}
} else {
this.logService.info("[Pin Service] Pin-unlock via legacy PinKeyEncryptedUserKey");
// This branch is deprecated and will be removed in the future, but is kept for migration.
try {
const pinKeyEncryptedUserKey =
await this.pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent(userId);
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
return await this.decryptUserKey(pin, email, kdfConfig, pinKeyEncryptedUserKey!);
} catch (error) {
this.logService.error(`Error decrypting user key with pin: ${error}`);
return null;
}
return new SymmetricCryptoKey(userKeyBytes) as UserKey;
} catch (error) {
this.logService.error(`Failed to unseal pin: ${error}`);
return null;
}
}
/// Anything below here is deprecated and will be removed subsequently
async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> {
const startTime = performance.now();
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig);
this.logService.measure(startTime, "Crypto", "PinService", "makePinKey");
return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey;
}
/**
* Decrypts the UserKey with the provided PIN.
* @deprecated
* @throws If the PIN does not match the PIN that was used to encrypt the user key
* @throws If the salt, or KDF don't match the salt / KDF used to encrypt the user key
*/
private async decryptUserKey(
pin: string,
salt: string,
kdfConfig: KdfConfig,
pinKeyEncryptedUserKey: EncString,
): Promise<UserKey> {
assertNonNullish(pin, "pin");
assertNonNullish(salt, "salt");
assertNonNullish(kdfConfig, "kdfConfig");
assertNonNullish(pinKeyEncryptedUserKey, "pinKeyEncryptedUserKey");
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
const userKey = await this.encryptService.unwrapSymmetricKey(pinKeyEncryptedUserKey, pinKey);
return userKey as UserKey;
}
}

View File

@@ -2,17 +2,15 @@ import { mock } from "jest-mock-extended";
import { BehaviorSubject, filter } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { KeyService } from "@bitwarden/key-management";
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
import { MockSdkService } from "../..//platform/spec/mock-sdk.service";
import { FakeAccountService, mockAccountServiceWith, mockEnc } from "../../../spec";
import { LogService } from "../../platform/abstractions/log.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../types/guid";
import { PinKey, UserKey } from "../../types/key";
import { KeyGenerationService } from "../crypto";
import { UserKey } from "../../types/key";
import { EncryptService } from "../crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../crypto/models/enc-string";
@@ -22,16 +20,10 @@ import { PinService } from "./pin.service.implementation";
describe("PinService", () => {
let sut: PinService;
let accountService: FakeAccountService;
const encryptService = mock<EncryptService>();
const kdfConfigService = mock<KdfConfigService>();
const keyGenerationService = mock<KeyGenerationService>();
const logService = mock<LogService>();
const mockUserId = Utils.newGuid() as UserId;
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey;
const mockUserEmail = "user@example.com";
const mockPin = "1234";
const mockUserKeyEncryptedPin = new EncString("userKeyEncryptedPin");
const mockEphemeralEnvelope = "mock-ephemeral-envelope" as PasswordProtectedKeyEnvelope;
@@ -42,7 +34,6 @@ describe("PinService", () => {
const behaviorSubject = new BehaviorSubject<{ userId: UserId; userKey: UserKey }>(null);
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
(keyService as any)["unlockedUserKeys$"] = behaviorSubject
.asObservable()
.pipe(filter((x) => x != null));
@@ -50,16 +41,7 @@ describe("PinService", () => {
.mockDeep()
.unseal_password_protected_key_envelope.mockReturnValue(new Uint8Array(64));
sut = new PinService(
accountService,
encryptService,
kdfConfigService,
keyGenerationService,
logService,
keyService,
sdkService,
pinStateService,
);
sut = new PinService(encryptService, logService, keyService, sdkService, pinStateService);
});
it("should instantiate the PinService", () => {
@@ -89,26 +71,6 @@ describe("PinService", () => {
);
});
it("should migrate legacy persistent PIN if needed", async () => {
// Arrange
pinStateService.getPinLockType.mockResolvedValue("PERSISTENT");
pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockResolvedValue(
mockEnc("legacy-key"),
);
const getPinSpy = jest.spyOn(sut, "getPin").mockResolvedValue(mockPin);
const setPinSpy = jest.spyOn(sut, "setPin").mockResolvedValue();
// Act
await sut.userUnlocked(mockUserId);
// Assert
expect(getPinSpy).toHaveBeenCalledWith(mockUserId);
expect(setPinSpy).toHaveBeenCalledWith(mockPin, "PERSISTENT", mockUserId);
expect(logService.info).toHaveBeenCalledWith(
"[Pin Service] Migrating legacy PIN key to PinProtectedUserKeyEnvelope",
);
});
it("should do nothing if no migration or setup is needed", async () => {
// Arrange
pinStateService.getPinLockType.mockResolvedValue("DISABLED");
@@ -124,28 +86,6 @@ describe("PinService", () => {
});
});
describe("makePinKey()", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should make a PinKey", async () => {
// Arrange
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(mockPinKey);
// Act
await sut.makePinKey(mockPin, mockUserEmail, DEFAULT_KDF_CONFIG);
// Assert
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
mockPin,
mockUserEmail,
DEFAULT_KDF_CONFIG,
);
expect(keyGenerationService.stretchKey).toHaveBeenCalledWith(mockPinKey);
});
});
describe("getPin()", () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -383,7 +323,6 @@ describe("PinService", () => {
jest.clearAllMocks();
pinStateService.userKeyEncryptedPin$.mockReset();
pinStateService.getPinProtectedUserKeyEnvelope.mockReset();
pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockReset();
});
it("should throw an error if userId is null", async () => {
@@ -423,32 +362,5 @@ describe("PinService", () => {
// Assert
expect(result).toEqual(mockUserKey);
});
it("should return userkey with legacy pin PERSISTENT", async () => {
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(mockPinKey);
keyGenerationService.stretchKey.mockResolvedValue(mockPinKey);
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
encryptService.unwrapSymmetricKey.mockResolvedValue(mockUserKey);
// Arrange
const mockPin = "1234";
pinStateService.userKeyEncryptedPin$.mockReturnValueOnce(
new BehaviorSubject(mockUserKeyEncryptedPin),
);
pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockResolvedValueOnce(
mockUserKeyEncryptedPin,
);
// Act
const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId);
// Assert
expect(result).toEqual(mockUserKey);
});
});
});
// Test helpers
function randomBytes(length: number): Uint8Array {
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
}

View File

@@ -3,22 +3,6 @@ import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
import { EncryptedString } from "../crypto/models/enc-string";
/**
* The persistent (stored on disk) version of the UserKey, encrypted by the PinKey.
*
* @deprecated
* @remarks Persists through a client reset. Used when `requireMasterPasswordOnClientRestart` is disabled.
* @see SetPinComponent.setPinForm.requireMasterPasswordOnClientRestart
*/
export const PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT = new UserKeyDefinition<EncryptedString>(
PIN_DISK,
"pinKeyEncryptedUserKeyPersistent",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
/**
* The persistent (stored on disk) version of the UserKey, stored in a `PasswordProtectedKeyEnvelope`.
*

View File

@@ -56,7 +56,7 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
return;
}
// If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock.
// If there is an active user, check if they have an ephemeral PIN. If so, prevent process reload upon lock.
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (userId != null) {
if ((await this.pinService.getPinLockType(userId)) === "EPHEMERAL") {

View File

@@ -7,7 +7,7 @@ import { BehaviorSubject, from, of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { FakeAccountService, mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -109,19 +109,19 @@ describe("VaultTimeoutService", () => {
if (globalSetups?.userId) {
accountService.activeAccountSubject.next({
id: globalSetups.userId as UserId,
email: null,
emailVerified: false,
name: null,
...mockAccountInfoWith({
email: null,
name: null,
}),
});
}
accountService.accounts$ = of(
Object.entries(accounts).reduce(
(agg, [id]) => {
agg[id] = {
agg[id] = mockAccountInfoWith({
email: "",
emailVerified: true,
name: "",
};
});
return agg;
},
{} as Record<string, AccountInfo>,

View File

@@ -5,8 +5,10 @@ export interface IpcMessage {
message: SerializedOutgoingMessage;
}
export interface SerializedOutgoingMessage
extends Omit<OutgoingMessage, typeof Symbol.dispose | "free" | "payload"> {
export interface SerializedOutgoingMessage extends Omit<
OutgoingMessage,
typeof Symbol.dispose | "free" | "payload"
> {
payload: number[];
}

View File

@@ -7,6 +7,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { mockAccountInfoWith } from "../../../../spec";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -163,9 +164,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
} else {
activeUserAccount$.next({
id: userId,
email: "email",
name: "Test Name",
emailVerified: true,
...mockAccountInfoWith({
email: "email",
name: "Test Name",
}),
});
}
}
@@ -174,7 +176,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
const currentAccounts = (userAccounts$.getValue() as Record<string, any>) ?? {};
userAccounts$.next({
...currentAccounts,
[userId]: { email: "email", name: "Test Name", emailVerified: true },
[userId]: mockAccountInfoWith({
email: "email",
name: "Test Name",
}),
} as any);
}

View File

@@ -8,7 +8,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { awaitAsync } from "../../../../spec";
import { awaitAsync, mockAccountInfoWith } from "../../../../spec";
import { Matrix } from "../../../../spec/matrix";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
@@ -139,11 +139,18 @@ describe("NotificationsService", () => {
activeAccount.next(null);
accounts.next({} as any);
} else {
activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true });
const accountInfo = mockAccountInfoWith({
email: "email",
name: "Test Name",
});
activeAccount.next({
id: userId,
...accountInfo,
});
const current = (accounts.getValue() as Record<string, any>) ?? {};
accounts.next({
...current,
[userId]: { email: "email", name: "Test Name", emailVerified: true },
[userId]: accountInfo,
} as any);
}
}
@@ -349,7 +356,13 @@ describe("NotificationsService", () => {
describe("processNotification", () => {
beforeEach(async () => {
appIdService.getAppId.mockResolvedValue("test-app-id");
activeAccount.next({ id: mockUser1, email: "email", name: "Test Name", emailVerified: true });
activeAccount.next({
id: mockUser1,
...mockAccountInfoWith({
email: "email",
name: "Test Name",
}),
});
});
describe("NotificationType.LogOut", () => {

View File

@@ -1,6 +1,6 @@
import { firstValueFrom } from "rxjs";
import { FakeStateProvider, awaitAsync } from "../../../spec";
import { FakeStateProvider, awaitAsync, mockAccountInfoWith } from "../../../spec";
import { FakeAccountService } from "../../../spec/fake-account-service";
import { UserId } from "../../types/guid";
import { CloudRegion, Region } from "../abstractions/environment.service";
@@ -28,16 +28,14 @@ describe("EnvironmentService", () => {
beforeEach(async () => {
accountService = new FakeAccountService({
[testUser]: {
[testUser]: mockAccountInfoWith({
name: "name",
email: "email",
emailVerified: false,
},
[alternateTestUser]: {
}),
[alternateTestUser]: mockAccountInfoWith({
name: "name",
email: "email",
emailVerified: false,
},
}),
});
stateProvider = new FakeStateProvider(accountService);
@@ -47,9 +45,10 @@ describe("EnvironmentService", () => {
const switchUser = async (userId: UserId) => {
accountService.activeAccountSubject.next({
id: userId,
email: "test@example.com",
name: `Test Name ${userId}`,
emailVerified: false,
...mockAccountInfoWith({
email: "test@example.com",
name: `Test Name ${userId}`,
}),
});
await awaitAsync();
};

View File

@@ -3,7 +3,7 @@ import { TextEncoder } from "util";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { mockAccountServiceWith } from "../../../../spec";
import { mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec";
import { Account } from "../../../auth/abstractions/account.service";
import { CipherId, UserId } from "../../../types/guid";
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
@@ -40,9 +40,10 @@ describe("FidoAuthenticatorService", () => {
const userId = "testId" as UserId;
const activeAccountSubject = new BehaviorSubject<Account | null>({
id: userId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@example.com",
name: "Test User",
}),
});
let cipherService!: MockProxy<CipherService>;

View File

@@ -46,9 +46,9 @@ const KeyUsages: KeyUsage[] = ["sign"];
*
* It is highly recommended that the W3C specification is used a reference when reading this code.
*/
export class Fido2AuthenticatorService<ParentWindowReference>
implements Fido2AuthenticatorServiceAbstraction<ParentWindowReference>
{
export class Fido2AuthenticatorService<
ParentWindowReference,
> implements Fido2AuthenticatorServiceAbstraction<ParentWindowReference> {
constructor(
private cipherService: CipherService,
private userInterface: Fido2UserInterfaceService<ParentWindowReference>,

View File

@@ -47,9 +47,9 @@ import { guidToRawFormat } from "./guid-utils";
*
* It is highly recommended that the W3C specification is used a reference when reading this code.
*/
export class Fido2ClientService<ParentWindowReference>
implements Fido2ClientServiceAbstraction<ParentWindowReference>
{
export class Fido2ClientService<
ParentWindowReference,
> implements Fido2ClientServiceAbstraction<ParentWindowReference> {
private timeoutAbortController: AbortController;
private readonly TIMEOUTS = {
NO_VERIFICATION: {

View File

@@ -12,9 +12,9 @@ import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
mockAccountInfoWith,
} from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
@@ -92,7 +92,10 @@ describe("DefaultSdkService", () => {
.calledWith(userId)
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
accountService.accounts$ = of({
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
[userId]: mockAccountInfoWith({
email: "email",
name: "name",
}),
});
kdfConfigService.getKdfConfig$
.calledWith(userId)

View File

@@ -8,9 +8,9 @@ import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
mockAccountInfoWith,
} from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { ConfigService } from "../../abstractions/config/config.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
@@ -76,7 +76,10 @@ describe("DefaultRegisterSdkService", () => {
.calledWith(userId)
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
accountService.accounts$ = of({
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
[userId]: mockAccountInfoWith({
email: "email",
name: "name",
}),
});
});
@@ -125,7 +128,10 @@ describe("DefaultRegisterSdkService", () => {
it("destroys the internal SDK client when the account is removed (logout)", async () => {
const accounts$ = new BehaviorSubject({
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
[userId]: mockAccountInfoWith({
email: "email",
name: "name",
}),
});
accountService.accounts$ = accounts$;

View File

@@ -272,6 +272,7 @@ export class DefaultSyncService extends CoreSyncService {
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices);
await this.accountService.setAccountCreationDate(response.id, response.creationDate);
await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally,

View File

@@ -6,6 +6,7 @@ import { ObservedValueOf, of } from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/user-core";
import { mockAccountInfoWith } from "../../spec";
import { AccountService } from "../auth/abstractions/account.service";
import { TokenService } from "../auth/abstractions/token.service";
import { DeviceType } from "../enums";
@@ -55,9 +56,10 @@ describe("ApiService", () => {
accountService.activeAccount$ = of({
id: testActiveUser,
email: "user1@example.com",
emailVerified: true,
name: "Test Name",
...mockAccountInfoWith({
email: "user1@example.com",
name: "Test Name",
}),
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
httpOperations = mock();

View File

@@ -63,6 +63,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response";
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
@@ -165,7 +166,10 @@ export class ApiService implements ApiServiceAbstraction {
| SsoTokenRequest
| WebAuthnLoginTokenRequest,
): Promise<
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse
> {
const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
@@ -212,6 +216,8 @@ export class ApiService implements ApiServiceAbstraction {
responseJson?.ErrorModel?.Message === ApiService.NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE
) {
return new IdentityDeviceVerificationResponse(responseJson);
} else if (response.status === 400 && responseJson?.SsoOrganizationIdentifier) {
return new IdentitySsoRequiredResponse(responseJson);
}
}

View File

@@ -1,7 +1,12 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec";
import {
FakeAccountService,
FakeStateProvider,
awaitAsync,
mockAccountInfoWith,
} from "../../../spec";
import { Account } from "../../auth/abstractions/account.service";
import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../types/guid";
@@ -21,9 +26,10 @@ import { SimpleLogin } from "./vendor/simplelogin";
const SomeUser = "some user" as UserId;
const SomeAccount = {
id: SomeUser,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
...mockAccountInfoWith({
email: "someone@example.com",
name: "Someone",
}),
};
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);

View File

@@ -11,6 +11,7 @@ import {
FakeStateProvider,
awaitAsync,
mockAccountServiceWith,
mockAccountInfoWith,
} from "../../../../spec";
import { KeyGenerationService } from "../../../key-management/crypto";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
@@ -71,9 +72,10 @@ describe("SendService", () => {
accountService.activeAccountSubject.next({
id: mockUserId,
email: "email",
emailVerified: false,
name: "name",
...mockAccountInfoWith({
email: "email",
name: "name",
}),
});
// Initial encrypted state

View File

@@ -14,9 +14,11 @@ import { Classifier } from "./classifier";
* Data that cannot be serialized by JSON.stringify() should
* be excluded.
*/
export class SecretClassifier<Plaintext extends object, Disclosed, Secret>
implements Classifier<Plaintext, Disclosed, Secret>
{
export class SecretClassifier<Plaintext extends object, Disclosed, Secret> implements Classifier<
Plaintext,
Disclosed,
Secret
> {
private constructor(
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
excluded: readonly (keyof Plaintext)[],

View File

@@ -25,9 +25,13 @@ const ONE_MINUTE = 1000 * 60;
*
* DO NOT USE THIS for synchronized data.
*/
export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
implements SingleUserState<Outer>
{
export class SecretState<
Outer,
Id,
Plaintext extends object,
Disclosed,
Secret,
> implements SingleUserState<Outer> {
// The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together.
private constructor(

View File

@@ -6,6 +6,7 @@ import {
awaitAsync,
FakeAccountService,
FakeStateProvider,
mockAccountInfoWith,
ObservableTracker,
} from "../../../spec";
import { Account } from "../../auth/abstractions/account.service";
@@ -23,17 +24,19 @@ import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId;
const SomeAccount = {
id: SomeUser,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
...mockAccountInfoWith({
email: "someone@example.com",
name: "Someone",
}),
};
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
const SomeOtherAccount = {
id: "some other user" as UserId,
email: "someone@example.com",
emailVerified: true,
name: "Someone",
...mockAccountInfoWith({
email: "someone@example.com",
name: "Someone",
}),
};
type TestType = { foo: string };

View File

@@ -79,11 +79,11 @@ const DEFAULT_FRAME_SIZE = 32;
* @template Dependencies use-specific dependencies provided by the user.
*/
export class UserStateSubject<
State extends object,
Secret = State,
Disclosed = Record<string, never>,
Dependencies = null,
>
State extends object,
Secret = State,
Disclosed = Record<string, never>,
Dependencies = null,
>
extends Observable<State>
implements SubjectLike<State>
{

View File

@@ -9,8 +9,6 @@ export type PrfKey = Opaque<SymmetricCryptoKey, "PrfKey">;
export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">;
/** @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. */
export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">;
/** @deprecated */
export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;
export type OrgKey = Opaque<SymmetricCryptoKey, "OrgKey">;
export type ProviderKey = Opaque<SymmetricCryptoKey, "ProviderKey">;
export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;

View File

@@ -13,18 +13,19 @@ describe("buildCipherIcon", () => {
},
} as any as CipherView;
it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => {
setUri("androidapp://test.example");
// @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028
// it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => {
// setUri("androidapp://test.example");
const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
// const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
expect(iconDetails).toEqual({
icon: "bwi-android",
image: null,
fallbackImage: "",
imageEnabled: showFavicon,
});
});
// expect(iconDetails).toEqual({
// icon: "bwi-android",
// image: null,
// fallbackImage: "",
// imageEnabled: showFavicon,
// });
// });
it("does not mark as an android app if the protocol is not androidapp", () => {
// This weird URI points to test.androidapp with a default port and path of /.example
@@ -40,18 +41,18 @@ describe("buildCipherIcon", () => {
});
});
it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => {
setUri("iosapp://test.example");
// @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028
// it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => {
// setUri("iosapp://test.example");
const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
expect(iconDetails).toEqual({
icon: "bwi-apple",
image: null,
fallbackImage: "",
imageEnabled: showFavicon,
});
});
// const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
// expect(iconDetails).toEqual({
// icon: "bwi-apple",
// image: null,
// fallbackImage: "",
// imageEnabled: showFavicon,
// });
// });
it("does not mark as an ios app if the protocol is not iosapp", () => {
// This weird URI points to test.iosapp with a default port and path of /.example

View File

@@ -49,10 +49,12 @@ export function buildCipherIcon(
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
icon = "bwi-android";
// @TODO Re-add once we have Android icon https://bitwarden.atlassian.net/browse/PM-29028
// icon = "bwi-android";
image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
icon = "bwi-apple";
// @TODO Re-add once we have iOS icon https://bitwarden.atlassian.net/browse/PM-29028
// icon = "bwi-apple";
image = null;
} else if (
showFavicon &&

View File

@@ -155,7 +155,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.login != null) {
model.login = await this.login.decrypt(
bypassValidation,
userKeyOrOrgKey,
cipherDecryptionKey,
`Cipher Id: ${this.id}`,
);
}
@@ -167,17 +167,20 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
break;
case CipherType.Card:
if (this.card != null) {
model.card = await this.card.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
model.card = await this.card.decrypt(cipherDecryptionKey, `Cipher Id: ${this.id}`);
}
break;
case CipherType.Identity:
if (this.identity != null) {
model.identity = await this.identity.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
model.identity = await this.identity.decrypt(
cipherDecryptionKey,
`Cipher Id: ${this.id}`,
);
}
break;
case CipherType.SshKey:
if (this.sshKey != null) {
model.sshKey = await this.sshKey.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
model.sshKey = await this.sshKey.decrypt(cipherDecryptionKey, `Cipher Id: ${this.id}`);
}
break;
default:
@@ -188,7 +191,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
const attachments: AttachmentView[] = [];
for (const attachment of this.attachments) {
const decryptedAttachment = await attachment.decrypt(
userKeyOrOrgKey,
cipherDecryptionKey,
`Cipher Id: ${this.id}`,
);
attachments.push(decryptedAttachment);
@@ -199,7 +202,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.fields != null && this.fields.length > 0) {
const fields: FieldView[] = [];
for (const field of this.fields) {
const decryptedField = await field.decrypt(userKeyOrOrgKey);
const decryptedField = await field.decrypt(cipherDecryptionKey);
fields.push(decryptedField);
}
model.fields = fields;
@@ -208,7 +211,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.passwordHistory != null && this.passwordHistory.length > 0) {
const passwordHistory: PasswordHistoryView[] = [];
for (const ph of this.passwordHistory) {
const decryptedPh = await ph.decrypt(userKeyOrOrgKey);
const decryptedPh = await ph.decrypt(cipherDecryptionKey);
passwordHistory.push(decryptedPh);
}
model.passwordHistory = passwordHistory;

View File

@@ -7,6 +7,7 @@
[hideCardWrapper]="hideCardWrapper"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<router-outlet slot="header-actions" name="header-actions"></router-outlet>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>

View File

@@ -130,6 +130,15 @@ export class DefaultSecondaryOutletExampleComponent {}
})
export class DefaultEnvSelectorOutletExampleComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-header-actions-outlet-example-component",
template: "<p>Header Actions Outlet Example: <br> your header actions component goes here</p>",
standalone: false,
})
export class DefaultHeaderActionsOutletExampleComponent {}
export const DefaultContentExample: Story = {
render: (args) => ({
props: args,
@@ -171,6 +180,11 @@ export const DefaultContentExample: Story = {
component: DefaultEnvSelectorOutletExampleComponent,
outlet: "environment-selector",
},
{
path: "",
component: DefaultHeaderActionsOutletExampleComponent,
outlet: "header-actions",
},
],
},
],

View File

@@ -5,13 +5,19 @@
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<a
*ngIf="!hideLogo()"
[routerLink]="['/']"
class="tw-w-[200px] tw-block tw-mb-12 [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
<div class="tw-flex tw-justify-between tw-items-center tw-w-full tw-mb-12">
@if (!hideLogo()) {
<a
[routerLink]="['/']"
class="tw-w-32 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
}
<div class="tw-ms-auto">
<ng-content select="[slot=header-actions]"></ng-content>
</div>
</div>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
@let iconInput = icon();
@@ -25,7 +31,7 @@
<bit-icon [icon]="iconInput"></bit-icon>
</div>
<ng-container *ngIf="title()">
@if (title()) {
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
@@ -34,9 +40,11 @@
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
</ng-container>
}
<div *ngIf="subtitle()" class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
@if (subtitle()) {
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
}
</div>
<div
@@ -57,18 +65,20 @@
<ng-content select="[slot=secondary]"></ng-content>
</div>
<footer *ngIf="!hideFooter()" class="tw-text-center tw-mt-4 sm:tw-mt-6">
<div *ngIf="showReadonlyHostname()" bitTypography="body2">
{{ "accessing" | i18n }} {{ hostname }}
</div>
<ng-container *ngIf="!showReadonlyHostname()">
<ng-content select="[slot=environment-selector]"></ng-content>
</ng-container>
<ng-container *ngIf="!hideYearAndVersion">
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
</ng-container>
</footer>
@if (!hideFooter()) {
<footer class="tw-text-center tw-mt-4 sm:tw-mt-6">
@if (showReadonlyHostname()) {
<div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div>
} @else {
<ng-content select="[slot=environment-selector]"></ng-content>
}
@if (!hideYearAndVersion) {
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
}
</footer>
}
@if (!hideBackgroundIllustration()) {
<div

View File

@@ -8,6 +8,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AvatarModule } from "../avatar";
import { ButtonModule } from "../button";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -23,6 +24,7 @@ type StoryArgs = AnonLayoutComponent & {
showSecondary: boolean;
useDefaultIcon: boolean;
icon: Icon;
includeHeaderActions: boolean;
};
export default {
@@ -30,7 +32,7 @@ export default {
component: AnonLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, RouterModule],
imports: [ButtonModule, RouterModule, AvatarModule],
providers: [
{
provide: PlatformUtilsService,
@@ -76,6 +78,14 @@ export default {
[hideFooter]="hideFooter"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
@if (includeHeaderActions) {
<div slot="header-actions" class="tw-flex tw-items-center tw-gap-2">
<bit-avatar
size="small"
text="Bob Loblaw"
></bit-avatar>
</div>
}
<ng-container [ngSwitch]="contentLength">
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-medium">Thin Content</div></div>
<div *ngSwitchCase="'long'">
@@ -116,7 +126,7 @@ export default {
hideLogo: { control: "boolean" },
hideFooter: { control: "boolean" },
hideBackgroundIllustration: { control: "boolean" },
includeHeaderActions: { control: "boolean" },
contentLength: {
control: "radio",
options: ["normal", "long", "thin"],
@@ -138,6 +148,7 @@ export default {
hideBackgroundIllustration: false,
contentLength: "normal",
showSecondary: false,
includeHeaderActions: false,
},
} satisfies Meta<StoryArgs>;
@@ -188,6 +199,12 @@ export const SecondaryContent: Story = {
},
};
export const WithHeaderActions: Story = {
args: {
includeHeaderActions: true,
},
};
export const NoTitle: Story = { args: { title: undefined } };
export const NoSubtitle: Story = { args: { subtitle: undefined } };

View File

@@ -451,6 +451,24 @@ describe("ChipSelectComponent", () => {
expect(disabledMenuItem?.disabled).toBe(true);
});
it("should handle writeValue called before options are initialized", async () => {
const testApp = fixture.componentInstance;
component["rootTree"] = null;
component.writeValue("opt1");
expect(component["pendingValue"]).toBe("opt1");
expect(component["selectedOption"]).toBeUndefined();
testApp.options.set(testOptions);
fixture.detectChanges();
await fixture.whenStable();
expect(component["selectedOption"]?.value).toBe("opt1");
expect(component["pendingValue"]).toBeUndefined();
});
});
});

View File

@@ -100,10 +100,21 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Tree constructed from `this.options` */
private rootTree?: ChipSelectOption<T> | null;
/** Store the pending value when writeValue is called before options are initialized */
private pendingValue?: T;
constructor() {
// Initialize the root tree whenever options change
effect(() => {
this.initializeRootTree(this.options());
// If there's a pending value, apply it now that options are available
if (this.pendingValue !== undefined) {
this.selectedOption = this.findOption(this.rootTree, this.pendingValue);
this.setOrResetRenderedOptions();
this.pendingValue = undefined;
this.cdr.markForCheck();
}
});
// Focus the first menu item when menuItems change (e.g., navigating submenus)
@@ -255,6 +266,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Implemented as part of NG_VALUE_ACCESSOR */
writeValue(obj: T): void {
// If rootTree is not yet initialized, store the value to apply it later
if (!this.rootTree) {
this.pendingValue = obj;
return;
}
this.selectedOption = this.findOption(this.rootTree, obj);
this.setOrResetRenderedOptions();
// OnPush components require manual change detection when writeValue() is called

View File

@@ -43,9 +43,10 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
detach() {}
}
export abstract class DialogRef<R = unknown, C = unknown>
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
{
export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
CdkDialogRef<R, C>,
"close" | "closed" | "disableClose" | "componentInstance"
> {
abstract readonly isDrawer?: boolean;
// --- From CdkDialogRef ---

View File

@@ -83,7 +83,7 @@
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
</label>
<div
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid"
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid tw-items-center"
[ngClass]="{
'tw-border-secondary-300/50 tw-border-b tw-pb-[2px]': !disableReadOnlyBorder,
'tw-border-transparent tw-pb-[3px]': disableReadOnlyBorder,

View File

@@ -84,6 +84,11 @@ export const Default: StoryObj<NavGroupComponent> = {
<bit-nav-item text="Child B"></bit-nav-item>
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
<bit-nav-group open="true" text="Lorem Ipsum (Button)" icon="bwi-filter">
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
<bit-nav-item text="Child B"></bit-nav-item>
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
</bit-nav-group>
</bit-side-nav>
`,
}),

View File

@@ -2,16 +2,12 @@
@let open = sideNavService.open$ | async;
@if (open || icon()) {
<div
[ngStyle]="{
'padding-inline-start': navItemIndentationPadding(),
}"
[style.padding-inline-start]="navItemIndentationPadding()"
class="tw-relative tw-rounded-md tw-h-10"
[ngClass]="[
showActiveStyles
? 'tw-bg-background-alt4'
: 'tw-bg-background-alt3 hover:tw-bg-hover-contrast',
fvwStyles$ | async,
]"
[class.tw-bg-background-alt4]="showActiveStyles"
[class.tw-bg-background-alt3]="!showActiveStyles"
[class.hover:tw-bg-hover-contrast]="!showActiveStyles"
[class]="fvwStyles$ | async"
>
<div class="tw-relative tw-flex tw-items-center tw-h-full">
@if (open) {
@@ -26,13 +22,12 @@
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<div
[ngClass]="[
variant() === 'tree' || treeDepth() > 0 ? 'tw-py-0' : 'tw-py-2',
open ? 'tw-pe-4' : 'tw-text-center',
]"
[title]="text()"
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
[ngClass]="{ 'tw-justify-center': !open }"
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
[class.tw-text-center]="!open"
[class.tw-justify-center]="!open"
>
@if (icon()) {
<i
@@ -53,7 +48,7 @@
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
[ngClass]="{ 'tw-ps-0': variant() === 'tree' || treeDepth() > 0 }"
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
data-fvw
[routerLink]="route()"
[relativeTo]="relativeTo()"
@@ -74,7 +69,7 @@
<button
type="button"
class="tw-size-full tw-px-4 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
[ngClass]="open ? 'tw-pe-3' : 'tw-pe-4'"
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
data-fvw
(click)="mainContentClicked.emit()"
>

View File

@@ -7,8 +7,8 @@ import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/sa
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 { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -84,7 +84,7 @@ export const ImporterProviders: SafeProvider[] = [
CollectionService,
KeyService,
EncryptService,
PinServiceAbstraction,
KeyGenerationService,
AccountService,
RestrictedItemTypesService,
],

Some files were not shown because too many files have changed in this diff Show More