1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 19:53:59 +00:00

[PM-2014] feat: implement credential saving

This commit is contained in:
Andreas Coroiu
2023-05-09 14:16:30 +02:00
parent 607c585dbf
commit d5ca6fa39f
8 changed files with 150 additions and 19 deletions

View File

@@ -0,0 +1,7 @@
import { WebauthnResponseRequest } from "./webauthn-response.request";
export class SaveCredentialRequest {
deviceResponse: WebauthnResponseRequest;
name: string;
token: string;
}

View File

@@ -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),
};
}
}

View File

@@ -0,0 +1,15 @@
import { Utils } from "@bitwarden/common/misc/utils";
export abstract class WebauthnResponseRequest {
id: string;
rawId: string;
type: string;
extensions: Record<string, unknown>;
constructor(credential: PublicKeyCredential) {
this.id = credential.id;
this.rawId = Utils.fromBufferToB64(credential.rawId);
this.type = credential.type;
this.extensions = {}; // Extensions are handled client-side
}
}

View File

@@ -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<boolean> {
await this.apiService.send("POST", "/webauthn", request, true, true);
return true;
}
}

View File

@@ -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<WebauthnApiService>();
platformUtilsService = mock<PlatformUtilsService>();
i18nService = mock<I18nService>();
@@ -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;
}

View File

@@ -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<WebauthnCredentialView | undefined> {
): Promise<PublicKeyCredential | undefined> {
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;
}
}
}

View File

@@ -62,14 +62,7 @@
{{ "turnOn" | i18n }}
</ng-container>
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
bitDialogClose
[bit-dialog-close]="CreateCredentialDialogResult.Canceled"
>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>

View File

@@ -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<unknown>
) => {
return dialogService.open<CreateCredentialDialogResult, unknown>(
return dialogService.open<CreateCredentialDialogResult | undefined, unknown>(
CreateCredentialDialogComponent,
config
);