1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

Merge branch 'master' into ac/ac-1139/deprecate-custom-collection-perm

This commit is contained in:
Rui Tome
2023-11-27 12:32:34 +00:00
364 changed files with 23724 additions and 1953 deletions

View File

@@ -0,0 +1,69 @@
import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
export type State = "assert" | "assertFailed";
@Directive()
export class BaseLoginViaWebAuthnComponent implements OnInit {
protected currentState: State = "assert";
protected successRoute = "/vault";
protected forcePasswordResetRoute = "/update-temp-password";
constructor(
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
private router: Router,
private logService: LogService,
private validationService: ValidationService,
private i18nService: I18nService
) {}
ngOnInit(): void {
this.authenticate();
}
protected retry() {
this.currentState = "assert";
this.authenticate();
}
private async authenticate() {
let assertion: WebAuthnLoginCredentialAssertionView;
try {
const options = await this.webAuthnLoginService.getCredentialAssertionOptions();
assertion = await this.webAuthnLoginService.assertCredential(options);
} catch (error) {
this.validationService.showError(error);
this.currentState = "assertFailed";
return;
}
try {
const authResult = await this.webAuthnLoginService.logIn(assertion);
if (authResult.requiresTwoFactor) {
this.validationService.showError(
this.i18nService.t("twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn")
);
this.currentState = "assertFailed";
} else if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
await this.router.navigate([this.forcePasswordResetRoute]);
} else {
await this.router.navigate([this.successRoute]);
}
} catch (error) {
if (error instanceof ErrorResponse) {
this.validationService.showError(this.i18nService.t("invalidPasskeyPleaseTryAgain"));
}
this.logService.error(error);
this.currentState = "assertFailed";
}
}
}

View File

@@ -63,7 +63,6 @@
class="environment-selector-dialog-item"
(click)="toggle(ServerEnvironmentType.EU)"
[attr.aria-pressed]="selectedEnvironment === ServerEnvironmentType.EU ? 'true' : 'false'"
*ngIf="euServerFlagEnabled"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
@@ -75,7 +74,7 @@
></i>
<span>{{ "euDomain" | i18n }}</span>
</button>
<br *ngIf="euServerFlagEnabled" />
<br />
<button
type="button"
class="environment-selector-dialog-item"

View File

@@ -4,7 +4,6 @@ import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/cor
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import {
EnvironmentService as EnvironmentServiceAbstraction,
@@ -37,7 +36,6 @@ import {
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter();
euServerFlagEnabled: boolean;
isOpen = false;
showingModal = false;
selectedEnvironment: Region;
@@ -89,9 +87,6 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
async updateEnvironmentInfo() {
this.selectedEnvironment = this.environmentService.selectedRegion;
this.euServerFlagEnabled = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.DisplayEuEnvironmentFlag
);
}
close() {

View File

@@ -1,12 +1,13 @@
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject } from "rxjs";
import { Observable, Subject } from "rxjs";
import { take, takeUntil } from "rxjs/operators";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PasswordLoginCredentials } from "@bitwarden/common/auth/models/domain/login-credentials";
@@ -53,6 +54,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
protected showWebauthnLogin$: Observable<boolean>;
protected destroy$ = new Subject<void>();
@@ -76,7 +78,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
protected formBuilder: FormBuilder,
protected formValidationErrorService: FormValidationErrorsService,
protected route: ActivatedRoute,
protected loginService: LoginService
protected loginService: LoginService,
protected webAuthnLoginService: WebAuthnLoginServiceAbstraction
) {
super(environmentService, i18nService, platformUtilsService);
}
@@ -86,6 +89,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
}
async ngOnInit() {
this.showWebauthnLogin$ = this.webAuthnLoginService.enabled$;
this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
if (!params) {
return;

View File

@@ -0,0 +1,28 @@
import { svgIcon } from "@bitwarden/components";
export const CreatePasskeyFailedIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="163" height="115" fill="none">
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M31 19.46H9v22h22v-22Zm-24-2v26h26v-26H7Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M0 43.46a4 4 0 0 1 4-4h32a4 4 0 0 1 4 4v7h-4v-7H4v16.747l1.705 2.149a4 4 0 0 1 .866 2.486v22.205a4 4 0 0 1-1 2.645L4 91.475v17.985h32V91.475l-1.572-1.783a4 4 0 0 1-1-2.645V64.842a4 4 0 0 1 .867-2.486L36 60.207V56.46h4v3.747a4 4 0 0 1-.867 2.487l-1.704 2.148v22.205L39 88.83a4 4 0 0 1 1 2.645v17.985a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V91.475a4 4 0 0 1 1-2.645l1.571-1.783V64.842L.867 62.694A4 4 0 0 1 0 60.207V43.46Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M19.74 63.96a.5.5 0 0 1 .355.147l2.852 2.866a.5.5 0 0 1 .146.353V77.56c2.585 1.188 4.407 3.814 4.407 6.865 0 4.183-3.357 7.534-7.5 7.534-4.144 0-7.5-3.376-7.5-7.534a7.546 7.546 0 0 1 4.478-6.894v-1.443a.5.5 0 0 1 .146-.353l1.275-1.281-1.322-1.33a.5.5 0 0 1 0-.705l.332-.334-.262-.263a.5.5 0 0 1-.005-.7l1.332-1.377-1.445-1.452a.5.5 0 0 1-.145-.352v-1.114a.5.5 0 0 1 .145-.352l2.357-2.369a.5.5 0 0 1 .355-.147Zm-1.856 3.075v.7l1.645 1.654a.5.5 0 0 1 .005.7l-1.332 1.377.267.268a.5.5 0 0 1 0 .705l-.333.334 1.323 1.329a.5.5 0 0 1 0 .705l-1.48 1.488v1.57a.5.5 0 0 1-.32.466 6.545 6.545 0 0 0-4.159 6.095c0 3.61 2.913 6.534 6.5 6.534 3.588 0 6.5-2.901 6.5-6.534 0-2.749-1.707-5.105-4.095-6.074a.5.5 0 0 1-.312-.463V67.532L19.74 65.17l-1.857 1.866ZM20 85.623a1.27 1.27 0 0 0-1.268 1.276c0 .702.56 1.276 1.268 1.276.712 0 1.268-.555 1.268-1.276A1.27 1.27 0 0 0 20 85.623Zm-2.268 1.276A2.27 2.27 0 0 1 20 84.623a2.27 2.27 0 0 1 2.268 2.276c0 1.269-1 2.276-2.268 2.276a2.27 2.27 0 0 1-2.268-2.276ZM57.623 114a1 1 0 0 1 1-1h63.048a1 1 0 0 1 0 2H58.623a1 1 0 0 1-1-1Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M78.022 114V95.654h2V114h-2ZM98.418 114V95.654h2V114h-2Z" clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M16 14.46c0-7.732 6.268-14 14-14h119c7.732 0 14 6.268 14 14v68c0 7.732-6.268 14-14 14H39.5v-4H149c5.523 0 10-4.477 10-10v-68c0-5.523-4.477-10-10-10H30c-5.523 0-10 4.477-10 10v5h-4v-5Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M25 15.46a6 6 0 0 1 6-6h117a6 6 0 0 1 6 6v66a6 6 0 0 1-6 6H36.5v-2H148a4 4 0 0 0 4-4v-66a4 4 0 0 0-4-4H31a4 4 0 0 0-4 4v3h-2v-3Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500"
d="M104.269 32.86a1.42 1.42 0 0 0-1.007-.4h-25.83c-.39 0-.722.132-1.007.4a1.26 1.26 0 0 0-.425.947v16.199c0 1.207.25 2.407.75 3.597a13.22 13.22 0 0 0 1.861 3.165c.74.919 1.62 1.817 2.646 2.69a30.93 30.93 0 0 0 2.834 2.172c.868.577 1.77 1.121 2.712 1.636.942.516 1.612.862 2.007 1.043.394.181.714.326.95.42.18.083.373.128.583.128.21 0 .403-.041.582-.128.241-.099.557-.239.956-.42.394-.181 1.064-.532 2.006-1.043a36.595 36.595 0 0 0 2.712-1.636c.867-.576 1.813-1.302 2.838-2.171a19.943 19.943 0 0 0 2.646-2.69 13.24 13.24 0 0 0 1.862-3.166 9.19 9.19 0 0 0 .749-3.597V33.812c.005-.367-.14-.684-.425-.952Zm-3.329 17.298c0 5.864-10.593 10.916-10.593 10.916V35.93h10.593v14.228Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M18 24.46h-5v-2h5v2ZM27 24.46h-5v-2h5v2Z"
clip-rule="evenodd" />
<path class="tw-fill-danger-500"
d="M51.066 66.894a2.303 2.303 0 0 1-2.455-.5l-10.108-9.797L28.375 66.4l-.002.002a2.294 2.294 0 0 1-3.185.005 2.24 2.24 0 0 1-.506-2.496c.117-.27.286-.518.503-.728l10.062-9.737-9.945-9.623a2.258 2.258 0 0 1-.698-1.6c-.004-.314.06-.619.176-.894a2.254 2.254 0 0 1 1.257-1.222 2.305 2.305 0 0 1 1.723.014c.267.11.518.274.732.486l10.01 9.682 9.995-9.688.009-.008a2.292 2.292 0 0 1 3.159.026c.425.411.68.98.684 1.59a2.242 2.242 0 0 1-.655 1.6l-.01.01-9.926 9.627 10.008 9.7.029.027a2.237 2.237 0 0 1 .53 2.496l-.002.004a2.258 2.258 0 0 1-1.257 1.222Z" />
</svg>
`;

View File

@@ -0,0 +1,26 @@
import { svgIcon } from "@bitwarden/components";
export const CreatePasskeyIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="163" height="116" fill="none">
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M31 19.58H9v22h22v-22Zm-24-2v26h26v-26H7Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M0 43.58a4 4 0 0 1 4-4h32a4 4 0 0 1 4 4v7h-4v-7H4v16.747l1.705 2.149a4 4 0 0 1 .866 2.486v22.204a4 4 0 0 1-1 2.646L4 91.595v17.985h32V91.595l-1.572-1.783a4 4 0 0 1-1-2.646V64.962a4 4 0 0 1 .867-2.486L36 60.327V56.58h4v3.747a4 4 0 0 1-.867 2.486l-1.704 2.149v22.204L39 88.95a4 4 0 0 1 1 2.646v17.985a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V91.595a4 4 0 0 1 1-2.646l1.571-1.783V64.962L.867 62.813A4 4 0 0 1 0 60.327V43.58Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M19.74 64.08a.5.5 0 0 1 .355.147l2.852 2.866a.5.5 0 0 1 .146.352V77.68c2.585 1.189 4.407 3.814 4.407 6.865 0 4.183-3.357 7.535-7.5 7.535-4.144 0-7.5-3.377-7.5-7.535a7.546 7.546 0 0 1 4.478-6.894V76.21a.5.5 0 0 1 .146-.353l1.275-1.282-1.322-1.329a.5.5 0 0 1 0-.705l.332-.334-.262-.263a.5.5 0 0 1-.005-.7l1.332-1.377-1.445-1.452a.5.5 0 0 1-.145-.353v-1.113a.5.5 0 0 1 .145-.353l2.357-2.368a.5.5 0 0 1 .355-.147Zm-1.856 3.074v.7l1.645 1.654a.5.5 0 0 1 .005.7l-1.332 1.377.267.268a.5.5 0 0 1 0 .706l-.333.334 1.323 1.329a.5.5 0 0 1 0 .705l-1.48 1.488v1.57a.5.5 0 0 1-.32.466 6.545 6.545 0 0 0-4.159 6.094c0 3.61 2.913 6.535 6.5 6.535 3.588 0 6.5-2.902 6.5-6.535 0-2.748-1.707-5.104-4.095-6.073a.5.5 0 0 1-.312-.463V67.651l-2.352-2.364-1.857 1.866ZM20 85.742a1.27 1.27 0 0 0-1.268 1.277c0 .701.56 1.276 1.268 1.276.712 0 1.268-.555 1.268-1.276A1.27 1.27 0 0 0 20 85.742Zm-2.268 1.277A2.27 2.27 0 0 1 20 84.742a2.27 2.27 0 0 1 2.268 2.277c0 1.268-1 2.276-2.268 2.276a2.27 2.27 0 0 1-2.268-2.276ZM41.796 42.844a1 1 0 0 1 1.413.058l5.526 6A1 1 0 0 1 48 50.58H27a1 1 0 1 1 0-2h18.72l-3.982-4.323a1 1 0 0 1 .058-1.413ZM33.315 62.315a1 1 0 0 1-1.413-.058l-5.526-6a1 1 0 0 1 .735-1.677h21a1 1 0 1 1 0 2h-18.72l3.982 4.322a1 1 0 0 1-.058 1.413ZM57.623 114.12a1 1 0 0 1 1-1h63.048a1 1 0 1 1 0 2H58.623a1 1 0 0 1-1-1Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M78.022 114.12V95.774h2v18.346h-2ZM98.418 114.12V95.774h2v18.346h-2Z" clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M16 14.58c0-7.732 6.268-14 14-14h119c7.732 0 14 6.268 14 14v68c0 7.732-6.268 14-14 14H39.5v-4H149c5.523 0 10-4.478 10-10v-68c0-5.523-4.477-10-10-10H30c-5.523 0-10 4.477-10 10v5h-4v-5Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500" fill-rule="evenodd"
d="M25 15.58a6 6 0 0 1 6-6h117a6 6 0 0 1 6 6v66a6 6 0 0 1-6 6H36.5v-2H148a4 4 0 0 0 4-4v-66a4 4 0 0 0-4-4H31a4 4 0 0 0-4 4v3h-2v-3Z"
clip-rule="evenodd" />
<path class="tw-fill-secondary-500"
d="M104.269 32.98a1.42 1.42 0 0 0-1.007-.4h-25.83c-.39 0-.722.132-1.007.4a1.26 1.26 0 0 0-.425.947v16.199c0 1.207.25 2.406.75 3.597a13.222 13.222 0 0 0 1.861 3.165c.74.919 1.62 1.817 2.646 2.69a30.93 30.93 0 0 0 2.834 2.172c.868.577 1.77 1.121 2.712 1.636.942.515 1.612.861 2.007 1.043.394.18.714.325.95.42.18.082.373.128.583.128.21 0 .403-.042.582-.128.241-.099.557-.24.956-.42.394-.182 1.064-.532 2.006-1.043a36.56 36.56 0 0 0 2.712-1.636c.867-.577 1.813-1.302 2.838-2.172a19.943 19.943 0 0 0 2.646-2.69 13.24 13.24 0 0 0 1.862-3.165c.5-1.187.749-2.386.749-3.597V33.93c.005-.367-.14-.684-.425-.952Zm-3.329 17.298c0 5.864-10.593 10.916-10.593 10.916V36.049h10.593v14.23Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" d="M18 24.58h-5v-2h5v2ZM27 24.58h-5v-2h5v2Z"
clip-rule="evenodd" />
</svg>
`;

View File

@@ -3,10 +3,17 @@ import { InjectionToken } from "@angular/core";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
export const WINDOW = new InjectionToken<Window>("WINDOW");
export const OBSERVABLE_MEMORY_STORAGE = new InjectionToken<
AbstractMemoryStorageService & ObservableStorageService
>("OBSERVABLE_MEMORY_STORAGE");
export const OBSERVABLE_DISK_STORAGE = new InjectionToken<
AbstractStorageService & ObservableStorageService
>("OBSERVABLE_DISK_STORAGE");
export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE");
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");

View File

@@ -54,6 +54,9 @@ import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation";
@@ -68,6 +71,9 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service";
import { WebAuthnLoginPrfCryptoService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-crypto.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
@@ -101,6 +107,9 @@ import { NoopNotificationsService } from "@bitwarden/common/platform/services/no
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@@ -170,6 +179,8 @@ import {
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
SECURE_STORAGE,
STATE_FACTORY,
STATE_SERVICE_USE_CACHE,
@@ -337,7 +348,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
{
provide: AccountServiceAbstraction,
useClass: AccountServiceImplementation,
deps: [MessagingServiceAbstraction, LogService],
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider],
},
{
provide: InternalAccountService,
@@ -747,6 +758,33 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
useClass: AuthRequestCryptoServiceImplementation,
deps: [CryptoServiceAbstraction],
},
{
provide: WebAuthnLoginPrfCryptoServiceAbstraction,
useClass: WebAuthnLoginPrfCryptoService,
deps: [CryptoFunctionServiceAbstraction],
},
{
provide: WebAuthnLoginApiServiceAbstraction,
useClass: WebAuthnLoginApiService,
deps: [ApiServiceAbstraction, EnvironmentServiceAbstraction],
},
{
provide: WebAuthnLoginServiceAbstraction,
useClass: WebAuthnLoginService,
deps: [
WebAuthnLoginApiServiceAbstraction,
AuthServiceAbstraction,
ConfigServiceAbstraction,
WebAuthnLoginPrfCryptoServiceAbstraction,
WINDOW,
LogService,
],
},
{
provide: GlobalStateProvider,
useClass: DefaultGlobalStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
},
],
})
export class JslibServicesModule {}

