Auth/PM-2041 - Finish adding FIDO2 Authentication + Decryption to Web Client (#6798)

* PM-2041 - (1) Bring over WebauthnApiService + required models from existing #5493 PR (2) Per discussion with Andreas, remove unnecessary methods from WebauthnApiService

* PM-2041 - Rename responses folder to response to match rest of codebase

* PM-2041 - Recreate  BaseLoginViaWebAuthnComponent and then web implementation of it.

* PM-2041 - Web routing module - add LoginViaWebAuthnComponent and associated route "login-with-passkey"

* PM-2041 - InjectionTokens - add new navigator credentials injection token which provides the CredentialsContainer interface of the Credential Management API and exposes methods to request credentials and notify the user agent when events such as successful sign in or sign out happen

* PM-2041 - Rename WebauthnApiService & abstraction to WebAuthnLoginApiService

* PM-2041 - Rename WebauthnLoginApiService to WebAuthnAdminApiService

* PM-2041 - Bring over first draft of webauthn-login.service + abstraction; register on jslib-services.module.

* PM-2041 - Bring over web & base login component changes to add login with passkey button if feature flag enabled.

* PM-2041 - WebAuthnAdminApi - update list of TODOs based on conversation with Andreas

* PM-2041 - Login.module - cleanup todo after conversation w/ Andreas

* PM-2041 - Move utils out of web and into common auth/utils and renamed to webauthn-utils

* PM-2041 - Update userDecryptionOptions to support new webauthn prf decryption option

* PM-2041 - (1) Recreate webauthn-login service with updated logic (2) Move files from webauthn to webauthn-login (3) Recreate webauthn-login.strategy with updated logic

* PM-2041 - Remove completed TODO

* PM-2041 - Fix login-via-webauthn component imports + fix name (missing n)

* PM-2041 - Missed this change when renaming LoginViaWebAuthComponent to LoginViaWebAuthnComponent

* PM-2041 - Add WebAuthnLoginApiService to jslib-services.module

* PM-2041 - Remove unused param from WebAuthnLoginApiServiceAbstraction as we aren't supporting non-discoverable passkeys for MVP

* PM-2041 - WebAuthnLoginApiService - remove email and target correct endpoint for getCredentialAssertionOptions(...) call

* PM-2041 - WebAuthnLoginStrategy - (1) Remove unused dep (2) Add safeguard checks to setUserKey(...) logic similar to SSO login strategy

* PM-2041 - BaseLoginViaWebAuthnComponent - Rewrite authenticate logic to use new methods on webAuthnLoginService

* PM-2041 - UserDecryptionOptionsResponse - update naming of webAuthn options object to match server response

* PM-2041 - WebAuthnLoginAssertionResponseRequest - (1) clean up TODO (2) Fix response property name to match server

* PM-2041 - WebAuthnTokenRequest - must stringify device response b/c sending as form data

* PM-2041 - AuthService - Add WebAuthnLoginCredentials and WebAuthnLoginStrategy support to auth service

* PM-2041 - WIP tests for WebAuthnLoginService

* PM-2041 - UserDecryptionOptions - Rename WebAuthnPrfOptions to singular WebAuthnPrfOption to match server

* PM-2041 - Add TODO in login comp

* PM-2041 - (1) Update WebAuthnLoginService.assertCredential(...) to add a check to ensure we cannot leak PRF credentials to the BW server by mistake (2) Add credential to view names for clarity (3) Add JS doc style comments to WebAuthnLoginServiceAbstraction

* PM-2041 - Login.component.html - (1) Center passkey login button (2) Use correct user passkey icon

* PM-2041 - Utils + tests - (1) Add new hexStringToArrayBuffer(...) method (2) Add tests for existing fromBufferToHex(...) (3) Add tests for new hexStringToArrayBuffer(...) method

* PM-2041 - Fix broken import

* PM-2041 - WebAuthnLoginResponseRequest - Adjust warning to be correct

* PM-2041 - Webauthn-utils - createSymmetricKeyFromPrf(...) - add return type

* PM-2041 - WebAuthnLoginService spec file - good progress on figuring out how to test passkey assertion process. Tests are passing, but need to add more setup logic around the MockAuthenticatorAssertionResponse in order to be able to confirm the output is correct.

* PM-2041 - Utils + Utils Spec file changes - (1) Add new fromB64ToArrayBuffer(...) method (2) Add tests for existing fromBufferToB64(...) (3) Add tests for new fromB64ToArrayBuffer(...) method (4) Add round trip conversion tests in both directions

* PM-2041 - Utils.spec - update round trip conversion tests between hex string and array buffer.

* PM-2041 - WebAuthnLoginService.spec - assertCredential(...) happy path test passing

* PM-2041 - WebAuthnLoginAssertionResponseRequest - Add interface

* PM-2041 - WebAuthnLoginAssertionResponseRequest data should be UrlB64 strings per discussion w/ Andreas

* PM-2041 - WebAuthnLoginService Spec file - Per feedback, reverse approaches to generating test data (go from array buffer to b64 strings vs the reverse) to avoid using math.random which can introduce test inconsistency

* PM-2041 - Finish testing assertCredential(...)

* PM-2041 - WebAuthnLoginService tests completed - tested logIn method

* PM-2041 - Login html - add "or" between standard email login and passkey login

* PM-2041 - WebAuthnLoginStrategy test start

* PM-2041 - After rebase - BaseLoginViaWebAuthnComponent - Must rename ForceResetPasswordReason to ForceSetPasswordReason + refactor post login routing logic to match other auth owned flows.

* PM-2401 - Desktop - login comp - fix desktop build

* PM-2041 - Browser - login comp - fix build issue

* PM-2401 - WIP on webauthn-login.strategy testing

* PM-2401 - Finish testing webauthn login strategy

* PM-2041 - WebAuthnAdminApiService renamed to WebAuthnLoginAdminApiService

* PM-2041 - Remove unnecessary comment

* PM-2041 - Per PR feedback, remove noMargin and just add mb-3

* PM-2041 - Per PR feedback, remove unused 2FA and remember email logic (2FA isn't supported right now and we aren't using non-discoverable credentials so we aren't using a user entered email)

* PM-2401 - BaseLoginViaWebAuthnComponent - improve error handling to allow users to retry w/ another passkey

* PM-2401 - Per PR feedback, provide translated message to cover all invalid passkey scenarios.

* PM-2401 - WebAuthnLoginService - per PR feedback, remove unnecessary from

* PM-2041 - WebAuthnLoginCredentialAssertionView - per PR feedback, use actual key type

* PM-2401 - Per PR feedback, remove WebAuthnLoginStrategy constructor as it is identical to its super class constructor

* PM-2041 - WebAuthnLoginService tests - use first value from to improve tests

* PM-2401 - Fix WebAuthnLoginService build issue after changing SymmetricCryptoKey to PrfKey

* PM-2041 - WebAuthnLoginServiceAbstraction remove incorrect undefined from getCredentialAssertionOptions() abstraction

* PM-2041 - Refacor WebAuthn login service tests based on PR feedback

* PM-2041 - Per PR feedback, remove NAVIGATOR_CREDENTIALS injection token and just use WINDOW directly for WebAuthnLoginService

* PM-2041 - WebAuthnLoginServiceAbstraction - per PR feedback, improve assertCredential jsdocs with return info

* PM-2041 - Per PR feedback, update WebAuthnLoginStrategy logInTwoFactor(...) to return an exception if attempted to be called.

* PM-2041 - WebAuthnLoginResponseRequest - per PR feedback, replace fromBufferToB64(...) with fromBufferToUrlB64(...)

* PM-2041 - AssertionOptionsResponse - use doc comment per PR feedback

* PM-2041 - Per PR feedback, adjust location of helpers and mocks in WebAuthnLoginStrategy test file

* PM-2041 - Adjust WebAuthnLoginService tests to take the WebAuthnLoginResponseRequest change to use fromBufferToUrlB64(...) into account to get tests to pass again

* PM-2041 - WebAuthnLoginStrategy - adjust test name to match convention per PR feedback

* PM-2041 - More test tweaks - (1) Rename method (2) Support strict

* PM-2041 - Per PR feedback, AssertionOptionsResponse constructor should null check allowCredentials b/c it is optional

* PM-2041 - Per PR Feedback, remove duplicated fromB64ToArrayBuffer(...) from utils and update tests.

* PM-2041 - Per PR feedback, rename WebAuthnTokenRequest to WebAuthnLoginTokenRequest

* PM-2041 - Per discussion with product and Andreas, add 2FA transition handling just in case we add server support in the future.

* feat: stretch PRF key (#6927)

* feat: stretch PRF key

includes necessary utils -> service refactors

* feat: add tests

* [PM-2041] feat: assertion-options `POST` -> `GET`

* [PM-2041] chore: remove unused properties

* [PM-2041] fix: set private key

* [PM-2041] feat: remove all 2FA related fields

* [PM-2041] chore: clean up 2FA comments

* [PM-2041] chore: document `webauthn-login-prf-crypto.service.abstraction.ts`

* [PM-2041] chore: document webauthn login services

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
This commit is contained in:
Jared Snider 2023-11-22 13:24:33 -05:00 committed by GitHub
parent 2be9273e5f
commit 3a0603a837
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1864 additions and 42 deletions

View File

@ -7,6 +7,7 @@ import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstrac
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 { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -43,7 +44,8 @@ export class LoginComponent extends BaseLoginComponent {
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
route: ActivatedRoute,
loginService: LoginService
loginService: LoginService,
webAuthnLoginService: WebAuthnLoginServiceAbstraction
) {
super(
devicesApiService,
@ -61,7 +63,8 @@ export class LoginComponent extends BaseLoginComponent {
formBuilder,
formValidationErrorService,
route,
loginService
loginService,
webAuthnLoginService
);
super.onSuccessfulLogin = async () => {
await syncService.fullSync(true);

View File

@ -10,6 +10,7 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
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 { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
@ -71,7 +72,8 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
route: ActivatedRoute,
loginService: LoginService
loginService: LoginService,
webAuthnLoginService: WebAuthnLoginServiceAbstraction
) {
super(
devicesApiService,
@ -89,7 +91,8 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
formBuilder,
formValidationErrorService,
route,
loginService
loginService,
webAuthnLoginService
);
super.onSuccessfulLogin = () => {
return syncService.fullSync(true);

View File

@ -1,15 +0,0 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PrfKey,
SymmetricCryptoKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
const LoginWithPrfSalt = "passwordless-login";
export async function getLoginWithPrfSalt(): Promise<ArrayBuffer> {
return await crypto.subtle.digest("sha-256", Utils.fromUtf8ToArray(LoginWithPrfSalt));
}
export function createSymmetricKeyFromPrf(prf: ArrayBuffer) {
return new SymmetricCryptoKey(new Uint8Array(prf)) as PrfKey;
}

View File

@ -9,7 +9,7 @@ import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauth
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";
@Injectable({ providedIn: "root" })
export class WebauthnLoginApiService {
export class WebAuthnLoginAdminApiService {
constructor(private apiService: ApiService) {}
async getCredentialCreateOptions(

View File

@ -1,18 +1,20 @@
import { mock, MockProxy } from "jest-mock-extended";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
import { RotateableKeySetService } from "../rotateable-key-set.service";
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
import { WebauthnLoginAdminService } from "./webauthn-login-admin.service";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
describe("WebauthnAdminService", () => {
let apiService!: MockProxy<WebauthnLoginApiService>;
let apiService!: MockProxy<WebAuthnLoginAdminApiService>;
let userVerificationService!: MockProxy<UserVerificationService>;
let rotateableKeySetService!: MockProxy<RotateableKeySetService>;
let webAuthnLoginPrfCryptoService!: MockProxy<WebAuthnLoginPrfCryptoServiceAbstraction>;
let credentials: MockProxy<CredentialsContainer>;
let service!: WebauthnLoginAdminService;
@ -20,14 +22,16 @@ describe("WebauthnAdminService", () => {
// Polyfill missing class
window.PublicKeyCredential = class {} as any;
window.AuthenticatorAttestationResponse = class {} as any;
apiService = mock<WebauthnLoginApiService>();
apiService = mock<WebAuthnLoginAdminApiService>();
userVerificationService = mock<UserVerificationService>();
rotateableKeySetService = mock<RotateableKeySetService>();
webAuthnLoginPrfCryptoService = mock<WebAuthnLoginPrfCryptoServiceAbstraction>();
credentials = mock<CredentialsContainer>();
service = new WebauthnLoginAdminService(
apiService,
userVerificationService,
rotateableKeySetService,
webAuthnLoginPrfCryptoService,
credentials
);
});

View File

@ -3,6 +3,7 @@ import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap,
import { PrfKeySet } from "@bitwarden/auth";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Verification } from "@bitwarden/common/types/verification";
@ -13,10 +14,12 @@ import { RotateableKeySetService } from "../rotateable-key-set.service";
import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
import { createSymmetricKeyFromPrf, getLoginWithPrfSalt } from "./utils";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
@Injectable({ providedIn: "root" })
/**
* Service for managing WebAuthnLogin credentials.
*/
export class WebauthnLoginAdminService {
static readonly MaxCredentialCount = 5;
@ -30,12 +33,17 @@ export class WebauthnLoginAdminService {
shareReplay({ bufferSize: 1, refCount: true })
);
/**
* An Observable that emits a boolean indicating whether the service is currently fetching
* WebAuthnLogin credentials from the server.
*/
readonly loading$ = this._loading$.asObservable();
constructor(
private apiService: WebauthnLoginApiService,
private apiService: WebAuthnLoginAdminApiService,
private userVerificationService: UserVerificationService,
private rotateableKeySetService: RotateableKeySetService,
private webAuthnLoginPrfCryptoService: WebAuthnLoginPrfCryptoServiceAbstraction,
@Optional() navigatorCredentials?: CredentialsContainer,
@Optional() private logService?: LogService
) {
@ -43,6 +51,14 @@ export class WebauthnLoginAdminService {
this.navigatorCredentials = navigatorCredentials ?? navigator.credentials;
}
/**
* Get the credential attestation options needed for initiating the WebAuthnLogin credentail creation process.
* The options contains a challenge and other data for the authenticator.
* This method requires user verification.
*
* @param verification User verification data to be used for the request.
* @returns The credential attestation options and a token to be used for the credential creation request.
*/
async getCredentialCreateOptions(
verification: Verification
): Promise<CredentialCreateOptionsView> {
@ -51,6 +67,12 @@ export class WebauthnLoginAdminService {
return new CredentialCreateOptionsView(response.options, response.token);
}
/**
* Create a credential using the given options. This triggers the browsers WebAuthn API to create a credential.
*
* @param credentialOptions Options received from the server using `getCredentialCreateOptions`.
* @returns A pending credential that can be saved to server directly or be used to create a key set.
*/
async createCredential(
credentialOptions: CredentialCreateOptionsView
): Promise<PendingWebauthnLoginCredentialView | undefined> {
@ -76,6 +98,13 @@ export class WebauthnLoginAdminService {
}
}
/**
* Create a key set from the given pending credential. The credential must support PRF.
* This will trigger the browsers WebAuthn API to generate a PRF-output.
*
* @param pendingCredential A credential created using `createCredential`.
* @returns A key set that can be saved to the server. Undefined is returned if the credential doesn't support PRF.
*/
async createKeySet(
pendingCredential: PendingWebauthnLoginCredentialView
): Promise<PrfKeySet | undefined> {
@ -88,7 +117,9 @@ export class WebauthnLoginAdminService {
userVerification:
pendingCredential.createOptions.options.authenticatorSelection.userVerification,
// TODO: Remove `any` when typescript typings add support for PRF
extensions: { prf: { eval: { first: await getLoginWithPrfSalt() } } } as any,
extensions: {
prf: { eval: { first: await this.webAuthnLoginPrfCryptoService.getLoginWithPrfSalt() } },
} as any,
},
};
@ -105,7 +136,9 @@ export class WebauthnLoginAdminService {
return undefined;
}
const symmetricPrfKey = createSymmetricKeyFromPrf(prfResult);
const symmetricPrfKey = await this.webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf(
prfResult
);
return await this.rotateableKeySetService.createKeySet(symmetricPrfKey);
} catch (error) {
this.logService?.error(error);
@ -113,6 +146,13 @@ export class WebauthnLoginAdminService {
}
}
/**
* Save a pending credential to the server. This will also save the key set if it is provided.
*
* @param name User provided name for the credential.
* @param credential A pending credential created using `createCredential`.
* @param prfKeySet A key set created using `createKeySet`.
*/
async saveCredential(
name: string,
credential: PendingWebauthnLoginCredentialView,
@ -144,6 +184,12 @@ export class WebauthnLoginAdminService {
return this.credentials$;
}
/**
* Subscribe to a single credential by id.
*
* @param credentialId The id of the credential to subscribe to.
* @returns An observable that emits the credential with the given id.
*/
getCredential$(credentialId: string): Observable<WebauthnLoginCredentialView> {
return this.credentials$.pipe(
map((credentials) => credentials.find((c) => c.id === credentialId)),
@ -151,6 +197,13 @@ export class WebauthnLoginAdminService {
);
}
/**
* Delete a credential from the server. This method requires user verification.
*
* @param credentialId The id of the credential to delete.
* @param verification User verification data to be used for the request.
* @returns A promise that resolves when the credential has been deleted.
*/
async deleteCredential(credentialId: string, verification: Verification): Promise<void> {
const request = await this.userVerificationService.buildRequest(verification);
await this.apiService.deleteCredential(credentialId, request);

View File

@ -0,0 +1,50 @@
<div
class="tw-mx-auto tw-mt-5 tw-flex tw-max-w-lg tw-flex-col tw-items-center tw-justify-center tw-p-8"
>
<div>
<img class="logo logo-themed" alt="Bitwarden" />
<h3 bitTypography="h3" class="tw-my-8 tw-mb-3 tw-text-center">
{{ "readingPasskeyLoading" | i18n }}
</h3>
<div
class="tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<div class="tw-flex tw-flex-col tw-items-center">
<ng-container *ngIf="currentState === 'assert'">
<bit-icon [icon]="Icons.CreatePasskeyIcon" class="tw-my-10"></bit-icon>
<p bitTypography="body1">{{ "readingPasskeyLoadingInfo" | i18n }}</p>
<button
type="button"
bitButton
block
[loading]="true"
buttonType="primary"
class="tw-mb-4"
>
{{ "loading" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="currentState === 'assertFailed'">
<bit-icon [icon]="Icons.CreatePasskeyFailedIcon" class="tw-my-10"></bit-icon>
<p bitTypography="body1">{{ "readingPasskeyLoadingInfo" | i18n }}</p>
<button
type="button"
bitButton
block
buttonType="primary"
class="tw-mb-4"
(click)="retry()"
>
{{ "tryAgain" | i18n }}
</button>
</ng-container>
</div>
<p bitTypography="body1" class="tw-mb-0">
{{ "troubleLoggingIn" | i18n }}<br />
<a routerLink="/login">{{ "useADifferentLogInMethod" | i18n }}</a>
</p>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { Component } from "@angular/core";
import { BaseLoginViaWebAuthnComponent } from "@bitwarden/angular/auth/components/base-login-via-webauthn.component";
import { CreatePasskeyFailedIcon } from "@bitwarden/angular/auth/icons/create-passkey-failed.icon";
import { CreatePasskeyIcon } from "@bitwarden/angular/auth/icons/create-passkey.icon";
@Component({
selector: "app-login-via-webauthn",
templateUrl: "login-via-webauthn.component.html",
})
export class LoginViaWebAuthnComponent extends BaseLoginViaWebAuthnComponent {
protected readonly Icons = { CreatePasskeyIcon, CreatePasskeyFailedIcon };
}

View File

@ -51,6 +51,23 @@
</button>
</div>
<div
class="tw-mb-3 tw-flex tw-flex-col tw-items-center tw-justify-center"
*ngIf="showWebauthnLogin$ | async"
>
<p class="tw-mb-3">{{ "or" | i18n }}</p>
<a
bitLink
block
linkType="primary"
routerLink="/login-with-passkey"
(mousedown)="$event.preventDefault()"
>
<span><i class="bwi bwi-passkey"></i> {{ "loginWithPasskey" | i18n }}</span>
</a>
</div>
<hr />
<p class="tw-m-0 tw-text-sm">

View File

@ -15,6 +15,7 @@ import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/
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 { ListResponse } from "@bitwarden/common/models/response/list.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
@ -62,7 +63,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
private routerService: RouterService,
formBuilder: FormBuilder,
formValidationErrorService: FormValidationErrorsService,
loginService: LoginService
loginService: LoginService,
webAuthnLoginService: WebAuthnLoginServiceAbstraction
) {
super(
devicesApiService,
@ -80,7 +82,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
formBuilder,
formValidationErrorService,
route,
loginService
loginService,
webAuthnLoginService
);
this.onSuccessfulLogin = async () => {
this.messagingService.send("setFullWidth");

View File

@ -6,11 +6,22 @@ import { SharedModule } from "../../../app/shared";
import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component";
import { LoginViaAuthRequestComponent } from "./login-via-auth-request.component";
import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component";
import { LoginComponent } from "./login.component";
@NgModule({
imports: [SharedModule, CheckboxModule],
declarations: [LoginComponent, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent],
exports: [LoginComponent, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent],
declarations: [
LoginComponent,
LoginViaAuthRequestComponent,
LoginDecryptionOptionsComponent,
LoginViaWebAuthnComponent,
],
exports: [
LoginComponent,
LoginViaAuthRequestComponent,
LoginDecryptionOptionsComponent,
LoginViaWebAuthnComponent,
],
})
export class LoginModule {}

View File

@ -24,6 +24,7 @@ import { HintComponent } from "./auth/hint.component";
import { LockComponent } from "./auth/lock.component";
import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component";
import { LoginViaAuthRequestComponent } from "./auth/login/login-via-auth-request.component";
import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component";
import { LoginComponent } from "./auth/login/login.component";
import { RecoverDeleteComponent } from "./auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component";
@ -70,6 +71,11 @@ const routes: Routes = [
component: LoginViaAuthRequestComponent,
data: { titleId: "loginWithDevice" },
},
{
path: "login-with-passkey",
component: LoginViaWebAuthnComponent,
data: { titleId: "loginWithPasskey" },
},
{
path: "admin-approval-requested",
component: LoginViaAuthRequestComponent,

View File

@ -614,6 +614,12 @@
"loginWithPasskey": {
"message": "Log in with passkey"
},
"invalidPasskeyPleaseTryAgain": {
"message": "Invalid Passkey. Please try again."
},
"twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn": {
"message": "2FA for passkeys is not supported. Update the app to log in."
},
"loginWithPasskeyInfo": {
"message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity."
},

View File

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

View File

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

View File

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

View File

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

View File

@ -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";
@ -752,6 +758,28 @@ 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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",