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:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
libs/angular/src/auth/icons/create-passkey-failed.icon.ts
Normal file
28
libs/angular/src/auth/icons/create-passkey-failed.icon.ts
Normal 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>
|
||||
`;
|
||||
26
libs/angular/src/auth/icons/create-passkey.icon.ts
Normal file
26
libs/angular/src/auth/icons/create-passkey.icon.ts
Normal 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>
|
||||
`;
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response";
|
||||
|
||||
export class WebAuthnLoginApiServiceAbstraction {
|
||||
getCredentialAssertionOptions: () => Promise<CredentialAssertionOptionsResponse>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export enum AuthenticationType {
|
||||
Sso = 1,
|
||||
UserApi = 2,
|
||||
AuthRequest = 3,
|
||||
WebAuthn = 4,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AssertionOptionsResponse } from "../../../services/webauthn-login/response/assertion-options.response";
|
||||
|
||||
export class WebAuthnLoginCredentialAssertionOptionsView {
|
||||
constructor(readonly options: AssertionOptionsResponse, readonly token: string) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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";
|
||||
|
||||
18
libs/common/src/platform/state/key-definitions.ts
Normal file
18
libs/common/src/platform/state/key-definitions.ts
Normal 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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface CreateCredentialParams {
|
||||
user: {
|
||||
id: string; // b64 encoded
|
||||
displayName: string;
|
||||
name: string;
|
||||
};
|
||||
/** Forwarded to user interface */
|
||||
fallbackSupported: boolean;
|
||||
|
||||
@@ -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");
|
||||
|
||||
85
libs/common/src/vault/icon/build-cipher-icon.ts
Normal file
85
libs/common/src/vault/icon/build-cipher-icon.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export class Fido2CredentialView extends ItemView {
|
||||
keyValue: string;
|
||||
rpId: string;
|
||||
userHandle: string;
|
||||
userName: string;
|
||||
counter: number;
|
||||
rpName: string;
|
||||
userDisplayName: string;
|
||||
|
||||
@@ -1158,6 +1158,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
rpId: null,
|
||||
rpName: null,
|
||||
userHandle: null,
|
||||
userName: null,
|
||||
userDisplayName: null,
|
||||
origin: null,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user