View File

@@ -10,26 +10,9 @@ import {
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
/**
* Provides a mapping from supported card brands to
* the filenames of icon that should be present in images/cards folder of clients.
*/
const cardIcons: Record<string, string> = {
Visa: "card-visa",
Mastercard: "card-mastercard",
Amex: "card-amex",
Discover: "card-discover",
"Diners Club": "card-diners-club",
JCB: "card-jcb",
Maestro: "card-maestro",
UnionPay: "card-union-pay",
RuPay: "card-ru-pay",
};
@Component({
selector: "app-vault-icon",
templateUrl: "icon.component.html",
@@ -61,73 +44,6 @@ export class IconComponent implements OnInit {
this.data$ = combineLatest([
this.settingsService.disableFavicon$.pipe(distinctUntilChanged()),
this.cipher$.pipe(filter((c) => c !== undefined)),
]).pipe(
map(([disableFavicon, cipher]) => {
const imageEnabled = !disableFavicon;
let image = undefined;
let fallbackImage = "";
let icon = undefined;
switch (cipher.type) {
case CipherType.Login:
icon = "bwi-globe";
if (cipher.login.uri) {
let hostnameUri = cipher.login.uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
icon = "bwi-android";
image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
icon = "bwi-apple";
image = null;
} else if (
imageEnabled &&
hostnameUri.indexOf("://") === -1 &&
hostnameUri.indexOf(".") > -1
) {
hostnameUri = "http://" + hostnameUri;
isWebsite = true;
} else if (imageEnabled) {
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (imageEnabled && isWebsite) {
try {
image = iconsUrl + "/" + Utils.getHostname(hostnameUri) + "/icon.png";
fallbackImage = "images/bwi-globe.png";
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null.
}
}
} else {
image = null;
}
break;
case CipherType.SecureNote:
icon = "bwi-sticky-note";
break;
case CipherType.Card:
icon = "bwi-credit-card";
if (imageEnabled && cipher.card.brand in cardIcons) {
icon = "credit-card-icon " + cardIcons[cipher.card.brand];
}
break;
case CipherType.Identity:
icon = "bwi-id-card";
break;
default:
break;
}
return {
imageEnabled,
image,
fallbackImage,
icon,
};
})
);
]).pipe(map(([disableFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, disableFavicon)));
}
}

View File

@@ -38,6 +38,7 @@ import { EmailRequest } from "../auth/models/request/email.request";
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
@@ -144,7 +145,11 @@ export abstract class ApiService {
) => Promise<any>;
postIdentityToken: (
request: PasswordTokenRequest | SsoTokenRequest | UserApiTokenRequest
request:
| PasswordTokenRequest
| SsoTokenRequest
| UserApiTokenRequest
| WebAuthnLoginTokenRequest
) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
refreshIdentityToken: () => Promise<any>;

View File

@@ -10,5 +10,7 @@ export abstract class SettingsService {
getEquivalentDomains: (url: string) => Set<string>;
setDisableFavicon: (value: boolean) => Promise<any>;
getDisableFavicon: () => boolean;
setAutoFillOverlayVisibility: (value: number) => Promise<void>;
getAutoFillOverlayVisibility: () => Promise<number>;
clear: (userId?: string) => Promise<void>;
}

View File

@@ -9,34 +9,87 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
import { PolicyResponse } from "../../models/response/policy.response";
export abstract class PolicyService {
/**
* All {@link Policy} objects for the active user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* @see {@link get$} or {@link policyAppliesToActiveUser$} if you want to know when a policy applies to a user.
*/
policies$: Observable<Policy[]>;
/**
* @returns the first {@link Policy} found that applies to the active user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @param policyFilter Optional predicate to apply when filtering policies
*/
get$: (policyType: PolicyType, policyFilter?: (policy: Policy) => boolean) => Observable<Policy>;
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
/**
* All {@link Policy} objects for the specified user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* @see {@link policyAppliesToUser} if you want to know when a policy applies to the user.
* @deprecated Use {@link policies$} instead
*/
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
/**
* @returns true if the {@link PolicyType} applies to the current user, otherwise false.
* @remarks A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
*/
policyAppliesToActiveUser$: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean
) => Observable<boolean>;
/**
* @deprecated Do not call this, use the policies$ observable collection
* @returns true if the {@link PolicyType} applies to the specified user, otherwise false.
* @remarks A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @see {@link policyAppliesToActiveUser$} if you only want to know about the current user.
*/
getAll: (type?: PolicyType, userId?: string) => Promise<Policy[]>;
evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions
) => boolean;
getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string
) => [ResetPasswordPolicyOptions, boolean];
mapPolicyFromResponse: (policyResponse: PolicyResponse) => Policy;
mapPoliciesFromToken: (policiesResponse: ListResponse<PolicyResponse>) => Policy[];
policyAppliesToUser: (
policyType: PolicyType,
policyFilter?: (policy: Policy) => boolean,
userId?: string
) => Promise<boolean>;
// Policy specific interfaces
/**
* Combines all Master Password policies that apply to the user.
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
*/
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
/**
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
*/
evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions
) => boolean;
/**
* @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy
* is enabled
*/
getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string
) => [ResetPasswordPolicyOptions, boolean];
// Helpers
/**
* Instantiates {@link Policy} objects from {@link PolicyResponse} objects.
*/
mapPolicyFromResponse: (policyResponse: PolicyResponse) => Policy;
/**
* Instantiates {@link Policy} objects from {@link ListResponse<PolicyResponse>} objects.
*/
mapPoliciesFromToken: (policiesResponse: ListResponse<PolicyResponse>) => Policy[];
}
export abstract class InternalPolicyService extends PolicyService {

View File

@@ -270,6 +270,10 @@ export class Organization {
return this.providerId != null || this.providerName != null;
}
get hasReseller() {
return this.hasProvider && this.providerType === ProviderType.Reseller;
}
get canAccessSecretsManager() {
return this.useSecretsManager && this.accessSecretsManager;
}

View File

@@ -7,6 +7,11 @@ export class Policy extends Domain {
organizationId: string;
type: PolicyType;
data: any;
/**
* Warning: a user can be exempt from a policy even if the policy is enabled.
* @see {@link PolicyService} has methods to tell you whether a policy applies to a user.
*/
enabled: boolean;
constructor(obj?: PolicyData) {

View File

@@ -42,11 +42,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
.subscribe();
}
/**
* Returns the first policy found that applies to the active user
* @param policyType Policy type to search for
* @param policyFilter Additional filter to apply to the policy
*/
get$(policyType: PolicyType, policyFilter?: (policy: Policy) => boolean): Observable<Policy> {
return this.policies$.pipe(
concatMap(async (policies) => {
@@ -64,9 +59,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
);
}
/**
* @deprecated Do not call this, use the policies$ observable collection
*/
async getAll(type?: PolicyType, userId?: string): Promise<Policy[]> {
let response: Policy[] = [];
const decryptedPolicies = await this.stateService.getDecryptedPolicies({ userId: userId });

View File

@@ -3,12 +3,20 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status";
/**
* 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 = {
status: AuthenticationStatus;
email: string;
name: string | undefined;
};
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
return a.status == b.status && a.email == b.email && a.name == b.name;
}
export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;

View File

@@ -9,6 +9,7 @@ import {
PasswordLoginCredentials,
SsoLoginCredentials,
AuthRequestLoginCredentials,
WebAuthnLoginCredentials,
} from "../models/domain/login-credentials";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { AuthRequestResponse } from "../models/response/auth-request.response";
@@ -26,6 +27,7 @@ export abstract class AuthService {
| PasswordLoginCredentials
| SsoLoginCredentials
| AuthRequestLoginCredentials
| WebAuthnLoginCredentials
) => Promise<AuthResult>;
logInTwoFactor: (
twoFactor: TokenTwoFactorRequest,

View File

@@ -0,0 +1,5 @@
import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response";
export class WebAuthnLoginApiServiceAbstraction {
getCredentialAssertionOptions: () => Promise<CredentialAssertionOptionsResponse>;
}

View File

@@ -0,0 +1,17 @@
import { PrfKey } from "../../../platform/models/domain/symmetric-crypto-key";
/**
* Contains methods for all crypto operations specific to the WebAuthn login flow.
*/
export abstract class WebAuthnLoginPrfCryptoServiceAbstraction {
/**
* Get the salt used to generate the PRF-output used when logging in with WebAuthn.
*/
getLoginWithPrfSalt: () => Promise<ArrayBuffer>;
/**
* Create a symmetric key from the PRF-output by stretching it.
* This should be used as `ExternalKey` with `RotateableKeySet`.
*/
createSymmetricKeyFromPrf: (prf: ArrayBuffer) => Promise<PrfKey>;
}

View File

@@ -0,0 +1,48 @@
import { Observable } from "rxjs";
import { AuthResult } from "../../models/domain/auth-result";
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
/**
* Service for logging in with WebAuthnLogin credentials.
*/
export abstract class WebAuthnLoginServiceAbstraction {
/**
* An Observable that emits a boolean indicating whether the WebAuthn login feature is enabled.
*/
readonly enabled$: Observable<boolean>;
/**
* Gets the credential assertion options needed for initiating the WebAuthn
* authentication process. It should provide the challenge and other data
* (whether FIDO2 user verification is required, the relying party id, timeout duration for the process to complete, etc.)
* for the authenticator.
*/
getCredentialAssertionOptions: () => Promise<WebAuthnLoginCredentialAssertionOptionsView>;
/**
* Asserts the credential. This involves user interaction with the authenticator
* to sign a challenge with a private key (proving ownership of the private key).
* This will trigger the browsers WebAuthn API to assert a credential. A PRF-output might
* be included in the response if the authenticator supports it.
*
* @param {WebAuthnLoginCredentialAssertionOptionsView} credentialAssertionOptions - The options provided by the
* `getCredentialAssertionOptions` method, including the challenge and other data.
* @returns {WebAuthnLoginCredentialAssertionView} The assertion obtained from the authenticator.
* If the assertion is not successfully obtained, it returns undefined.
*/
assertCredential: (
credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView
) => Promise<WebAuthnLoginCredentialAssertionView | undefined>;
/**
* Logs the user in using the assertion obtained from the authenticator.
* It completes the authentication process if the assertion is successfully validated server side:
* the server verifies the signed challenge with the corresponding public key.
*
* @param {WebAuthnLoginCredentialAssertionView} assertion - The assertion obtained from the authenticator
* that needs to be validated for login.
*/
logIn: (assertion: WebAuthnLoginCredentialAssertionView) => Promise<AuthResult>;
}

View File

@@ -3,4 +3,5 @@ export enum AuthenticationType {
Sso = 1,
UserApi = 2,
AuthRequest = 3,
WebAuthn = 4,
}

View File

@@ -24,12 +24,14 @@ import {
PasswordLoginCredentials,
SsoLoginCredentials,
UserApiLoginCredentials,
WebAuthnLoginCredentials,
} from "../models/domain/login-credentials";
import { DeviceRequest } from "../models/request/identity-token/device.request";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { UserApiTokenRequest } from "../models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../models/request/identity-token/webauthn-login-token.request";
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
@@ -37,7 +39,11 @@ import { IdentityTwoFactorResponse } from "../models/response/identity-two-facto
type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse;
export abstract class LoginStrategy {
protected abstract tokenRequest: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest;
protected abstract tokenRequest:
| UserApiTokenRequest
| PasswordTokenRequest
| SsoTokenRequest
| WebAuthnLoginTokenRequest;
protected captchaBypassToken: string = null;
constructor(
@@ -58,6 +64,7 @@ export abstract class LoginStrategy {
| PasswordLoginCredentials
| SsoLoginCredentials
| AuthRequestLoginCredentials
| WebAuthnLoginCredentials
): Promise<AuthResult>;
async logInTwoFactor(

View File

@@ -0,0 +1,333 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "../../abstractions/api.service";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import {
PrfKey,
SymmetricCryptoKey,
UserKey,
} from "../../platform/models/domain/symmetric-crypto-key";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { AuthResult } from "../models/domain/auth-result";
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response";
import { WebAuthnLoginAssertionResponseRequest } from "../services/webauthn-login/request/webauthn-login-assertion-response.request";
import { identityTokenResponseFactory } from "./login.strategy.spec";
import { WebAuthnLoginStrategy } from "./webauthn-login.strategy";
describe("WebAuthnLoginStrategy", () => {
let cryptoService!: MockProxy<CryptoService>;
let apiService!: MockProxy<ApiService>;
let tokenService!: MockProxy<TokenService>;
let appIdService!: MockProxy<AppIdService>;
let platformUtilsService!: MockProxy<PlatformUtilsService>;
let messagingService!: MockProxy<MessagingService>;
let logService!: MockProxy<LogService>;
let stateService!: MockProxy<StateService>;
let twoFactorService!: MockProxy<TwoFactorService>;
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
const token = "mockToken";
const deviceId = Utils.newGuid();
let webAuthnCredentials!: WebAuthnLoginCredentials;
let originalPublicKeyCredential!: PublicKeyCredential | any;
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
beforeAll(() => {
// Save off the original classes so we can restore them after all tests are done if they exist
originalPublicKeyCredential = global.PublicKeyCredential;
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
// We must do this to make the mocked classes available for all the
// assertCredential(...) tests.
global.PublicKeyCredential = MockPublicKeyCredential;
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
});
beforeEach(() => {
jest.clearAllMocks();
cryptoService = mock<CryptoService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
messagingService = mock<MessagingService>();
logService = mock<LogService>();
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
tokenService.getTwoFactorToken.mockResolvedValue(null);
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
webAuthnLoginStrategy = new WebAuthnLoginStrategy(
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService
);
// Create credentials
const publicKeyCredential = new MockPublicKeyCredential();
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential);
const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey;
webAuthnCredentials = new WebAuthnLoginCredentials(token, deviceResponse, prfKey);
});
afterAll(() => {
// Restore global after all tests are done
global.PublicKeyCredential = originalPublicKeyCredential;
global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse;
});
const mockEncPrfPrivateKey =
"2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=";
const mockEncUserKey =
"4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw==";
const userDecryptionOptsServerResponseWithWebAuthnPrfOption: IUserDecryptionOptionsServerResponse =
{
HasMasterPassword: true,
WebAuthnPrfOption: {
EncryptedPrivateKey: mockEncPrfPrivateKey,
EncryptedUserKey: mockEncUserKey,
},
};
const mockIdTokenResponseWithModifiedWebAuthnPrfOption = (key: string, value: any) => {
const userDecryptionOpts: IUserDecryptionOptionsServerResponse = {
...userDecryptionOptsServerResponseWithWebAuthnPrfOption,
WebAuthnPrfOption: {
...userDecryptionOptsServerResponseWithWebAuthnPrfOption.WebAuthnPrfOption,
[key]: value,
},
};
return identityTokenResponseFactory(null, userDecryptionOpts);
};
it("returns successful authResult when api service returns valid credentials", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithWebAuthnPrfOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
// Act
const authResult = await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(apiService.postIdentityToken).toHaveBeenCalledWith(
expect.objectContaining({
// webauthn specific info
token: webAuthnCredentials.token,
deviceResponse: webAuthnCredentials.deviceResponse,
// standard info
device: expect.objectContaining({
identifier: deviceId,
}),
})
);
expect(authResult).toBeInstanceOf(AuthResult);
expect(authResult).toMatchObject({
captchaSiteKey: "",
forcePasswordReset: 0,
resetMasterPassword: false,
twoFactorProviders: null,
requiresTwoFactor: false,
requiresCaptcha: false,
});
});
it("decrypts and sets user key when webAuthn PRF decryption option exists with valid PRF key and enc key data", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithWebAuthnPrfOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
const mockPrfPrivateKey: Uint8Array = randomBytes(32);
const mockUserKeyArray: Uint8Array = randomBytes(32);
const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey;
cryptoService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey);
cryptoService.rsaDecrypt.mockResolvedValue(mockUserKeyArray);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// // Assert
expect(cryptoService.decryptToBytes).toHaveBeenCalledTimes(1);
expect(cryptoService.decryptToBytes).toHaveBeenCalledWith(
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey,
webAuthnCredentials.prfKey
);
expect(cryptoService.rsaDecrypt).toHaveBeenCalledTimes(1);
expect(cryptoService.rsaDecrypt).toHaveBeenCalledWith(
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey.encryptedString,
mockPrfPrivateKey
);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockUserKey);
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey);
// Master key and private key should not be set
expect(cryptoService.setMasterKey).not.toHaveBeenCalled();
});
it("does not try to set the user key when prfKey is missing", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithWebAuthnPrfOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
// Remove PRF key
webAuthnCredentials.prfKey = null;
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(cryptoService.decryptToBytes).not.toHaveBeenCalled();
expect(cryptoService.rsaDecrypt).not.toHaveBeenCalled();
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
describe.each([
{
valueName: "encPrfPrivateKey",
},
{
valueName: "encUserKey",
},
])("given webAuthn PRF decryption option has missing encrypted key data", ({ valueName }) => {
it(`does not set the user key when ${valueName} is missing`, async () => {
// Arrange
const idTokenResponse = mockIdTokenResponseWithModifiedWebAuthnPrfOption(valueName, null);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
});
it("does not set the user key when the PRF encrypted private key decryption fails", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithWebAuthnPrfOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
cryptoService.decryptToBytes.mockResolvedValue(null);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
it("does not set the user key when the encrypted user key decryption fails", async () => {
// Arrange
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOptsServerResponseWithWebAuthnPrfOption
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
cryptoService.rsaDecrypt.mockResolvedValue(null);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
});
// Helpers and mocks
function randomBytes(length: number): Uint8Array {
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
}
// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts
// so we need to mock them and assign them to the global object to make them available
// for the tests
class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
clientDataJSON: ArrayBuffer = randomBytes(32).buffer;
authenticatorData: ArrayBuffer = randomBytes(196).buffer;
signature: ArrayBuffer = randomBytes(72).buffer;
userHandle: ArrayBuffer = randomBytes(16).buffer;
clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON);
authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData);
signatureB64Str = Utils.fromBufferToUrlB64(this.signature);
userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle);
}
class MockPublicKeyCredential implements PublicKeyCredential {
authenticatorAttachment = "cross-platform";
id = "mockCredentialId";
type = "public-key";
rawId: ArrayBuffer = randomBytes(32).buffer;
rawIdB64Str = Utils.fromBufferToB64(this.rawId);
response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse();
// Use random 64 character hex string (32 bytes - matters for symmetric key creation)
// to represent the prf key binary data and convert to ArrayBuffer
// Creating the array buffer from a known hex value allows us to
// assert on the value in tests
private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
);
getClientExtensionResults(): any {
return {
prf: {
results: {
first: this.prfKeyArrayBuffer,
},
},
};
}
static isConditionalMediationAvailable(): Promise<boolean> {
return Promise.resolve(false);
}
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
return Promise.resolve(false);
}
}

View File

@@ -0,0 +1,68 @@
import { SymmetricCryptoKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key";
import { AuthResult } from "../models/domain/auth-result";
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
import { WebAuthnLoginTokenRequest } from "../models/request/identity-token/webauthn-login-token.request";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { LoginStrategy } from "./login.strategy";
export class WebAuthnLoginStrategy extends LoginStrategy {
tokenRequest: WebAuthnLoginTokenRequest;
private credentials: WebAuthnLoginCredentials;
protected override async setMasterKey() {
return Promise.resolve();
}
protected override async setUserKey(idTokenResponse: IdentityTokenResponse) {
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
if (userDecryptionOptions?.webAuthnPrfOption) {
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
// confirm we still have the prf key
if (!this.credentials.prfKey) {
return;
}
// decrypt prf encrypted private key
const privateKey = await this.cryptoService.decryptToBytes(
webAuthnPrfOption.encryptedPrivateKey,
this.credentials.prfKey
);
// decrypt user key with private key
const userKey = await this.cryptoService.rsaDecrypt(
webAuthnPrfOption.encryptedUserKey.encryptedString,
privateKey
);
if (userKey) {
await this.cryptoService.setUserKey(new SymmetricCryptoKey(userKey) as UserKey);
}
}
}
protected override async setPrivateKey(response: IdentityTokenResponse): Promise<void> {
await this.cryptoService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount())
);
}
async logInTwoFactor(): Promise<AuthResult> {
throw new Error("2FA not supported yet for WebAuthn Login.");
}
async logIn(credentials: WebAuthnLoginCredentials) {
this.credentials = credentials;
this.tokenRequest = new WebAuthnLoginTokenRequest(
credentials.token,
credentials.deviceResponse,
await this.buildDeviceRequest()
);
const [authResult] = await this.startLogIn();
return authResult;
}
}

View File

@@ -1,5 +1,10 @@
import { MasterKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key";
import {
MasterKey,
UserKey,
SymmetricCryptoKey,
} from "../../../platform/models/domain/symmetric-crypto-key";
import { AuthenticationType } from "../../enums/authentication-type";
import { WebAuthnLoginAssertionResponseRequest } from "../../services/webauthn-login/request/webauthn-login-assertion-response.request";
import { TokenTwoFactorRequest } from "../request/identity-token/token-two-factor.request";
export class PasswordLoginCredentials {
@@ -44,3 +49,13 @@ export class AuthRequestLoginCredentials {
public twoFactor?: TokenTwoFactorRequest
) {}
}
export class WebAuthnLoginCredentials {
readonly type = AuthenticationType.WebAuthn;
constructor(
public token: string,
public deviceResponse: WebAuthnLoginAssertionResponseRequest,
public prfKey?: SymmetricCryptoKey
) {}
}

View File

@@ -5,7 +5,7 @@ export abstract class TokenRequest {
protected device?: DeviceRequest;
protected authRequest: string;
constructor(protected twoFactor: TokenTwoFactorRequest, device?: DeviceRequest) {
constructor(protected twoFactor?: TokenTwoFactorRequest, device?: DeviceRequest) {
this.device = device != null ? device : null;
}
@@ -14,7 +14,7 @@ export abstract class TokenRequest {
// Implemented in subclass if required
}
setTwoFactor(twoFactor: TokenTwoFactorRequest) {
setTwoFactor(twoFactor: TokenTwoFactorRequest | undefined) {
this.twoFactor = twoFactor;
}

View File

@@ -0,0 +1,25 @@
import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request";
import { DeviceRequest } from "./device.request";
import { TokenRequest } from "./token.request";
export class WebAuthnLoginTokenRequest extends TokenRequest {
constructor(
public token: string,
public deviceResponse: WebAuthnLoginAssertionResponseRequest,
device?: DeviceRequest
) {
super(undefined, device);
}
toIdentityToken(clientId: string) {
const obj = super.toIdentityToken(clientId);
obj.grant_type = "webauthn";
obj.token = this.token;
// must be a string b/c sending as form encoded data
obj.deviceResponse = JSON.stringify(this.deviceResponse);
return obj;
}
}

View File

@@ -8,17 +8,23 @@ import {
ITrustedDeviceUserDecryptionOptionServerResponse,
TrustedDeviceUserDecryptionOptionResponse,
} from "./trusted-device-user-decryption-option.response";
import {
IWebAuthnPrfDecryptionOptionServerResponse,
WebAuthnPrfDecryptionOptionResponse,
} from "./webauthn-prf-decryption-option.response";
export interface IUserDecryptionOptionsServerResponse {
HasMasterPassword: boolean;
TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse;
KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse;
WebAuthnPrfOption?: IWebAuthnPrfDecryptionOptionServerResponse;
}
export class UserDecryptionOptionsResponse extends BaseResponse {
hasMasterPassword: boolean;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
constructor(response: IUserDecryptionOptionsServerResponse) {
super(response);
@@ -35,5 +41,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
this.getResponseProperty("KeyConnectorOption")
);
}
if (response.WebAuthnPrfOption) {
this.webAuthnPrfOption = new WebAuthnPrfDecryptionOptionResponse(
this.getResponseProperty("WebAuthnPrfOption")
);
}
}
}

View File

@@ -0,0 +1,22 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { EncString } from "../../../../platform/models/domain/enc-string";
export interface IWebAuthnPrfDecryptionOptionServerResponse {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
}
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
super(response);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
}
}
}

