From d5ca6fa39fdfec675b62937569418d00c2b12b83 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 9 May 2023 14:16:30 +0200 Subject: [PATCH] [PM-2014] feat: implement credential saving --- .../request/save-credential.request.ts | 7 +++ .../webauthn-attestation-response.request.ts | 23 +++++++++ .../request/webauthn-response.request.ts | 15 ++++++ .../services/webauthn/webauthn-api.service.ts | 6 +++ .../webauthn/webauthn.service.spec.ts | 48 ++++++++++++++++++- .../services/webauthn/webauthn.service.ts | 34 +++++++++++-- .../create-credential-dialog.component.html | 9 +--- .../create-credential-dialog.component.ts | 27 +++++++++-- 8 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/auth/core/services/webauthn/request/save-credential.request.ts create mode 100644 apps/web/src/app/auth/core/services/webauthn/request/webauthn-attestation-response.request.ts create mode 100644 apps/web/src/app/auth/core/services/webauthn/request/webauthn-response.request.ts diff --git a/apps/web/src/app/auth/core/services/webauthn/request/save-credential.request.ts b/apps/web/src/app/auth/core/services/webauthn/request/save-credential.request.ts new file mode 100644 index 00000000000..94ba5bd56c6 --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn/request/save-credential.request.ts @@ -0,0 +1,7 @@ +import { WebauthnResponseRequest } from "./webauthn-response.request"; + +export class SaveCredentialRequest { + deviceResponse: WebauthnResponseRequest; + name: string; + token: string; +} diff --git a/apps/web/src/app/auth/core/services/webauthn/request/webauthn-attestation-response.request.ts b/apps/web/src/app/auth/core/services/webauthn/request/webauthn-attestation-response.request.ts new file mode 100644 index 00000000000..1a632cb948c --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn/request/webauthn-attestation-response.request.ts @@ -0,0 +1,23 @@ +import { Utils } from "@bitwarden/common/misc/utils"; + +import { WebauthnResponseRequest } from "./webauthn-response.request"; + +export class WebauthnAttestationResponseRequest extends WebauthnResponseRequest { + response: { + attestationObject: string; + clientDataJson: string; + }; + + constructor(credential: PublicKeyCredential) { + super(credential); + + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + throw new Error("Invalid authenticator response"); + } + + this.response = { + attestationObject: Utils.fromBufferToB64(credential.response.attestationObject), + clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON), + }; + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn/request/webauthn-response.request.ts b/apps/web/src/app/auth/core/services/webauthn/request/webauthn-response.request.ts new file mode 100644 index 00000000000..edd890e8e6b --- /dev/null +++ b/apps/web/src/app/auth/core/services/webauthn/request/webauthn-response.request.ts @@ -0,0 +1,15 @@ +import { Utils } from "@bitwarden/common/misc/utils"; + +export abstract class WebauthnResponseRequest { + id: string; + rawId: string; + type: string; + extensions: Record; + + constructor(credential: PublicKeyCredential) { + this.id = credential.id; + this.rawId = Utils.fromBufferToB64(credential.rawId); + this.type = credential.type; + this.extensions = {}; // Extensions are handled client-side + } +} diff --git a/apps/web/src/app/auth/core/services/webauthn/webauthn-api.service.ts b/apps/web/src/app/auth/core/services/webauthn/webauthn-api.service.ts index 04a42d637fd..11fec06962d 100644 --- a/apps/web/src/app/auth/core/services/webauthn/webauthn-api.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn/webauthn-api.service.ts @@ -6,6 +6,7 @@ import { Verification } from "@bitwarden/common/types/verification"; import { CoreAuthModule } from "../../core.module"; +import { SaveCredentialRequest } from "./request/save-credential.request"; import { CredentialCreateOptionsResponse } from "./response/credential-create-options.response"; @Injectable({ providedIn: CoreAuthModule }) @@ -22,4 +23,9 @@ export class WebauthnApiService { const response = await this.apiService.send("POST", "/webauthn/options", request, true, true); return new CredentialCreateOptionsResponse(response); } + + async saveCredential(request: SaveCredentialRequest): Promise { + await this.apiService.send("POST", "/webauthn", request, true, true); + return true; + } } diff --git a/apps/web/src/app/auth/core/services/webauthn/webauthn.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn/webauthn.service.spec.ts index 1ceb11273ca..086965712e7 100644 --- a/apps/web/src/app/auth/core/services/webauthn/webauthn.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn/webauthn.service.spec.ts @@ -19,6 +19,9 @@ describe("WebauthnService", () => { let webauthnService!: WebauthnService; beforeAll(() => { + // Polyfill missing class + window.PublicKeyCredential = class {} as any; + window.AuthenticatorAttestationResponse = class {} as any; apiService = mock(); platformUtilsService = mock(); i18nService = mock(); @@ -66,8 +69,8 @@ describe("WebauthnService", () => { }); it("should return credential when navigator.credentials does not throw", async () => { - const credential: Credential = Symbol() as any; - credentials.create.mockResolvedValue(credential); + const credential = createDeviceResponse(); + credentials.create.mockResolvedValue(credential as PublicKeyCredential); const options = createCredentialCreateOptions(); const result = await webauthnService.createCredential(options); @@ -75,6 +78,30 @@ describe("WebauthnService", () => { expect(result).toBe(credential); }); }); + + describe("saveCredential", () => { + it("should return false and show toast when api service call throws", async () => { + apiService.saveCredential.mockRejectedValue(new Error("Mock error")); + const options = createCredentialCreateOptions(); + const deviceResponse = Symbol() as any; + + const result = await webauthnService.saveCredential(options, deviceResponse, "name"); + + expect(result).toBe(false); + expect(platformUtilsService.showToast).toHaveBeenCalled(); + }); + + it("should return true when api service call is successfull", async () => { + apiService.saveCredential.mockResolvedValue(true); + const options = createCredentialCreateOptions(); + const deviceResponse = createDeviceResponse(); + + const result = await webauthnService.saveCredential(options, deviceResponse, "name"); + + expect(result).toBe(true); + expect(apiService.saveCredential).toHaveBeenCalled(); + }); + }); }); function createVerification(): Verification { @@ -87,3 +114,20 @@ function createVerification(): Verification { function createCredentialCreateOptions(): CredentialCreateOptionsView { return new CredentialCreateOptionsView(Symbol() as any, Symbol() as any); } + +function createDeviceResponse(): PublicKeyCredential { + const credential = { + id: "dGVzdA==", + rawId: new Uint8Array([0x74, 0x65, 0x73, 0x74]), + type: "public-key", + response: { + attestationObject: new Uint8Array([0, 0, 0]), + clientDataJSON: "eyJ0ZXN0IjoidGVzdCJ9", + }, + } as any; + + Object.setPrototypeOf(credential, PublicKeyCredential.prototype); + Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype); + + return credential; +} diff --git a/apps/web/src/app/auth/core/services/webauthn/webauthn.service.ts b/apps/web/src/app/auth/core/services/webauthn/webauthn.service.ts index bea5b414560..3474af231c8 100644 --- a/apps/web/src/app/auth/core/services/webauthn/webauthn.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn/webauthn.service.ts @@ -9,10 +9,10 @@ import { Verification } from "@bitwarden/common/types/verification"; import { CoreAuthModule } from "../../core.module"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; +import { SaveCredentialRequest } from "./request/save-credential.request"; +import { WebauthnAttestationResponseRequest } from "./request/webauthn-attestation-response.request"; import { WebauthnApiService } from "./webauthn-api.service"; -type WebauthnCredentialView = unknown; - @Injectable({ providedIn: CoreAuthModule }) export class WebauthnService { private credentials: CredentialsContainer; @@ -51,15 +51,39 @@ export class WebauthnService { async createCredential( credentialOptions: CredentialCreateOptionsView - ): Promise { + ): Promise { const nativeOptions: CredentialCreationOptions = { publicKey: credentialOptions.options, }; try { - return await this.credentials.create(nativeOptions); - } catch { + const response = await this.credentials.create(nativeOptions); + if (!(response instanceof PublicKeyCredential)) { + return undefined; + } + return response; + } catch (error) { + this.logService?.error(error); return undefined; } } + + async saveCredential( + credentialOptions: CredentialCreateOptionsView, + deviceResponse: PublicKeyCredential, + name: string + ) { + try { + const request = new SaveCredentialRequest(); + request.deviceResponse = new WebauthnAttestationResponseRequest(deviceResponse); + request.token = credentialOptions.token; + request.name = name; + await this.apiService.saveCredential(request); + return true; + } catch (error) { + this.logService?.error(error); + this.platformUtilsService.showToast("error", null, this.i18nService.t("unexpectedError")); + return false; + } + } } diff --git a/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.html b/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.html index 86f665f4a30..44ac9d92b9c 100644 --- a/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.html +++ b/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.html @@ -62,14 +62,7 @@ {{ "turnOn" | i18n }} - diff --git a/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.ts b/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.ts index ed241c2afd5..0c8095297c2 100644 --- a/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/fido2-login-settings/create-credential-dialog/create-credential-dialog.component.ts @@ -13,7 +13,6 @@ import { CreatePasskeyIcon } from "./create-passkey.icon"; export enum CreateCredentialDialogResult { Success, - Canceled, } type Step = @@ -40,6 +39,7 @@ export class CreateCredentialDialogComponent { }), }); protected credentialOptions?: CredentialCreateOptionsView; + protected deviceResponse?: PublicKeyCredential; constructor( private formBuilder: FormBuilder, @@ -72,12 +72,31 @@ export class CreateCredentialDialogComponent { } if (this.currentStep === "credentialCreation") { - const credential = await this.webauthnService.createCredential(this.credentialOptions); - if (credential === undefined) { + this.deviceResponse = await this.webauthnService.createCredential(this.credentialOptions); + if (this.deviceResponse === undefined) { this.currentStep = "credentialCreationFailed"; return; } this.currentStep = "credentialNaming"; + return; + } + + if (this.currentStep === "credentialNaming") { + this.formGroup.controls.credentialNaming.markAllAsTouched(); + if (this.formGroup.controls.credentialNaming.invalid) { + return; + } + + const result = await this.webauthnService.saveCredential( + this.credentialOptions, + this.deviceResponse, + this.formGroup.value.credentialNaming.name + ); + if (!result) { + return; + } + + this.dialogRef.close(CreateCredentialDialogResult.Success); } } finally { this.dialogRef.disableClose = false; @@ -94,7 +113,7 @@ export const openCreateCredentialDialog = ( dialogService: DialogServiceAbstraction, config: DialogConfig ) => { - return dialogService.open( + return dialogService.open( CreateCredentialDialogComponent, config );