From 3a0603a837fc20c10a124983f926766293ee63ee Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:24:33 -0500 Subject: [PATCH] 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 Co-authored-by: Andreas Coroiu --- .../browser/src/auth/popup/login.component.ts | 7 +- .../desktop/src/auth/login/login.component.ts | 7 +- .../core/services/webauthn-login/utils.ts | 15 - ...ts => webauthn-login-admin-api.service.ts} | 2 +- .../webauthn-login-admin.service.spec.ts | 10 +- .../webauthn-login-admin.service.ts | 63 ++- .../login-via-webauthn.component.html | 50 +++ .../login-via-webauthn.component.ts | 13 + .../src/app/auth/login/login.component.html | 17 + .../web/src/app/auth/login/login.component.ts | 7 +- apps/web/src/app/auth/login/login.module.ts | 15 +- apps/web/src/app/oss-routing.module.ts | 6 + apps/web/src/locales/en/messages.json | 6 + .../base-login-via-webauthn.component.ts | 69 ++++ .../src/auth/components/login.component.ts | 9 +- .../auth/icons/create-passkey-failed.icon.ts | 28 ++ .../src/auth/icons/create-passkey.icon.ts | 26 ++ .../src/services/jslib-services.module.ts | 28 ++ libs/common/src/abstractions/api.service.ts | 7 +- .../src/auth/abstractions/auth.service.ts | 2 + .../webauthn-login-api.service.abstraction.ts | 5 + ...hn-login-prf-crypto.service.abstraction.ts | 17 + .../webauthn-login.service.abstraction.ts | 48 +++ .../src/auth/enums/authentication-type.ts | 1 + .../auth/login-strategies/login.strategy.ts | 9 +- .../webauthn-login.strategy.spec.ts | 333 ++++++++++++++++ .../webauthn-login.strategy.ts | 68 ++++ .../auth/models/domain/login-credentials.ts | 17 +- .../request/identity-token/token.request.ts | 4 +- .../webauthn-login-token.request.ts | 25 ++ .../user-decryption-options.response.ts | 11 + ...webauthn-prf-decryption-option.response.ts | 22 + ...login-credential-assertion-options.view.ts | 5 + ...ebauthn-login-credential-assertion.view.ts | 10 + libs/common/src/auth/services/auth.service.ts | 23 +- ...bauthn-login-assertion-response.request.ts | 30 ++ .../webauthn-login-response.request.ts | 19 + .../response/assertion-options.response.ts | 28 ++ .../credential-assertion-options.response.ts | 14 + .../webauthn-login-api.service.ts | 21 + .../webauthn-login-prf-crypto.service.spec.ts | 32 ++ .../webauthn-login-prf-crypto.service.ts | 26 ++ .../webauthn-login.service.spec.ts | 377 ++++++++++++++++++ .../webauthn-login/webauthn-login.service.ts | 93 +++++ libs/common/src/platform/misc/utils.spec.ts | 237 +++++++++++ libs/common/src/platform/misc/utils.ts | 37 ++ libs/common/src/services/api.service.ts | 7 +- 47 files changed, 1864 insertions(+), 42 deletions(-) delete mode 100644 apps/web/src/app/auth/core/services/webauthn-login/utils.ts rename apps/web/src/app/auth/core/services/webauthn-login/{webauthn-login-api.service.ts => webauthn-login-admin-api.service.ts} (97%) create mode 100644 apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html create mode 100644 apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts create mode 100644 libs/angular/src/auth/components/base-login-via-webauthn.component.ts create mode 100644 libs/angular/src/auth/icons/create-passkey-failed.icon.ts create mode 100644 libs/angular/src/auth/icons/create-passkey.icon.ts create mode 100644 libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts create mode 100644 libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts create mode 100644 libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts create mode 100644 libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts create mode 100644 libs/common/src/auth/login-strategies/webauthn-login.strategy.ts create mode 100644 libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts create mode 100644 libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts create mode 100644 libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts create mode 100644 libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts create mode 100644 libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts create mode 100644 libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts create mode 100644 libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts create mode 100644 libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts create mode 100644 libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts create mode 100644 libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts create mode 100644 libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts create mode 100644 libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts create mode 100644 libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index d9b789e4d8..b930f2747d 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -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); diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index d1c6c88d14..8c182cdb21 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -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); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/utils.ts b/apps/web/src/app/auth/core/services/webauthn-login/utils.ts deleted file mode 100644 index 46995eedfb..0000000000 --- a/apps/web/src/app/auth/core/services/webauthn-login/utils.ts +++ /dev/null @@ -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 { - return await crypto.subtle.digest("sha-256", Utils.fromUtf8ToArray(LoginWithPrfSalt)); -} - -export function createSymmetricKeyFromPrf(prf: ArrayBuffer) { - return new SymmetricCryptoKey(new Uint8Array(prf)) as PrfKey; -} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts similarity index 97% rename from apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts rename to apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts index 6dc6156349..54789d5674 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts @@ -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( diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index 3094c4fccb..92a560201d 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -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; + let apiService!: MockProxy; let userVerificationService!: MockProxy; let rotateableKeySetService!: MockProxy; + let webAuthnLoginPrfCryptoService!: MockProxy; let credentials: MockProxy; let service!: WebauthnLoginAdminService; @@ -20,14 +22,16 @@ describe("WebauthnAdminService", () => { // Polyfill missing class window.PublicKeyCredential = class {} as any; window.AuthenticatorAttestationResponse = class {} as any; - apiService = mock(); + apiService = mock(); userVerificationService = mock(); rotateableKeySetService = mock(); + webAuthnLoginPrfCryptoService = mock(); credentials = mock(); service = new WebauthnLoginAdminService( apiService, userVerificationService, rotateableKeySetService, + webAuthnLoginPrfCryptoService, credentials ); }); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index 74b925da2c..891d403b43 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -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 { @@ -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 { @@ -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 { @@ -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 { 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 { const request = await this.userVerificationService.buildRequest(verification); await this.apiService.deleteCredential(credentialId, request); diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html new file mode 100644 index 0000000000..cb6ec90274 --- /dev/null +++ b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html @@ -0,0 +1,50 @@ +
+
+ +

+ {{ "readingPasskeyLoading" | i18n }} +

+ +
+
+ + +

{{ "readingPasskeyLoadingInfo" | i18n }}

+ +
+ + + +

{{ "readingPasskeyLoadingInfo" | i18n }}

+ +
+
+

+ {{ "troubleLoggingIn" | i18n }}
+ {{ "useADifferentLogInMethod" | i18n }} +

+
+
+
diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts new file mode 100644 index 0000000000..8f3a5ca3c3 --- /dev/null +++ b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts @@ -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 }; +} diff --git a/apps/web/src/app/auth/login/login.component.html b/apps/web/src/app/auth/login/login.component.html index f9ff828e94..72315b1a4d 100644 --- a/apps/web/src/app/auth/login/login.component.html +++ b/apps/web/src/app/auth/login/login.component.html @@ -51,6 +51,23 @@ +
+

{{ "or" | i18n }}

+ + + {{ "loginWithPasskey" | i18n }} + +
+

diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index c67c84286a..3a5c6d0eb2 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -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"); diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts index eb3d58cddf..ee3af10109 100644 --- a/apps/web/src/app/auth/login/login.module.ts +++ b/apps/web/src/app/auth/login/login.module.ts @@ -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 {} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 5f5baab2e3..a59576a77b 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -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, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 702f94e7e4..6d0a3a8e3e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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." }, diff --git a/libs/angular/src/auth/components/base-login-via-webauthn.component.ts b/libs/angular/src/auth/components/base-login-via-webauthn.component.ts new file mode 100644 index 0000000000..bcc3747667 --- /dev/null +++ b/libs/angular/src/auth/components/base-login-via-webauthn.component.ts @@ -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"; + } + } +} diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index cc81175b19..d344688cc5 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -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; protected destroy$ = new Subject(); @@ -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; diff --git a/libs/angular/src/auth/icons/create-passkey-failed.icon.ts b/libs/angular/src/auth/icons/create-passkey-failed.icon.ts new file mode 100644 index 0000000000..39a2389c5a --- /dev/null +++ b/libs/angular/src/auth/icons/create-passkey-failed.icon.ts @@ -0,0 +1,28 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CreatePasskeyFailedIcon = svgIcon` + + + + + + + + + + + +`; diff --git a/libs/angular/src/auth/icons/create-passkey.icon.ts b/libs/angular/src/auth/icons/create-passkey.icon.ts new file mode 100644 index 0000000000..c0e984bbee --- /dev/null +++ b/libs/angular/src/auth/icons/create-passkey.icon.ts @@ -0,0 +1,26 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CreatePasskeyIcon = svgIcon` + + + + + + + + + + +`; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1cbb152618..fedbf43391 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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, diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 1a8ca440d2..d8ce416fda 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -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; postIdentityToken: ( - request: PasswordTokenRequest | SsoTokenRequest | UserApiTokenRequest + request: + | PasswordTokenRequest + | SsoTokenRequest + | UserApiTokenRequest + | WebAuthnLoginTokenRequest ) => Promise; refreshIdentityToken: () => Promise; diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index 27107b708b..58931c7509 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -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; logInTwoFactor: ( twoFactor: TokenTwoFactorRequest, diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts new file mode 100644 index 0000000000..01845a2e3d --- /dev/null +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts @@ -0,0 +1,5 @@ +import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response"; + +export class WebAuthnLoginApiServiceAbstraction { + getCredentialAssertionOptions: () => Promise; +} diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts new file mode 100644 index 0000000000..5ce58a06a9 --- /dev/null +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts @@ -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; + + /** + * Create a symmetric key from the PRF-output by stretching it. + * This should be used as `ExternalKey` with `RotateableKeySet`. + */ + createSymmetricKeyFromPrf: (prf: ArrayBuffer) => Promise; +} diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts new file mode 100644 index 0000000000..529c2e2016 --- /dev/null +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts @@ -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; + + /** + * 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; + + /** + * 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; + + /** + * 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; +} diff --git a/libs/common/src/auth/enums/authentication-type.ts b/libs/common/src/auth/enums/authentication-type.ts index 32751f40e5..23ca9ace76 100644 --- a/libs/common/src/auth/enums/authentication-type.ts +++ b/libs/common/src/auth/enums/authentication-type.ts @@ -3,4 +3,5 @@ export enum AuthenticationType { Sso = 1, UserApi = 2, AuthRequest = 3, + WebAuthn = 4, } diff --git a/libs/common/src/auth/login-strategies/login.strategy.ts b/libs/common/src/auth/login-strategies/login.strategy.ts index fb328c865c..74b2d9a762 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.ts @@ -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; async logInTwoFactor( diff --git a/libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts new file mode 100644 index 0000000000..83de3b5d6a --- /dev/null +++ b/libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts @@ -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; + let apiService!: MockProxy; + let tokenService!: MockProxy; + let appIdService!: MockProxy; + let platformUtilsService!: MockProxy; + let messagingService!: MockProxy; + let logService!: MockProxy; + let stateService!: MockProxy; + let twoFactorService!: MockProxy; + + 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(); + apiService = mock(); + tokenService = mock(); + appIdService = mock(); + platformUtilsService = mock(); + messagingService = mock(); + logService = mock(); + stateService = mock(); + twoFactorService = mock(); + + 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 { + return Promise.resolve(false); + } + + static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { + return Promise.resolve(false); + } +} diff --git a/libs/common/src/auth/login-strategies/webauthn-login.strategy.ts b/libs/common/src/auth/login-strategies/webauthn-login.strategy.ts new file mode 100644 index 0000000000..8d47be0197 --- /dev/null +++ b/libs/common/src/auth/login-strategies/webauthn-login.strategy.ts @@ -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 { + await this.cryptoService.setPrivateKey( + response.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } + + async logInTwoFactor(): Promise { + 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; + } +} diff --git a/libs/common/src/auth/models/domain/login-credentials.ts b/libs/common/src/auth/models/domain/login-credentials.ts index a2a7016442..2e3e4916d0 100644 --- a/libs/common/src/auth/models/domain/login-credentials.ts +++ b/libs/common/src/auth/models/domain/login-credentials.ts @@ -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 + ) {} +} diff --git a/libs/common/src/auth/models/request/identity-token/token.request.ts b/libs/common/src/auth/models/request/identity-token/token.request.ts index 5195eedbdf..653f7fe5c3 100644 --- a/libs/common/src/auth/models/request/identity-token/token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/token.request.ts @@ -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; } diff --git a/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts b/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts new file mode 100644 index 0000000000..75c6cf4c27 --- /dev/null +++ b/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts @@ -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; + } +} diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index fcf1f49ace..466b3cca70 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -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") + ); + } } } diff --git a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts new file mode 100644 index 0000000000..777b3dffc4 --- /dev/null +++ b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts @@ -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")); + } + } +} diff --git a/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts new file mode 100644 index 0000000000..4b49e979b1 --- /dev/null +++ b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts @@ -0,0 +1,5 @@ +import { AssertionOptionsResponse } from "../../../services/webauthn-login/response/assertion-options.response"; + +export class WebAuthnLoginCredentialAssertionOptionsView { + constructor(readonly options: AssertionOptionsResponse, readonly token: string) {} +} diff --git a/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts new file mode 100644 index 0000000000..bb36a62e5f --- /dev/null +++ b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts @@ -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 + ) {} +} diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index 2e4862b409..1c5f066d3a 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -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(); @@ -116,6 +119,7 @@ export class AuthService implements AuthServiceAbstraction { | PasswordLoginCredentials | SsoLoginCredentials | AuthRequestLoginCredentials + | WebAuthnLoginCredentials ): Promise { 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(); diff --git a/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts new file mode 100644 index 0000000000..2f7eddb2d6 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts @@ -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), + }; + } +} diff --git a/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts new file mode 100644 index 0000000000..4b1eff4d47 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts @@ -0,0 +1,19 @@ +import { Utils } from "../../../../platform/misc/utils"; + +export abstract class WebAuthnLoginResponseRequest { + id: string; + rawId: string; + type: string; + extensions: Record; + + 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 + } +} diff --git a/libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts b/libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts new file mode 100644 index 0000000000..b7ac7354f2 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts @@ -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"); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts b/libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts new file mode 100644 index 0000000000..f0b1d27c12 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts @@ -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"); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts new file mode 100644 index 0000000000..a10f518881 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts @@ -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 { + const response = await this.apiService.send( + "GET", + `/accounts/webauthn/assertion-options`, + null, + false, + true, + this.environmentService.getIdentityUrl() + ); + return new CredentialAssertionOptionsResponse(response); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts new file mode 100644 index 0000000000..7bfb36d769 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts @@ -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; + let service: WebAuthnLoginPrfCryptoService; + + beforeEach(() => { + cryptoFunctionService = mock(); + 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)); +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts new file mode 100644 index 0000000000..4962f10010 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts @@ -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 { + return await this.cryptoFunctionService.hash(LoginWithPrfSalt, "sha256"); + } + + async createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise { + return (await this.stretchKey(new Uint8Array(prf))) as PrfKey; + } + + private async stretchKey(key: Uint8Array): Promise { + 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); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts new file mode 100644 index 0000000000..4b8e73abb2 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts @@ -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(); + const authService = mock(); + const configService = mock(); + const webAuthnLoginPrfCryptoService = mock(); + const navigatorCredentials = mock(); + const logService = mock(); + + 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 { + return Promise.resolve(false); + } + + static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { + 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 + ); +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts new file mode 100644 index 0000000000..43fcf00c3d --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts @@ -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; + + 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 { + const response = await this.webAuthnLoginApiService.getCredentialAssertionOptions(); + return new WebAuthnLoginCredentialAssertionOptionsView(response.options, response.token); + } + + async assertCredential( + credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView + ): Promise { + 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 { + const credential = new WebAuthnLoginCredentials( + assertion.token, + assertion.deviceResponse, + assertion.prfKey + ); + const result = await this.authService.logIn(credential); + return result; + } +} diff --git a/libs/common/src/platform/misc/utils.spec.ts b/libs/common/src/platform/misc/utils.spec.ts index 0fd76a8092..0a76493a01 100644 --- a/libs/common/src/platform/misc/utils.spec.ts +++ b/libs/common/src/platform/misc/utils.spec.ts @@ -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); diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 2feb6288af..776ec51a26 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -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) { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 2275c2015f..423cb38be1 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -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 { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",