View File

@@ -0,0 +1,5 @@
import { AssertionOptionsResponse } from "../../../services/webauthn-login/response/assertion-options.response";
export class WebAuthnLoginCredentialAssertionOptionsView {
constructor(readonly options: AssertionOptionsResponse, readonly token: string) {}
}

View File

@@ -0,0 +1,10 @@
import { PrfKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request";
export class WebAuthnLoginCredentialAssertionView {
constructor(
readonly token: string,
readonly deviceResponse: WebAuthnLoginAssertionResponseRequest,
readonly prfKey?: PrfKey
) {}
}

View File

@@ -1,9 +1,15 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service";
import { AuthenticationStatus } from "../enums/authentication-status";
@@ -13,6 +19,11 @@ import { AccountServiceImplementation } from "./account.service";
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let globalStateProvider: MockProxy<GlobalStateProvider>;
let accountsState: MockProxy<GlobalState<Record<UserId, AccountInfo>>>;
let accountsSubject: BehaviorSubject<Record<UserId, AccountInfo>>;
let activeAccountIdState: MockProxy<GlobalState<UserId>>;
let activeAccountIdSubject: BehaviorSubject<UserId>;
let sut: AccountServiceImplementation;
const userId = "userId" as UserId;
function userInfo(status: AuthenticationStatus): AccountInfo {
@@ -20,10 +31,29 @@ describe("accountService", () => {
}
beforeEach(() => {
messagingService = mock<MessagingService>();
logService = mock<LogService>();
messagingService = mock();
logService = mock();
globalStateProvider = mock();
accountsState = mock();
activeAccountIdState = mock();
sut = new AccountServiceImplementation(messagingService, logService);
accountsSubject = new BehaviorSubject<Record<UserId, AccountInfo>>(null);
accountsState.state$ = accountsSubject.asObservable();
activeAccountIdSubject = new BehaviorSubject<UserId>(null);
activeAccountIdState.state$ = activeAccountIdSubject.asObservable();
globalStateProvider.get.mockImplementation((keyDefinition) => {
switch (keyDefinition) {
case ACCOUNT_ACCOUNTS:
return accountsState;
case ACCOUNT_ACTIVE_ACCOUNT_ID:
return activeAccountIdState;
default:
throw new Error("Unknown key definition");
}
});
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
});
afterEach(() => {
@@ -39,8 +69,8 @@ describe("accountService", () => {
it("should emit the active account and status", async () => {
const emissions = trackEmissions(sut.activeAccount$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
expect(emissions).toEqual([
undefined, // initial value
@@ -48,9 +78,21 @@ describe("accountService", () => {
]);
});
it("should update the status if the account status changes", async () => {
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
const emissions = trackEmissions(sut.activeAccount$);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) });
expect(emissions).toEqual([
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should remember the last emitted value", async () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
activeAccountIdSubject.next(userId);
expect(await firstValueFrom(sut.activeAccount$)).toEqual({
id: userId,
@@ -59,77 +101,98 @@ describe("accountService", () => {
});
});
describe("accounts$", () => {
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({});
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
});
});
});
describe("addAccount", () => {
it("should emit the new account", () => {
const emissions = trackEmissions(sut.accounts$);
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
expect(emissions).toEqual([
{}, // initial value
{ [userId]: userInfo(AuthenticationStatus.Unlocked) },
]);
expect(accountsState.update).toHaveBeenCalledTimes(1);
const callback = accountsState.update.mock.calls[0][0];
expect(callback({}, null)).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
});
describe("setAccountName", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit the updated account", () => {
const emissions = trackEmissions(sut.accounts$);
it("should update the account", async () => {
sut.setAccountName(userId, "new name");
expect(emissions).toEqual([
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } },
]);
const callback = accountsState.update.mock.calls[0][0];
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" },
});
});
it("should not update if the name is the same", async () => {
sut.setAccountName(userId, "name");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
});
describe("setAccountEmail", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit the updated account", () => {
const emissions = trackEmissions(sut.accounts$);
it("should update the account", () => {
sut.setAccountEmail(userId, "new email");
expect(emissions).toEqual([
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } },
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } },
]);
const callback = accountsState.update.mock.calls[0][0];
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" },
});
});
it("should not update if the email is the same", () => {
sut.setAccountEmail(userId, "email");
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
});
describe("setAccountStatus", () => {
beforeEach(() => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should not emit if the status is the same", async () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
it("should update the account", () => {
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]);
});
const callback = accountsState.update.mock.calls[0][0];
it("should maintain an accounts cache", async () => {
expect(await firstValueFrom(sut.accounts$)).toEqual({
[userId]: userInfo(AuthenticationStatus.Unlocked),
expect(callback(accountsSubject.value, null)).toEqual({
[userId]: {
...userInfo(AuthenticationStatus.Unlocked),
status: AuthenticationStatus.Locked,
},
});
});
it("should emit if the status is different", () => {
const emissions = trackEmissions(sut.accounts$);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
it("should not update if the status is the same", () => {
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
expect(emissions).toEqual([
{ userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach
{ userId: userInfo(AuthenticationStatus.Locked) },
]);
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
expect(callback(accountsSubject.value, null)).toBe(false);
});
it("should emit logout if the status is logged out", () => {
@@ -148,34 +211,20 @@ describe("accountService", () => {
});
describe("switchAccount", () => {
let emissions: { id: string; status: AuthenticationStatus }[];
beforeEach(() => {
emissions = [];
sut.activeAccount$.subscribe((value) => emissions.push(value));
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
});
it("should emit undefined if no account is provided", () => {
sut.switchAccount(undefined);
expect(emissions).toEqual([undefined]);
sut.switchAccount(null);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(callback(userId, accountsSubject.value)).toBeUndefined();
});
it("should emit the active account and status", () => {
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
sut.switchAccount(userId);
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
sut.switchAccount(undefined);
sut.switchAccount(undefined);
expect(emissions).toEqual([
undefined, // initial value
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
]);
});
it("should throw if switched to an unknown account", () => {
expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist");
it("should throw if the account does not exist", () => {
sut.switchAccount("unknown" as UserId);
const callback = activeAccountIdState.update.mock.calls[0][0];
expect(() => callback(userId, accountsSubject.value)).toThrowError("Account does not exist");
});
});
});

View File

@@ -1,50 +1,80 @@
import {
BehaviorSubject,
Subject,
combineLatestWith,
map,
distinctUntilChanged,
shareReplay,
} from "rxjs";
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
import { Jsonify } from "type-fest";
import { AccountInfo, InternalAccountService } from "../../auth/abstractions/account.service";
import {
AccountInfo,
InternalAccountService,
accountInfoEqual,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
GlobalState,
GlobalStateProvider,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status";
export function AccountsDeserializer(
accounts: Jsonify<Record<UserId, AccountInfo> | null>
): Record<UserId, AccountInfo> {
if (accounts == null) {
return {};
}
return accounts;
}
export class AccountServiceImplementation implements InternalAccountService {
private accounts = new BehaviorSubject<Record<UserId, AccountInfo>>({});
private activeAccountId = new BehaviorSubject<UserId | undefined>(undefined);
private lock = new Subject<UserId>();
private logout = new Subject<UserId>();
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$ = this.accounts.asObservable();
activeAccount$ = this.activeAccountId.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false })
);
accounts$;
activeAccount$;
accountLock$ = this.lock.asObservable();
accountLogout$ = this.logout.asObservable();
constructor(private messagingService: MessagingService, private logService: LogService) {}
constructor(
private messagingService: MessagingService,
private logService: LogService,
private globalStateProvider: GlobalStateProvider
) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
this.accounts$ = this.accountsState.state$.pipe(
map((accounts) => (accounts == null ? {} : accounts))
);
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false })
);
}
addAccount(userId: UserId, accountData: AccountInfo): void {
this.accounts.value[userId] = accountData;
this.accounts.next(this.accounts.value);
this.accountsState.update((accounts) => {
accounts ||= {};
accounts[userId] = accountData;
return accounts;
});
}
setAccountName(userId: UserId, name: string): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], name });
this.setAccountInfo(userId, { name });
}
setAccountEmail(userId: UserId, email: string): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], email });
this.setAccountInfo(userId, { email });
}
setAccountStatus(userId: UserId, status: AuthenticationStatus): void {
this.setAccountInfo(userId, { ...this.accounts.value[userId], status });
this.setAccountInfo(userId, { status });
if (status === AuthenticationStatus.LoggedOut) {
this.logout.next(userId);
@@ -54,16 +84,22 @@ export class AccountServiceImplementation implements InternalAccountService {
}
switchAccount(userId: UserId) {
if (userId == null) {
// indicates no account is active
this.activeAccountId.next(undefined);
return;
}
this.activeAccountIdState.update(
(_, accounts) => {
if (userId == null) {
// indicates no account is active
return undefined;
}
if (this.accounts.value[userId] == null) {
throw new Error("Account does not exist");
}
this.activeAccountId.next(userId);
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
return userId;
},
{
combineLatestWith: this.accounts$,
}
);
}
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
@@ -76,18 +112,26 @@ export class AccountServiceImplementation implements InternalAccountService {
}
}
private setAccountInfo(userId: UserId, accountInfo: AccountInfo) {
if (this.accounts.value[userId] == null) {
throw new Error("Account does not exist");
private setAccountInfo(userId: UserId, update: Partial<AccountInfo>) {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };
}
this.accountsState.update(
(accounts) => {
accounts[userId] = newAccountInfo(accounts[userId]);
return accounts;
},
{
// Avoid unnecessary updates
// TODO: Faster comparison, maybe include a hash on the objects?
shouldUpdate: (accounts) => {
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
// Avoid unnecessary updates
// TODO: Faster comparison, maybe include a hash on the objects?
if (JSON.stringify(this.accounts.value[userId]) === JSON.stringify(accountInfo)) {
return;
}
this.accounts.value[userId] = accountInfo;
this.accounts.next(this.accounts.value);
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
},
}
);
}
}

