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:
@@ -0,0 +1,7 @@
|
||||
import { WebauthnResponseRequest } from "./webauthn-response.request";
|
||||
|
||||
export class SaveCredentialRequest {
|
||||
deviceResponse: WebauthnResponseRequest;
|
||||
name: string;
|
||||
token: string;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user