View File

@@ -30,6 +30,7 @@ import { AuthRequestLoginStrategy } from "../login-strategies/auth-request-login
import { PasswordLoginStrategy } from "../login-strategies/password-login.strategy";
import { SsoLoginStrategy } from "../login-strategies/sso-login.strategy";
import { UserApiLoginStrategy } from "../login-strategies/user-api-login.strategy";
import { WebAuthnLoginStrategy } from "../login-strategies/webauthn-login.strategy";
import { AuthResult } from "../models/domain/auth-result";
import { KdfConfig } from "../models/domain/kdf-config";
import {
@@ -37,6 +38,7 @@ import {
PasswordLoginCredentials,
SsoLoginCredentials,
UserApiLoginCredentials,
WebAuthnLoginCredentials,
} from "../models/domain/login-credentials";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
@@ -85,7 +87,8 @@ export class AuthService implements AuthServiceAbstraction {
| UserApiLoginStrategy
| PasswordLoginStrategy
| SsoLoginStrategy
| AuthRequestLoginStrategy;
| AuthRequestLoginStrategy
| WebAuthnLoginStrategy;
private sessionTimeout: any;
private pushNotificationSubject = new Subject<string>();
@@ -116,6 +119,7 @@ export class AuthService implements AuthServiceAbstraction {
| PasswordLoginCredentials
| SsoLoginCredentials
| AuthRequestLoginCredentials
| WebAuthnLoginCredentials
): Promise<AuthResult> {
this.clearState();
@@ -123,7 +127,8 @@ export class AuthService implements AuthServiceAbstraction {
| UserApiLoginStrategy
| PasswordLoginStrategy
| SsoLoginStrategy
| AuthRequestLoginStrategy;
| AuthRequestLoginStrategy
| WebAuthnLoginStrategy;
switch (credentials.type) {
case AuthenticationType.Password:
@@ -188,6 +193,19 @@ export class AuthService implements AuthServiceAbstraction {
this.deviceTrustCryptoService
);
break;
case AuthenticationType.WebAuthn:
strategy = new WebAuthnLoginStrategy(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService
);
break;
}
const result = await strategy.logIn(credentials as any);
@@ -353,6 +371,7 @@ export class AuthService implements AuthServiceAbstraction {
| PasswordLoginStrategy
| SsoLoginStrategy
| AuthRequestLoginStrategy
| WebAuthnLoginStrategy
) {
this.logInStrategy = strategy;
this.startSessionTimeout();

View File

@@ -0,0 +1,30 @@
import { Utils } from "../../../../platform/misc/utils";
import { WebAuthnLoginResponseRequest } from "./webauthn-login-response.request";
// base 64 strings
export interface WebAuthnLoginAssertionResponseData {
authenticatorData: string;
signature: string;
clientDataJSON: string;
userHandle: string;
}
export class WebAuthnLoginAssertionResponseRequest extends WebAuthnLoginResponseRequest {
response: WebAuthnLoginAssertionResponseData;
constructor(credential: PublicKeyCredential) {
super(credential);
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error("Invalid authenticator response");
}
this.response = {
authenticatorData: Utils.fromBufferToUrlB64(credential.response.authenticatorData),
signature: Utils.fromBufferToUrlB64(credential.response.signature),
clientDataJSON: Utils.fromBufferToUrlB64(credential.response.clientDataJSON),
userHandle: Utils.fromBufferToUrlB64(credential.response.userHandle),
};
}
}

View File

@@ -0,0 +1,19 @@
import { Utils } from "../../../../platform/misc/utils";
export abstract class WebAuthnLoginResponseRequest {
id: string;
rawId: string;
type: string;
extensions: Record<string, unknown>;
constructor(credential: PublicKeyCredential) {
this.id = credential.id;
this.rawId = Utils.fromBufferToUrlB64(credential.rawId);
this.type = credential.type;
// WARNING: do not add PRF information here by mapping
// credential.getClientExtensionResults() into the extensions property,
// as it will be sent to the server (leaking credentials).
this.extensions = {}; // Extensions are handled client-side
}
}

View File

@@ -0,0 +1,28 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { Utils } from "../../../../platform/misc/utils";
export class AssertionOptionsResponse
extends BaseResponse
implements PublicKeyCredentialRequestOptions
{
/** A list of credentials that the authenticator is allowed to use; only used for non-discoverable flow */
allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: BufferSource;
extensions?: AuthenticationExtensionsClientInputs;
rpId?: string;
timeout?: number;
userVerification?: UserVerificationRequirement;
constructor(response: unknown) {
super(response);
this.allowCredentials = this.getResponseProperty("allowCredentials")?.map((c: any) => ({
...c,
id: Utils.fromUrlB64ToArray(c.id).buffer,
}));
this.challenge = Utils.fromUrlB64ToArray(this.getResponseProperty("challenge"));
this.extensions = this.getResponseProperty("extensions");
this.rpId = this.getResponseProperty("rpId");
this.timeout = this.getResponseProperty("timeout");
this.userVerification = this.getResponseProperty("userVerification");
}
}

View File

@@ -0,0 +1,14 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { AssertionOptionsResponse } from "./assertion-options.response";
export class CredentialAssertionOptionsResponse extends BaseResponse {
options: AssertionOptionsResponse;
token: string;
constructor(response: unknown) {
super(response);
this.options = new AssertionOptionsResponse(this.getResponseProperty("options"));
this.token = this.getResponseProperty("token");
}
}

View File

@@ -0,0 +1,21 @@
import { ApiService } from "../../../abstractions/api.service";
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
import { CredentialAssertionOptionsResponse } from "./response/credential-assertion-options.response";
export class WebAuthnLoginApiService implements WebAuthnLoginApiServiceAbstraction {
constructor(private apiService: ApiService, private environmentService: EnvironmentService) {}
async getCredentialAssertionOptions(): Promise<CredentialAssertionOptionsResponse> {
const response = await this.apiService.send(
"GET",
`/accounts/webauthn/assertion-options`,
null,
false,
true,
this.environmentService.getIdentityUrl()
);
return new CredentialAssertionOptionsResponse(response);
}
}

View File

@@ -0,0 +1,32 @@
import { mock, MockProxy } from "jest-mock-extended";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { WebAuthnLoginPrfCryptoService } from "./webauthn-login-prf-crypto.service";
describe("WebAuthnLoginPrfCryptoService", () => {
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let service: WebAuthnLoginPrfCryptoService;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
service = new WebAuthnLoginPrfCryptoService(cryptoFunctionService);
});
describe("createSymmetricKeyFromPrf", () => {
it("should stretch the key to 64 bytes when given a key with 32 bytes", async () => {
cryptoFunctionService.hkdfExpand.mockImplementation((key, salt, length) =>
Promise.resolve(randomBytes(length))
);
const result = await service.createSymmetricKeyFromPrf(randomBytes(32));
expect(result.key.length).toBe(64);
});
});
});
/** This is a fake function that always returns the same byte sequence */
function randomBytes(length: number) {
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
}

View File

@@ -0,0 +1,26 @@
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { PrfKey, SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
const LoginWithPrfSalt = "passwordless-login";
export class WebAuthnLoginPrfCryptoService implements WebAuthnLoginPrfCryptoServiceAbstraction {
constructor(private cryptoFunctionService: CryptoFunctionService) {}
async getLoginWithPrfSalt(): Promise<ArrayBuffer> {
return await this.cryptoFunctionService.hash(LoginWithPrfSalt, "sha256");
}
async createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey> {
return (await this.stretchKey(new Uint8Array(prf))) as PrfKey;
}
private async stretchKey(key: Uint8Array): Promise<SymmetricCryptoKey> {
const newKey = new Uint8Array(64);
const encKey = await this.cryptoFunctionService.hkdfExpand(key, "enc", 32, "sha256");
const macKey = await this.cryptoFunctionService.hkdfExpand(key, "mac", 32, "sha256");
newKey.set(new Uint8Array(encKey));
newKey.set(new Uint8Array(macKey), 32);
return new SymmetricCryptoKey(newKey);
}
}

View File

@@ -0,0 +1,377 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { PrfKey, SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { AuthService } from "../../abstractions/auth.service";
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { AuthResult } from "../../models/domain/auth-result";
import { WebAuthnLoginCredentials } from "../../models/domain/login-credentials";
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request";
import { CredentialAssertionOptionsResponse } from "./response/credential-assertion-options.response";
import { WebAuthnLoginService } from "./webauthn-login.service";
describe("WebAuthnLoginService", () => {
let webAuthnLoginService: WebAuthnLoginService;
const webAuthnLoginApiService = mock<WebAuthnLoginApiServiceAbstraction>();
const authService = mock<AuthService>();
const configService = mock<ConfigServiceAbstraction>();
const webAuthnLoginPrfCryptoService = mock<WebAuthnLoginPrfCryptoServiceAbstraction>();
const navigatorCredentials = mock<CredentialsContainer>();
const logService = mock<LogService>();
let originalPublicKeyCredential!: PublicKeyCredential | any;
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
let originalNavigator!: Navigator;
beforeAll(() => {
// Save off the original classes so we can restore them after all tests are done if they exist
originalPublicKeyCredential = global.PublicKeyCredential;
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
// We must do this to make the mocked classes available for all the
// assertCredential(...) tests.
global.PublicKeyCredential = MockPublicKeyCredential;
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
// Save the original navigator
originalNavigator = global.window.navigator;
// Mock the window.navigator with mocked CredentialsContainer
Object.defineProperty(global.window, "navigator", {
value: {
...originalNavigator,
credentials: navigatorCredentials,
},
configurable: true,
});
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
// Restore global after all tests are done
global.PublicKeyCredential = originalPublicKeyCredential;
global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse;
// Restore the original navigator
Object.defineProperty(global.window, "navigator", {
value: originalNavigator,
configurable: true,
});
});
function createWebAuthnLoginService(config: { featureEnabled: boolean }): WebAuthnLoginService {
configService.getFeatureFlag$.mockReturnValue(of(config.featureEnabled));
return new WebAuthnLoginService(
webAuthnLoginApiService,
authService,
configService,
webAuthnLoginPrfCryptoService,
window,
logService
);
}
it("instantiates", () => {
webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
expect(webAuthnLoginService).not.toBeFalsy();
});
describe("enabled$", () => {
it("should emit true when feature flag for PasswordlessLogin is enabled", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
// Act & Assert
const result = await firstValueFrom(webAuthnLoginService.enabled$);
expect(result).toBe(true);
});
it("should emit false when feature flag for PasswordlessLogin is disabled", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: false });
// Act & Assert
const result = await firstValueFrom(webAuthnLoginService.enabled$);
expect(result).toBe(false);
});
});
describe("getCredentialAssertionOptions()", () => {
it("webAuthnLoginService returns WebAuthnLoginCredentialAssertionOptionsView when getCredentialAssertionOptions is called with the feature enabled", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
const challenge = "6CG3jqMCVASJVXySMi9KWw";
const token = "BWWebAuthnLoginAssertionOptions_CfDJ_2KBN892w";
const timeout = 60000;
const rpId = "localhost";
const allowCredentials = [] as PublicKeyCredentialDescriptor[];
const userVerification = "required";
const objectName = "webAuthnLoginAssertionOptions";
const mockedCredentialAssertionOptionsServerResponse = {
options: {
challenge: challenge,
timeout: timeout,
rpId: rpId,
allowCredentials: allowCredentials,
userVerification: userVerification,
status: "ok",
errorMessage: "",
},
token: token,
object: objectName,
};
const mockedCredentialAssertionOptionsResponse = new CredentialAssertionOptionsResponse(
mockedCredentialAssertionOptionsServerResponse
);
webAuthnLoginApiService.getCredentialAssertionOptions.mockResolvedValue(
mockedCredentialAssertionOptionsResponse
);
// Act
const result = await webAuthnLoginService.getCredentialAssertionOptions();
// Assert
expect(result).toBeInstanceOf(WebAuthnLoginCredentialAssertionOptionsView);
});
});
describe("assertCredential(...)", () => {
it("should assert the credential and return WebAuthnLoginAssertionView on success", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
const credentialAssertionOptions = buildCredentialAssertionOptions();
// Mock webAuthnUtils functions
const expectedSaltHex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
const saltArrayBuffer = Utils.hexStringToArrayBuffer(expectedSaltHex);
const publicKeyCredential = new MockPublicKeyCredential();
const prfResult: ArrayBuffer =
publicKeyCredential.getClientExtensionResults().prf?.results?.first;
const prfKey = new SymmetricCryptoKey(new Uint8Array(prfResult)) as PrfKey;
webAuthnLoginPrfCryptoService.getLoginWithPrfSalt.mockResolvedValue(saltArrayBuffer);
webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf.mockResolvedValue(prfKey);
// Mock implementations
navigatorCredentials.get.mockResolvedValue(publicKeyCredential);
// Act
const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions);
// Assert
expect(webAuthnLoginPrfCryptoService.getLoginWithPrfSalt).toHaveBeenCalled();
expect(navigatorCredentials.get).toHaveBeenCalledWith(
expect.objectContaining({
publicKey: expect.objectContaining({
...credentialAssertionOptions.options,
extensions: expect.objectContaining({
prf: expect.objectContaining({
eval: expect.objectContaining({
first: saltArrayBuffer,
}),
}),
}),
}),
})
);
expect(webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf).toHaveBeenCalledWith(
prfResult
);
expect(result).toBeInstanceOf(WebAuthnLoginCredentialAssertionView);
expect(result.token).toEqual(credentialAssertionOptions.token);
expect(result.deviceResponse).toBeInstanceOf(WebAuthnLoginAssertionResponseRequest);
expect(result.deviceResponse.id).toEqual(publicKeyCredential.id);
expect(result.deviceResponse.rawId).toEqual(publicKeyCredential.rawIdB64Str);
expect(result.deviceResponse.type).toEqual(publicKeyCredential.type);
// extensions being empty could change in the future but for now it is expected
expect(result.deviceResponse.extensions).toEqual({});
// but it should never contain any PRF information
expect("prf" in result.deviceResponse.extensions).toBe(false);
expect(result.deviceResponse.response).toEqual({
authenticatorData: publicKeyCredential.response.authenticatorDataB64Str,
clientDataJSON: publicKeyCredential.response.clientDataJSONB64Str,
signature: publicKeyCredential.response.signatureB64Str,
userHandle: publicKeyCredential.response.userHandleB64Str,
});
expect(result.prfKey).toEqual(prfKey);
});
it("should return undefined on non-PublicKeyCredential browser response", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
const credentialAssertionOptions = buildCredentialAssertionOptions();
// Mock the navigatorCredentials.get to return null
navigatorCredentials.get.mockResolvedValue(null);
// Act
const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions);
// Assert
expect(result).toBeUndefined();
});
it("should log an error and return undefined when navigatorCredentials.get throws an error", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
const credentialAssertionOptions = buildCredentialAssertionOptions();
// Mock navigatorCredentials.get to throw an error
const errorMessage = "Simulated error";
navigatorCredentials.get.mockRejectedValue(new Error(errorMessage));
// Spy on logService.error
const logServiceErrorSpy = jest.spyOn(logService, "error");
// Act
const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions);
// Assert
expect(result).toBeUndefined();
expect(logServiceErrorSpy).toHaveBeenCalledWith(expect.any(Error));
});
});
describe("logIn(...)", () => {
function buildWebAuthnLoginCredentialAssertionView(): WebAuthnLoginCredentialAssertionView {
const publicKeyCredential = new MockPublicKeyCredential();
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential);
const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey;
return new WebAuthnLoginCredentialAssertionView("mockToken", deviceResponse, prfKey);
}
it("should accept an assertion with a signed challenge and use it to try and login", async () => {
// Arrange
const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true });
const assertion = buildWebAuthnLoginCredentialAssertionView();
const mockAuthResult: AuthResult = new AuthResult();
jest.spyOn(authService, "logIn").mockResolvedValue(mockAuthResult);
// Act
const result = await webAuthnLoginService.logIn(assertion);
// Assert
expect(result).toEqual(mockAuthResult);
const callArguments = authService.logIn.mock.calls[0];
expect(callArguments[0]).toBeInstanceOf(WebAuthnLoginCredentials);
});
});
});
// Test helpers
function randomBytes(length: number): Uint8Array {
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
}
// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts
// so we need to mock them and assign them to the global object to make them available
// for the tests
class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
clientDataJSON: ArrayBuffer = randomBytes(32).buffer;
authenticatorData: ArrayBuffer = randomBytes(196).buffer;
signature: ArrayBuffer = randomBytes(72).buffer;
userHandle: ArrayBuffer = randomBytes(16).buffer;
clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON);
authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData);
signatureB64Str = Utils.fromBufferToUrlB64(this.signature);
userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle);
}
class MockPublicKeyCredential implements PublicKeyCredential {
authenticatorAttachment = "cross-platform";
id = "mockCredentialId";
type = "public-key";
rawId: ArrayBuffer = randomBytes(32).buffer;
rawIdB64Str = Utils.fromBufferToUrlB64(this.rawId);
response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse();
// Use random 64 character hex string (32 bytes - matters for symmetric key creation)
// to represent the prf key binary data and convert to ArrayBuffer
// Creating the array buffer from a known hex value allows us to
// assert on the value in tests
private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
);
getClientExtensionResults(): any {
return {
prf: {
results: {
first: this.prfKeyArrayBuffer,
},
},
};
}
static isConditionalMediationAvailable(): Promise<boolean> {
return Promise.resolve(false);
}
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
return Promise.resolve(false);
}
}
function buildCredentialAssertionOptions(): WebAuthnLoginCredentialAssertionOptionsView {
// Mock credential assertion options
const challenge = "6CG3jqMCVASJVXySMi9KWw";
const token = "BWWebAuthnLoginAssertionOptions_CfDJ_2KBN892w";
const timeout = 60000;
const rpId = "localhost";
const allowCredentials = [] as PublicKeyCredentialDescriptor[];
const userVerification = "required";
const objectName = "webAuthnLoginAssertionOptions";
const credentialAssertionOptionsServerResponse = {
options: {
challenge: challenge,
timeout: timeout,
rpId: rpId,
allowCredentials: allowCredentials,
userVerification: userVerification,
status: "ok",
errorMessage: "",
},
token: token,
object: objectName,
};
const credentialAssertionOptionsResponse = new CredentialAssertionOptionsResponse(
credentialAssertionOptionsServerResponse
);
return new WebAuthnLoginCredentialAssertionOptionsView(
credentialAssertionOptionsResponse.options,
credentialAssertionOptionsResponse.token
);
}

View File

@@ -0,0 +1,93 @@
import { Observable } from "rxjs";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
import { LogService } from "../../../platform/abstractions/log.service";
import { PrfKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { AuthService } from "../../abstractions/auth.service";
import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "../../abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "../../models/domain/auth-result";
import { WebAuthnLoginCredentials } from "../../models/domain/login-credentials";
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request";
export class WebAuthnLoginService implements WebAuthnLoginServiceAbstraction {
readonly enabled$: Observable<boolean>;
private navigatorCredentials: CredentialsContainer;
constructor(
private webAuthnLoginApiService: WebAuthnLoginApiServiceAbstraction,
private authService: AuthService,
private configService: ConfigServiceAbstraction,
private webAuthnLoginPrfCryptoService: WebAuthnLoginPrfCryptoServiceAbstraction,
private window: Window,
private logService?: LogService
) {
this.enabled$ = this.configService.getFeatureFlag$(FeatureFlag.PasswordlessLogin, false);
this.navigatorCredentials = this.window.navigator.credentials;
}
async getCredentialAssertionOptions(): Promise<WebAuthnLoginCredentialAssertionOptionsView> {
const response = await this.webAuthnLoginApiService.getCredentialAssertionOptions();
return new WebAuthnLoginCredentialAssertionOptionsView(response.options, response.token);
}
async assertCredential(
credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView
): Promise<WebAuthnLoginCredentialAssertionView> {
const nativeOptions: CredentialRequestOptions = {
publicKey: credentialAssertionOptions.options,
};
// TODO: Remove `any` when typescript typings add support for PRF
nativeOptions.publicKey.extensions = {
prf: { eval: { first: await this.webAuthnLoginPrfCryptoService.getLoginWithPrfSalt() } },
} as any;
try {
const response = await this.navigatorCredentials.get(nativeOptions);
if (!(response instanceof PublicKeyCredential)) {
return undefined;
}
// TODO: Remove `any` when typescript typings add support for PRF
const prfResult = (response.getClientExtensionResults() as any).prf?.results?.first;
let symmetricPrfKey: PrfKey | undefined;
if (prfResult != undefined) {
symmetricPrfKey = await this.webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf(
prfResult
);
}
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(response);
// Verify that we aren't going to send PRF information to the server in any case.
// Note: this will only happen if a dev has done something wrong.
if ("prf" in deviceResponse.extensions) {
throw new Error("PRF information is not allowed to be sent to the server.");
}
return new WebAuthnLoginCredentialAssertionView(
credentialAssertionOptions.token,
deviceResponse,
symmetricPrfKey
);
} catch (error) {
this.logService?.error(error);
return undefined;
}
}
async logIn(assertion: WebAuthnLoginCredentialAssertionView): Promise<AuthResult> {
const credential = new WebAuthnLoginCredentials(
assertion.token,
assertion.deviceResponse,
assertion.prfKey
);
const result = await this.authService.logIn(credential);
return result;
}
}

View File

@@ -1,10 +1,10 @@
export enum FeatureFlag {
DisplayEuEnvironmentFlag = "display-eu-environment",
DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning",
Fido2VaultCredentials = "fido2-vault-credentials",
TrustedDeviceEncryption = "trusted-device-encryption",
PasswordlessLogin = "passwordless-login",
AutofillV2 = "autofill-v2",
AutofillOverlay = "autofill-overlay",
BrowserFilelessImport = "browser-fileless-import",
ItemShare = "item-share",
FlexibleCollections = "flexible-collections",

View File

@@ -19,6 +19,7 @@ export class Fido2CredentialExport {
req.keyValue = "keyValue";
req.rpId = "rpId";
req.userHandle = "userHandle";
req.userName = "userName";
req.counter = "counter";
req.rpName = "rpName";
req.userDisplayName = "userDisplayName";
@@ -41,6 +42,7 @@ export class Fido2CredentialExport {
view.keyValue = req.keyValue;
view.rpId = req.rpId;
view.userHandle = req.userHandle;
view.userName = req.userName;
view.counter = parseInt(req.counter);
view.rpName = req.rpName;
view.userDisplayName = req.userDisplayName;
@@ -63,6 +65,7 @@ export class Fido2CredentialExport {
domain.keyValue = req.keyValue != null ? new EncString(req.keyValue) : null;
domain.rpId = req.rpId != null ? new EncString(req.rpId) : null;
domain.userHandle = req.userHandle != null ? new EncString(req.userHandle) : null;
domain.userName = req.userName != null ? new EncString(req.userName) : null;
domain.counter = req.counter != null ? new EncString(req.counter) : null;
domain.rpName = req.rpName != null ? new EncString(req.rpName) : null;
domain.userDisplayName =
@@ -79,6 +82,7 @@ export class Fido2CredentialExport {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: string;
rpName: string;
userDisplayName: string;
@@ -103,6 +107,7 @@ export class Fido2CredentialExport {
this.keyValue = o.keyValue;
this.rpId = o.rpId;
this.userHandle = o.userHandle;
this.userName = o.userName;
this.counter = String(o.counter);
this.rpName = o.rpName;
this.userDisplayName = o.userDisplayName;
@@ -115,6 +120,7 @@ export class Fido2CredentialExport {
this.keyValue = o.keyValue?.encryptedString;
this.rpId = o.rpId?.encryptedString;
this.userHandle = o.userHandle?.encryptedString;
this.userName = o.userName?.encryptedString;
this.counter = o.counter?.encryptedString;
this.rpName = o.rpName?.encryptedString;
this.userDisplayName = o.userDisplayName?.encryptedString;

View File

@@ -243,6 +243,8 @@ export abstract class StateService<T extends Account = Account> {
value: boolean,
options?: StorageOptions
) => Promise<void>;
getEnablePasskeys: (options?: StorageOptions) => Promise<boolean>;
setEnablePasskeys: (value: boolean, options?: StorageOptions) => Promise<void>;
getDisableContextMenuItem: (options?: StorageOptions) => Promise<boolean>;
setDisableContextMenuItem: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
@@ -285,6 +287,8 @@ export abstract class StateService<T extends Account = Account> {
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>;
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
getAutoFillOverlayVisibility: (options?: StorageOptions) => Promise<number>;
setAutoFillOverlayVisibility: (value: number, options?: StorageOptions) => Promise<void>;
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
@@ -428,8 +432,6 @@ export abstract class StateService<T extends Account = Account> {
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
getEmergencyAccessInvitation: (options?: StorageOptions) => Promise<any>;
setEmergencyAccessInvitation: (value: any, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
@@ -530,4 +532,17 @@ export abstract class StateService<T extends Account = Account> {
value: Record<string, Record<string, boolean>>,
options?: StorageOptions
) => Promise<void>;
/**
* fetches string value of URL user tried to navigate to while unauthenticated.
* @param options Defines the storage options for the URL; Defaults to session Storage.
* @returns route called prior to successful login.
*/
getDeepLinkRedirectUrl: (options?: StorageOptions) => Promise<string>;
/**
* Store URL in session storage by default, but can be configured. Developed to handle
* unauthN interrupted navigation.
* @param url URL of route
* @param options Defines the storage options for the URL; Defaults to session Storage.
*/
setDeepLinkRedirectUrl: (url: string, options?: StorageOptions) => Promise<void>;
}

View File

@@ -8,14 +8,17 @@ export type StorageUpdate = {
updateType: StorageUpdateType;
};
export abstract class AbstractStorageService {
abstract get valuesRequireDeserialization(): boolean;
export interface ObservableStorageService {
/**
* Provides an {@link Observable} that represents a stream of updates that
* have happened in this storage service or in the storage this service provides
* an interface to.
*/
abstract get updates$(): Observable<StorageUpdate>;
get updates$(): Observable<StorageUpdate>;
}
export abstract class AbstractStorageService {
abstract get valuesRequireDeserialization(): boolean;
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;

View File

@@ -244,6 +244,243 @@ describe("Utils Service", () => {
});
});
function runInBothEnvironments(testName: string, testFunc: () => void): void {
const environments = [
{ isNode: true, name: "Node environment" },
{ isNode: false, name: "non-Node environment" },
];
environments.forEach((env) => {
it(`${testName} in ${env.name}`, () => {
Utils.isNode = env.isNode;
testFunc();
});
});
}
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
describe("fromBufferToB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert an ArrayBuffer to a b64 string", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const b64String = Utils.fromBufferToB64(buffer);
expect(b64String).toBe(b64HelloWorldString);
});
runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => {
const buffer = new Uint8Array([]).buffer;
const b64String = Utils.fromBufferToB64(buffer);
expect(b64String).toBe("");
});
runInBothEnvironments("should return null for null input", () => {
const b64String = Utils.fromBufferToB64(null);
expect(b64String).toBeNull();
});
});
describe("fromB64ToArray(...)", () => {
runInBothEnvironments("should convert a b64 string to an Uint8Array", () => {
const expectedArray = new Uint8Array(asciiHelloWorldArray);
const resultArray = Utils.fromB64ToArray(b64HelloWorldString);
expect(resultArray).toEqual(expectedArray);
});
runInBothEnvironments("should return null for null input", () => {
const expectedArray = Utils.fromB64ToArray(null);
expect(expectedArray).toBeNull();
});
// Hmmm... this passes in browser but not in node
// as node doesn't throw an error for invalid base64 strings.
// It instead produces a buffer with the bytes that could be decoded
// and ignores the rest after an invalid character.
// https://github.com/nodejs/node/issues/8569
// This could be mitigated with a regex check before decoding...
// runInBothEnvironments("should throw an error for invalid base64 string", () => {
// const invalidB64String = "invalid base64";
// expect(() => {
// Utils.fromB64ToArrayBuffer(invalidB64String);
// }).toThrow();
// });
});
describe("Base64 and ArrayBuffer round trip conversions", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments(
"should correctly round trip convert from ArrayBuffer to base64 and back",
() => {
// Start with a known ArrayBuffer
const originalArray = new Uint8Array(asciiHelloWorldArray);
const originalBuffer = originalArray.buffer;
// Convert ArrayBuffer to a base64 string
const b64String = Utils.fromBufferToB64(originalBuffer);
// Convert that base64 string back to an ArrayBuffer
const roundTrippedBuffer = Utils.fromB64ToArray(b64String).buffer;
const roundTrippedArray = new Uint8Array(roundTrippedBuffer);
// Compare the original ArrayBuffer with the round-tripped ArrayBuffer
expect(roundTrippedArray).toEqual(originalArray);
}
);
runInBothEnvironments(
"should correctly round trip convert from base64 to ArrayBuffer and back",
() => {
// Convert known base64 string to ArrayBuffer
const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer;
// Convert the ArrayBuffer back to a base64 string
const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64);
// Compare the original base64 string with the round-tripped base64 string
expect(roundTrippedB64String).toBe(b64HelloWorldString);
}
);
});
describe("fromBufferToHex(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
/**
* Creates a string that represents a sequence of hexadecimal byte values in ascending order.
* Each byte value corresponds to its position in the sequence.
*
* @param {number} length - The number of bytes to include in the string.
* @return {string} A string of hexadecimal byte values in sequential order.
*
* @example
* // Returns '000102030405060708090a0b0c0d0e0f101112...ff'
* createSequentialHexByteString(256);
*/
function createSequentialHexByteString(length: number) {
let sequentialHexString = "";
for (let i = 0; i < length; i++) {
// Convert the number to a hex string and pad with leading zeros if necessary
const hexByte = i.toString(16).padStart(2, "0");
sequentialHexString += hexByte;
}
return sequentialHexString;
}
runInBothEnvironments("should convert an ArrayBuffer to a hex string", () => {
const buffer = new Uint8Array([0, 1, 10, 16, 255]).buffer;
const hexString = Utils.fromBufferToHex(buffer);
expect(hexString).toBe("00010a10ff");
});
runInBothEnvironments("should handle an empty buffer", () => {
const buffer = new ArrayBuffer(0);
const hexString = Utils.fromBufferToHex(buffer);
expect(hexString).toBe("");
});
runInBothEnvironments(
"should correctly convert a large buffer containing a repeating sequence of all 256 unique byte values to hex",
() => {
const largeBuffer = new Uint8Array(1024).map((_, index) => index % 256).buffer;
const hexString = Utils.fromBufferToHex(largeBuffer);
const expectedHexString = createSequentialHexByteString(256).repeat(4);
expect(hexString).toBe(expectedHexString);
}
);
runInBothEnvironments("should correctly convert a buffer with a single byte to hex", () => {
const singleByteBuffer = new Uint8Array([0xab]).buffer;
const hexString = Utils.fromBufferToHex(singleByteBuffer);
expect(hexString).toBe("ab");
});
runInBothEnvironments(
"should correctly convert a buffer with an odd number of bytes to hex",
() => {
const oddByteBuffer = new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89]).buffer;
const hexString = Utils.fromBufferToHex(oddByteBuffer);
expect(hexString).toBe("0123456789");
}
);
});
describe("hexStringToArrayBuffer(...)", () => {
test("should convert a hex string to an ArrayBuffer correctly", () => {
const hexString = "ff0a1b"; // Arbitrary hex string
const expectedResult = new Uint8Array([255, 10, 27]).buffer;
const result = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult));
});
test("should throw an error if the hex string length is not even", () => {
const hexString = "abc"; // Odd number of characters
expect(() => {
Utils.hexStringToArrayBuffer(hexString);
}).toThrow("HexString has to be an even length");
});
test("should convert a hex string representing zero to an ArrayBuffer correctly", () => {
const hexString = "00";
const expectedResult = new Uint8Array([0]).buffer;
const result = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult));
});
test("should handle an empty hex string", () => {
const hexString = "";
const expectedResult = new ArrayBuffer(0);
const result = Utils.hexStringToArrayBuffer(hexString);
expect(result).toEqual(expectedResult);
});
test("should convert a long hex string to an ArrayBuffer correctly", () => {
const hexString = "0102030405060708090a0b0c0d0e0f";
const expectedResult = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
.buffer;
const result = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult));
});
});
describe("ArrayBuffer and Hex string round trip conversions", () => {
runInBothEnvironments(
"should allow round-trip conversion from ArrayBuffer to hex and back",
() => {
const originalBuffer = new Uint8Array([10, 20, 30, 40, 255]).buffer; // arbitrary buffer
const hexString = Utils.fromBufferToHex(originalBuffer);
const roundTripBuffer = Utils.hexStringToArrayBuffer(hexString);
expect(new Uint8Array(roundTripBuffer)).toEqual(new Uint8Array(originalBuffer));
}
);
runInBothEnvironments(
"should allow round-trip conversion from hex to ArrayBuffer and back",
() => {
const hexString = "0a141e28ff"; // arbitrary hex string
const bufferFromHex = Utils.hexStringToArrayBuffer(hexString);
const roundTripHexString = Utils.fromBufferToHex(bufferFromHex);
expect(roundTripHexString).toBe(hexString);
}
);
});
describe("mapToRecord", () => {
it("should handle null", () => {
expect(Utils.mapToRecord(null)).toEqual(null);

View File

@@ -170,6 +170,43 @@ export class Utils {
}
}
/**
* Converts a hex string to an ArrayBuffer.
* Note: this doesn't need any Node specific code as parseInt() / ArrayBuffer / Uint8Array
* work the same in Node and the browser.
* @param {string} hexString - A string of hexadecimal characters.
* @returns {ArrayBuffer} The ArrayBuffer representation of the hex string.
*/
static hexStringToArrayBuffer(hexString: string): ArrayBuffer {
// Check if the hexString has an even length, as each hex digit represents half a byte (4 bits),
// and it takes two hex digits to represent a full byte (8 bits).
if (hexString.length % 2 !== 0) {
throw "HexString has to be an even length";
}
// Create an ArrayBuffer with a length that is half the length of the hex string,
// because each pair of hex digits will become a single byte.
const arrayBuffer = new ArrayBuffer(hexString.length / 2);
// Create a Uint8Array view on top of the ArrayBuffer (each position represents a byte)
// as ArrayBuffers cannot be edited directly.
const uint8Array = new Uint8Array(arrayBuffer);
// Loop through the bytes
for (let i = 0; i < uint8Array.length; i++) {
// Extract two hex characters (1 byte)
const hexByte = hexString.substr(i * 2, 2);
// Convert hexByte into a decimal value from base 16. (ex: ff --> 255)
const byteValue = parseInt(hexByte, 16);
// Place the byte value into the uint8Array
uint8Array[i] = byteValue;
}
return arrayBuffer;
}
static fromUrlB64ToB64(urlB64Str: string): string {
let output = urlB64Str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {

View File

@@ -7,7 +7,6 @@ export class GlobalState {
installedVersion?: string;
locale?: string;
organizationInvitation?: any;
emergencyAccessInvitation?: any;
ssoCodeVerifier?: string;
ssoOrganizationIdentifier?: string;
ssoState?: string;
@@ -37,7 +36,10 @@ export class GlobalState {
enableDuckDuckGoBrowserIntegration?: boolean;
region?: string;
neverDomains?: { [id: string]: unknown };
enablePasskeys?: boolean;
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
disableContextMenuItem?: boolean;
autoFillOverlayVisibility?: number;
deepLinkRedirectUrl?: string;
}

View File

@@ -3,7 +3,7 @@ import { Subject } from "rxjs";
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service";
export class MemoryStorageService extends AbstractMemoryStorageService {
private store = new Map<string, unknown>();
protected store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {

View File

@@ -1,6 +1,7 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { AutofillOverlayVisibility } from "../../../../../apps/browser/src/autofill/utils/autofill-overlay.enum";
import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
@@ -1212,6 +1213,24 @@ export class StateService<
);
}
async getEnablePasskeys(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.enablePasskeys ?? true
);
}
async setEnablePasskeys(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
globals.enablePasskeys = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
async getDisableContextMenuItem(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -1526,6 +1545,27 @@ export class StateService<
);
}
async getAutoFillOverlayVisibility(options?: StorageOptions): Promise<number> {
return (
(
await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
)
)?.autoFillOverlayVisibility ?? AutofillOverlayVisibility.OnFieldFocus
);
}
async setAutoFillOverlayVisibility(value: number, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
globals.autoFillOverlayVisibility = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}
async getEnableAutoFillOnPageLoad(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -2364,23 +2404,6 @@ export class StateService<
);
}
async getEmergencyAccessInvitation(options?: StorageOptions): Promise<any> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.emergencyAccessInvitation;
}
async setEmergencyAccessInvitation(value: any, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
globals.emergencyAccessInvitation = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
@@ -2862,6 +2885,23 @@ export class StateService<
);
}
async getDeepLinkRedirectUrl(options?: StorageOptions): Promise<string> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.deepLinkRedirectUrl;
}
async setDeepLinkRedirectUrl(url: string, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
globals.deepLinkRedirectUrl = url;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions())
);
}
protected async getGlobals(options: StorageOptions): Promise<TGlobalState> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {

View File

@@ -1,6 +1,7 @@
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { GlobalStateProvider } from "../global-state.provider";
@@ -13,8 +14,8 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
private globalStateCache: Record<string, GlobalState<unknown>> = {};
constructor(
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService
private memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
private diskStorage: AbstractStorageService & ObservableStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {

View File

@@ -10,7 +10,10 @@ import {
timeout,
} from "rxjs";
import { AbstractStorageService } from "../../abstractions/storage.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { GlobalState } from "../global-state";
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
@@ -29,7 +32,7 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
constructor(
private keyDefinition: KeyDefinition<T>,
private chosenLocation: AbstractStorageService
private chosenLocation: AbstractStorageService & ObservableStorageService
) {
this.storageKey = globalKeyBuilder(this.keyDefinition);

View File

@@ -3,6 +3,7 @@ import { EncryptService } from "../../abstractions/encrypt.service";
import {
AbstractMemoryStorageService,
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { KeyDefinition } from "../key-definition";
import { StorageLocation } from "../state-definition";
@@ -17,8 +18,8 @@ export class DefaultUserStateProvider implements UserStateProvider {
constructor(
protected accountService: AccountService,
protected encryptService: EncryptService,
protected memoryStorage: AbstractMemoryStorageService,
protected diskStorage: AbstractStorageService
protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
protected diskStorage: AbstractStorageService & ObservableStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {

View File

@@ -15,7 +15,10 @@ import {
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { EncryptService } from "../../abstractions/encrypt.service";
import { AbstractStorageService } from "../../abstractions/storage.service";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DerivedUserState } from "../derived-user-state";
import { KeyDefinition, userKeyBuilder } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
@@ -40,7 +43,7 @@ export class DefaultUserState<T> implements UserState<T> {
protected keyDefinition: KeyDefinition<T>,
private accountService: AccountService,
private encryptService: EncryptService,
private chosenStorageLocation: AbstractStorageService
private chosenStorageLocation: AbstractStorageService & ObservableStorageService
) {
this.formattedKey$ = this.accountService.activeAccount$.pipe(
map((account) =>

View File

@@ -1,3 +1,7 @@
export { DerivedUserState } from "./derived-user-state";
export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider";
export { DefaultUserStateProvider } from "./implementations/default-user-state.provider";
export { GlobalState } from "./global-state";
export { GlobalStateProvider } from "./global-state.provider";
export { UserState } from "./user-state";
export { UserStateProvider } from "./user-state.provider";
export * from "./key-definitions";

View File

@@ -0,0 +1,18 @@
import { AccountInfo } from "../../auth/abstractions/account.service";
import { AccountsDeserializer } from "../../auth/services/account.service";
import { UserId } from "../../types/guid";
import { KeyDefinition } from "./key-definition";
import { StateDefinition } from "./state-definition";
const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const ACCOUNT_ACCOUNTS = new KeyDefinition<Record<UserId, AccountInfo>>(
ACCOUNT_MEMORY,
"accounts",
{
deserializer: (obj) => AccountsDeserializer(obj),
}
);
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", {
deserializer: (id: UserId) => id,
});

View File

@@ -42,6 +42,7 @@ import { PasswordTokenRequest } from "../auth/models/request/identity-token/pass
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
import { TokenTwoFactorRequest } from "../auth/models/request/identity-token/token-two-factor.request";
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
@@ -180,7 +181,11 @@ export class ApiService implements ApiServiceAbstraction {
// Auth APIs
async postIdentityToken(
request: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest
request:
| UserApiTokenRequest
| PasswordTokenRequest
| SsoTokenRequest
| WebAuthnLoginTokenRequest
): Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse> {
const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",

View File

@@ -74,6 +74,14 @@ export class SettingsService implements SettingsServiceAbstraction {
return this._disableFavicon.getValue();
}
async setAutoFillOverlayVisibility(value: number): Promise<void> {
return await this.stateService.setAutoFillOverlayVisibility(value);
}
async getAutoFillOverlayVisibility(): Promise<number> {
return await this.stateService.getAutoFillOverlayVisibility();
}
async clear(userId?: string): Promise<void> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._settings.next({});

View File

@@ -103,6 +103,7 @@ export interface CreateCredentialParams {
user: {
id: string; // b64 encoded
displayName: string;
name: string;
};
/** Forwarded to user interface */
fallbackSupported: boolean;

View File

@@ -8,6 +8,7 @@ export class Fido2CredentialApi extends BaseResponse {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: string;
rpName: string;
userDisplayName: string;
@@ -27,6 +28,7 @@ export class Fido2CredentialApi extends BaseResponse {
this.keyValue = this.getResponseProperty("keyValue");
this.rpId = this.getResponseProperty("RpId");
this.userHandle = this.getResponseProperty("UserHandle");
this.userName = this.getResponseProperty("UserName");
this.counter = this.getResponseProperty("Counter");
this.rpName = this.getResponseProperty("RpName");
this.userDisplayName = this.getResponseProperty("UserDisplayName");

View File

@@ -0,0 +1,85 @@
import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
export function buildCipherIcon(
iconsServerUrl: string,
cipher: CipherView,
isFaviconDisabled: boolean
) {
const imageEnabled = !isFaviconDisabled;
let icon;
let image;
let fallbackImage = "";
const cardIcons: Record<string, string> = {
Visa: "card-visa",
Mastercard: "card-mastercard",
Amex: "card-amex",
Discover: "card-discover",
"Diners Club": "card-diners-club",
JCB: "card-jcb",
Maestro: "card-maestro",
UnionPay: "card-union-pay",
RuPay: "card-ru-pay",
};
switch (cipher.type) {
case CipherType.Login:
icon = "bwi-globe";
if (cipher.login.uri) {
let hostnameUri = cipher.login.uri;
let isWebsite = false;
if (hostnameUri.indexOf("androidapp://") === 0) {
icon = "bwi-android";
image = null;
} else if (hostnameUri.indexOf("iosapp://") === 0) {
icon = "bwi-apple";
image = null;
} else if (
imageEnabled &&
hostnameUri.indexOf("://") === -1 &&
hostnameUri.indexOf(".") > -1
) {
hostnameUri = `http://${hostnameUri}`;
isWebsite = true;
} else if (imageEnabled) {
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (imageEnabled && isWebsite) {
try {
image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;
fallbackImage = "images/bwi-globe.png";
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null.
}
}
} else {
image = null;
}
break;
case CipherType.SecureNote:
icon = "bwi-sticky-note";
break;
case CipherType.Card:
icon = "bwi-credit-card";
if (imageEnabled && cipher.card.brand in cardIcons) {
icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`;
}
break;
case CipherType.Identity:
icon = "bwi-id-card";
break;
default:
break;
}
return {
imageEnabled,
image,
fallbackImage,
icon,
};
}

View File

@@ -8,6 +8,7 @@ export class Fido2CredentialData {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: string;
rpName: string;
userDisplayName: string;
@@ -26,6 +27,7 @@ export class Fido2CredentialData {
this.keyValue = data.keyValue;
this.rpId = data.rpId;
this.userHandle = data.userHandle;
this.userName = data.userName;
this.counter = data.counter;
this.rpName = data.rpName;
this.userDisplayName = data.userDisplayName;

View File

@@ -25,6 +25,7 @@ describe("Fido2Credential", () => {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
counter: null,
@@ -42,6 +43,7 @@ describe("Fido2Credential", () => {
keyValue: "keyValue",
rpId: "rpId",
userHandle: "userHandle",
userName: "userName",
counter: "counter",
rpName: "rpName",
userDisplayName: "userDisplayName",
@@ -58,6 +60,7 @@ describe("Fido2Credential", () => {
keyValue: { encryptedString: "keyValue", encryptionType: 0 },
rpId: { encryptedString: "rpId", encryptionType: 0 },
userHandle: { encryptedString: "userHandle", encryptionType: 0 },
userName: { encryptedString: "userName", encryptionType: 0 },
counter: { encryptedString: "counter", encryptionType: 0 },
rpName: { encryptedString: "rpName", encryptionType: 0 },
userDisplayName: { encryptedString: "userDisplayName", encryptionType: 0 },
@@ -85,6 +88,7 @@ describe("Fido2Credential", () => {
credential.keyValue = mockEnc("keyValue");
credential.rpId = mockEnc("rpId");
credential.userHandle = mockEnc("userHandle");
credential.userName = mockEnc("userName");
credential.counter = mockEnc("2");
credential.rpName = mockEnc("rpName");
credential.userDisplayName = mockEnc("userDisplayName");
@@ -101,6 +105,7 @@ describe("Fido2Credential", () => {
keyValue: "keyValue",
rpId: "rpId",
userHandle: "userHandle",
userName: "userName",
rpName: "rpName",
userDisplayName: "userDisplayName",
counter: 2,
@@ -120,6 +125,7 @@ describe("Fido2Credential", () => {
keyValue: "keyValue",
rpId: "rpId",
userHandle: "userHandle",
userName: "userName",
counter: "2",
rpName: "rpName",
userDisplayName: "userDisplayName",
@@ -144,6 +150,7 @@ describe("Fido2Credential", () => {
credential.keyValue = createEncryptedEncString("keyValue");
credential.rpId = createEncryptedEncString("rpId");
credential.userHandle = createEncryptedEncString("userHandle");
credential.userName = createEncryptedEncString("userName");
credential.counter = createEncryptedEncString("2");
credential.rpName = createEncryptedEncString("rpName");
credential.userDisplayName = createEncryptedEncString("userDisplayName");

View File

@@ -14,6 +14,7 @@ export class Fido2Credential extends Domain {
keyValue: EncString;
rpId: EncString;
userHandle: EncString;
userName: EncString;
counter: EncString;
rpName: EncString;
userDisplayName: EncString;
@@ -37,6 +38,7 @@ export class Fido2Credential extends Domain {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
counter: null,
rpName: null,
userDisplayName: null,
@@ -58,6 +60,7 @@ export class Fido2Credential extends Domain {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
discoverable: null,
@@ -102,6 +105,7 @@ export class Fido2Credential extends Domain {
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
counter: null,
rpName: null,
userDisplayName: null,
@@ -122,6 +126,7 @@ export class Fido2Credential extends Domain {
const keyValue = EncString.fromJSON(obj.keyValue);
const rpId = EncString.fromJSON(obj.rpId);
const userHandle = EncString.fromJSON(obj.userHandle);
const userName = EncString.fromJSON(obj.userName);
const counter = EncString.fromJSON(obj.counter);
const rpName = EncString.fromJSON(obj.rpName);
const userDisplayName = EncString.fromJSON(obj.userDisplayName);
@@ -136,6 +141,7 @@ export class Fido2Credential extends Domain {
keyValue,
rpId,
userHandle,
userName,
counter,
rpName,
userDisplayName,

View File

@@ -135,6 +135,7 @@ describe("Login DTO", () => {
keyValue: "keyValue" as EncryptedString,
rpId: "rpId" as EncryptedString,
userHandle: "userHandle" as EncryptedString,
userName: "userName" as EncryptedString,
counter: "counter" as EncryptedString,
rpName: "rpName" as EncryptedString,
userDisplayName: "userDisplayName" as EncryptedString,
@@ -159,6 +160,7 @@ describe("Login DTO", () => {
keyValue: "keyValue_fromJSON",
rpId: "rpId_fromJSON",
userHandle: "userHandle_fromJSON",
userName: "userName_fromJSON",
counter: "counter_fromJSON",
rpName: "rpName_fromJSON",
userDisplayName: "userDisplayName_fromJSON",
@@ -185,6 +187,7 @@ function initializeFido2Credential<T extends Fido2CredentialLike>(key: T): T {
key.keyValue = "keyValue";
key.rpId = "rpId";
key.userHandle = "userHandle";
key.userName = "userName";
key.counter = "counter";
key.rpName = "rpName";
key.userDisplayName = "userDisplayName";
@@ -202,6 +205,7 @@ function encryptFido2Credential(key: Fido2CredentialLike): Fido2Credential {
encrypted.keyValue = { encryptedString: key.keyValue, encryptionType: 0 } as EncString;
encrypted.rpId = { encryptedString: key.rpId, encryptionType: 0 } as EncString;
encrypted.userHandle = { encryptedString: key.userHandle, encryptionType: 0 } as EncString;
encrypted.userName = { encryptedString: key.userName, encryptionType: 0 } as EncString;
encrypted.counter = { encryptedString: key.counter, encryptionType: 0 } as EncString;
encrypted.rpName = { encryptedString: key.rpName, encryptionType: 0 } as EncString;
encrypted.userDisplayName = {

View File

@@ -81,6 +81,7 @@ export class CipherRequest {
keyApi.rpName = key.rpName != null ? key.rpName.encryptedString : null;
keyApi.counter = key.counter != null ? key.counter.encryptedString : null;
keyApi.userHandle = key.userHandle != null ? key.userHandle.encryptedString : null;
keyApi.userName = key.userName != null ? key.userName.encryptedString : null;
keyApi.userDisplayName =
key.userDisplayName != null ? key.userDisplayName.encryptedString : null;
keyApi.discoverable =

View File

@@ -84,7 +84,7 @@ export class CipherView implements View, InitializerMetadata {
}
get subTitle(): string {
return this.item.subTitle;
return this.item?.subTitle;
}
get hasPasswordHistory(): boolean {
@@ -124,7 +124,7 @@ export class CipherView implements View, InitializerMetadata {
}
get linkedFieldOptions() {
return this.item.linkedFieldOptions;
return this.item?.linkedFieldOptions;
}
linkedFieldValue(id: LinkedIdType) {

View File

@@ -10,6 +10,7 @@ export class Fido2CredentialView extends ItemView {
keyValue: string;
rpId: string;
userHandle: string;
userName: string;
counter: number;
rpName: string;
userDisplayName: string;

View File

@@ -1158,6 +1158,7 @@ export class CipherService implements CipherServiceAbstraction {
rpId: null,
rpName: null,
userHandle: null,
userName: null,
userDisplayName: null,
origin: null,
},

View File

@@ -247,6 +247,7 @@ describe("FidoAuthenticatorService", () => {
rpId: params.rpEntity.id,
rpName: params.rpEntity.name,
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
userName: params.userEntity.name,
counter: 0,
userDisplayName: params.userEntity.displayName,
discoverable: false,
@@ -796,6 +797,7 @@ function createCipherView(
fido2CredentialView.counter = fido2Credential.counter ?? 0;
fido2CredentialView.userHandle =
fido2Credential.userHandle ?? Fido2Utils.bufferToString(randomBytes(16));
fido2CredentialView.userName = fido2Credential.userName;
fido2CredentialView.keyAlgorithm = fido2Credential.keyAlgorithm ?? "ECDSA";
fido2CredentialView.keyCurve = fido2Credential.keyCurve ?? "P-256";
fido2CredentialView.discoverable = fido2Credential.discoverable ?? true;

View File

@@ -401,6 +401,7 @@ async function createKeyView(
fido2Credential.keyValue = Fido2Utils.bufferToString(pkcs8Key);
fido2Credential.rpId = params.rpEntity.id;
fido2Credential.userHandle = Fido2Utils.bufferToString(params.userEntity.id);
fido2Credential.userName = params.userEntity.name;
fido2Credential.counter = 0;
fido2Credential.rpName = params.rpEntity.name;
fido2Credential.userDisplayName = params.userEntity.displayName;

View File

@@ -40,6 +40,7 @@ describe("FidoAuthenticatorService", () => {
client = new Fido2ClientService(authenticator, configService, authService, stateService);
configService.getFeatureFlag.mockResolvedValue(true);
stateService.getEnablePasskeys.mockResolvedValue(true);
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
});
@@ -58,7 +59,7 @@ describe("FidoAuthenticatorService", () => {
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
it("should throw error if user.id is too small", async () => {
const params = createParams({ user: { id: "", displayName: "name" } });
const params = createParams({ user: { id: "", displayName: "displayName", name: "name" } });
const result = async () => await client.createCredential(params, tab);
@@ -70,7 +71,8 @@ describe("FidoAuthenticatorService", () => {
const params = createParams({
user: {
id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ",
displayName: "name",
displayName: "displayName",
name: "name",
},
});
@@ -228,6 +230,16 @@ describe("FidoAuthenticatorService", () => {
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
const params = createParams();
stateService.getEnablePasskeys.mockResolvedValue(false);
const result = async () => await client.createCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if user is logged out", async () => {
const params = createParams();
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
@@ -261,6 +273,7 @@ describe("FidoAuthenticatorService", () => {
user: params.user ?? {
id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA",
displayName: "User Name",
name: "name",
},
fallbackSupported: params.fallbackSupported ?? false,
timeout: params.timeout,
@@ -387,6 +400,16 @@ describe("FidoAuthenticatorService", () => {
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
const params = createParams();
stateService.getEnablePasskeys.mockResolvedValue(false);
const result = async () => await client.assertCredential(params, tab);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
it("should throw FallbackRequestedError if user is logged out", async () => {
const params = createParams();
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);

View File

@@ -46,7 +46,11 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
) {}
async isFido2FeatureEnabled(): Promise<boolean> {
return await this.configService.getFeatureFlag<boolean>(FeatureFlag.Fido2VaultCredentials);
const featureFlagEnabled = await this.configService.getFeatureFlag<boolean>(
FeatureFlag.Fido2VaultCredentials
);
const userEnabledPasskeys = await this.stateService.getEnablePasskeys();
return featureFlagEnabled && userEnabledPasskeys;
}
async createCredential(
@@ -196,7 +200,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
authData: Fido2Utils.bufferToString(makeCredentialResult.authData),
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm,
transports: ["internal"],
transports: params.rp.id === "google.com" ? ["internal", "usb"] : ["internal"],
};
}
@@ -395,6 +399,7 @@ function mapToMakeCredentialParams({
userEntity: {
id: Fido2Utils.stringToBuffer(params.user.id),
displayName: params.user.displayName,
name: params.user.name,
},
fallbackSupported: params.fallbackSupported,
};

View File

@@ -25,6 +25,7 @@ export class CheckboxComponent implements BitFormControlAbstraction {
"tw-w-3.5",
"tw-mr-1.5",
"tw-bottom-[-1px]", // Fix checkbox looking off-center
"tw-flex-none", // Flexbox fix for bit-form-control
"before:tw-content-['']",
"before:tw-block",

View File

@@ -1,5 +1,12 @@
import { Component, Input } from "@angular/core";
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import {
FormsModule,
ReactiveFormsModule,
FormBuilder,
Validators,
FormGroup,
FormControl,
} from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service";
@@ -15,7 +22,8 @@ const template = `
<input type="checkbox" bitCheckbox formControlName="checkbox">
<bit-label>Click me</bit-label>
</bit-form-control>
</form>`;
</form>
`;
@Component({
selector: "app-example",
@@ -89,6 +97,40 @@ export const Default: Story = {
},
};
export const Hint: Story = {
render: (args) => ({
props: {
formObj: new FormGroup({
checkbox: new FormControl(false),
}),
},
template: `
<form [formGroup]="formObj">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkbox">
<bit-label>Really long value that never ends.</bit-label>
<bit-hint>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum.
Ut non odio est. Duis eu nisi ultrices, porttitor lorem eget, ornare libero. Fusce ex ante, consequat ac
sem et, euismod placerat tellus.
</bit-hint>
</bit-form-control>
</form>
`,
}),
parameters: {
docs: {
source: {
code: template,
},
},
},
args: {
checked: false,
disabled: false,
},
};
export const Custom: Story = {
render: (args) => ({
props: args,

View File

@@ -1,13 +1,13 @@
<label [class]="labelClasses">
<ng-content></ng-content>
<span [class]="labelContentClasses">
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
<span>
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
</span>
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
</span>
</label>
<div>
<ng-content select="bit-hint" *ngIf="!hasError"></ng-content>
</div>
<div *ngIf="hasError" class="tw-mt-1 tw-text-danger">
<i class="bwi bwi-error"></i> {{ displayError }}
</div>

View File

@@ -20,22 +20,36 @@ export class FormControlComponent {
this._inline = coerceBooleanProperty(value);
}
private _disableMargin = false;
@Input() set disableMargin(value: boolean | "") {
this._disableMargin = coerceBooleanProperty(value);
}
get disableMargin() {
return this._disableMargin;
}
@ContentChild(BitFormControlAbstraction) protected formControl: BitFormControlAbstraction;
@HostBinding("class") get classes() {
return ["tw-mb-6"].concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"]);
return []
.concat(this.inline ? ["tw-inline-block", "tw-mr-4"] : ["tw-block"])
.concat(this.disableMargin ? [] : ["tw-mb-6"]);
}
constructor(private i18nService: I18nService) {}
protected get labelClasses() {
return ["tw-transition", "tw-select-none", "tw-mb-0"].concat(
this.formControl.disabled ? "tw-cursor-auto" : "tw-cursor-pointer"
);
return [
"tw-transition",
"tw-select-none",
"tw-mb-0",
"tw-inline-flex",
"tw-items-baseline",
].concat(this.formControl.disabled ? "tw-cursor-auto" : "tw-cursor-pointer");
}
protected get labelContentClasses() {
return ["tw-font-semibold"].concat(
return ["tw-inline-flex", "tw-flex-col", "tw-font-semibold"].concat(
this.formControl.disabled ? "tw-text-muted" : "tw-text-main"
);
}

View File

@@ -6,7 +6,7 @@ let nextId = 0;
@Directive({
selector: "bit-hint",
host: {
class: "tw-text-muted tw-inline-block tw-mt-1",
class: "tw-text-muted tw-font-normal tw-inline-block tw-mt-1",
},
})
export class BitHintComponent {

View File

@@ -1,15 +1,15 @@
<bit-form-control inline>
<bit-form-control [inline]="!block" disableMargin>
<input
type="radio"
bitRadio
[id]="inputId"
[name]="name"
[disabled]="groupDisabled || disabled"
[value]="value"
[checked]="selected"
(change)="onInputChange()"
(blur)="onBlur()"
/>
<ng-content select="bit-label" ngProjectAs="bit-label"></ng-content>
<ng-content select="bit-hint" ngProjectAs="bit-hint"></ng-content>
</bit-form-control>

View File

@@ -10,6 +10,10 @@ let nextId = 0;
})
export class RadioButtonComponent {
@HostBinding("attr.id") @Input() id = `bit-radio-button-${nextId++}`;
@HostBinding("class") get classList() {
return [this.block ? "tw-block" : "tw-inline-block", "tw-mb-2"];
}
@Input() value: unknown;
@Input() disabled = false;
@@ -31,6 +35,10 @@ export class RadioButtonComponent {
return this.groupComponent.disabled;
}
get block() {
return this.groupComponent.block;
}
protected onInputChange() {
this.groupComponent.onInputChange(this.value);
}

View File

@@ -2,13 +2,14 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormControlModule } from "../form-control";
import { SharedModule } from "../shared";
import { RadioButtonComponent } from "./radio-button.component";
import { RadioGroupComponent } from "./radio-group.component";
import { RadioInputComponent } from "./radio-input.component";
@NgModule({
imports: [CommonModule, FormControlModule],
imports: [CommonModule, SharedModule, FormControlModule],
declarations: [RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
exports: [FormControlModule, RadioInputComponent, RadioButtonComponent, RadioGroupComponent],
})

View File

@@ -1,5 +1,5 @@
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup } from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -67,6 +67,37 @@ export const Inline: Story = {
}),
};
export const InlineHint: Story = {
render: () => ({
props: {
formObj: new FormGroup({
radio: new FormControl(0),
}),
},
template: `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
</bit-radio-button>
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
</bit-radio-button>
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
</bit-radio-button>
<bit-hint>This is a hint for the radio group</bit-hint>
</bit-radio-group>
</form>
`,
}),
};
export const Block: Story = {
render: () => ({
props: {
@@ -76,20 +107,20 @@ export const Block: Story = {
},
template: `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group">
<bit-radio-group formControlName="radio" aria-label="Example radio group" [block]="true">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" class="tw-block" [value]="0">
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
<bit-hint>This is a hint for the first option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-second" class="tw-block" [value]="1">
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
<bit-hint>This is a hint for the second option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-third" class="tw-block" [value]="2">
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
<bit-hint>This is a hint for the third option</bit-hint>
</bit-radio-button>
@@ -98,3 +129,37 @@ export const Block: Story = {
`,
}),
};
export const BlockHint: Story = {
render: () => ({
props: {
formObj: new FormGroup({
radio: new FormControl(0),
}),
},
template: `
<form [formGroup]="formObj">
<bit-radio-group formControlName="radio" aria-label="Example radio group" [block]="true">
<bit-label>Group of radio buttons</bit-label>
<bit-radio-button id="radio-first" [value]="0">
<bit-label>First</bit-label>
<bit-hint>This is a hint for the first option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-second" [value]="1">
<bit-label>Second</bit-label>
<bit-hint>This is a hint for the second option</bit-hint>
</bit-radio-button>
<bit-radio-button id="radio-third" [value]="2">
<bit-label>Third</bit-label>
<bit-hint>This is a hint for the third option</bit-hint>
</bit-radio-button>
<bit-hint>This is a hint for the radio group</bit-hint>
</bit-radio-group>
</form>
`,
}),
};

View File

@@ -2,6 +2,7 @@
<fieldset>
<legend class="tw-mb-1 tw-block tw-text-sm tw-font-semibold tw-text-main">
<ng-content select="bit-label"></ng-content>
<span *ngIf="required" class="tw-text-xs tw-font-normal"> ({{ "required" | i18n }})</span>
</legend>
<ng-container *ngTemplateOutlet="content"></ng-container>
</fieldset>
@@ -11,4 +12,9 @@
<ng-container *ngTemplateOutlet="content"></ng-container>
</ng-container>
<ng-template #content><ng-content></ng-content></ng-template>
<ng-template #content>
<div>
<ng-content></ng-content>
</div>
<ng-content select="bit-hint" ngProjectAs="bit-hint"></ng-content>
</ng-template>

View File

@@ -1,5 +1,5 @@
import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core";
import { ControlValueAccessor, NgControl } from "@angular/forms";
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
import { BitLabel } from "../form-control/label.directive";
@@ -21,8 +21,11 @@ export class RadioGroupComponent implements ControlValueAccessor {
this._name = value;
}
@Input() block = false;
@HostBinding("attr.role") role = "radiogroup";
@HostBinding("attr.id") @Input() id = `bit-radio-group-${nextId++}`;
@HostBinding("class") classList = ["tw-block", "tw-mb-4"];
@ContentChild(BitLabel) protected label: BitLabel;
@@ -32,6 +35,10 @@ export class RadioGroupComponent implements ControlValueAccessor {
}
}
get required() {
return this.ngControl?.control?.hasValidator(Validators.required) ?? false;
}
// ControlValueAccessor
onChange: (value: unknown) => void;
onTouched: () => void;

View File

@@ -29,6 +29,7 @@ export class RadioInputComponent implements BitFormControlAbstraction {
"tw-h-3.5",
"tw-mr-1.5",
"tw-bottom-[-1px]", // Fix checkbox looking off-center
"tw-flex-none", // Flexbox fix for bit-form-control
"hover:tw-border-2",
"[&>label:hover]:tw-border-2",