1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 10:13:31 +00:00
Files
browser/libs/common/src/fido2/services/fido2-client.service.spec.ts
SmithThe4th c14ab487ac [PM-1500] Add feature flag to enable passkeys (#5406)
* Added launch darkly feature flag to passkeys implementation

* fixed linter

* Updated fido2 client service test to accomodate feature flag

* Updated fido2client service to include unit test for feature flag

* Renamed enable pass keys to fido2 vault credentials, added unit test when feature flag is not enabled

* fixed failing Login domain test case
2023-05-24 08:06:41 -04:00

431 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mock, MockProxy } from "jest-mock-extended";
import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
import { Utils } from "../../misc/utils";
import {
Fido2AutenticatorError,
Fido2AutenticatorErrorCode,
Fido2AuthenticatorGetAssertionResult,
Fido2AuthenticatorMakeCredentialResult,
} from "../abstractions/fido2-authenticator.service.abstraction";
import {
AssertCredentialParams,
CreateCredentialParams,
FallbackRequestedError,
} from "../abstractions/fido2-client.service.abstraction";
import { Fido2Utils } from "../abstractions/fido2-utils";
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
import { Fido2ClientService } from "./fido2-client.service";
const RpId = "bitwarden.com";
describe("FidoAuthenticatorService", () => {
let authenticator!: MockProxy<Fido2AuthenticatorService>;
let configService!: MockProxy<ConfigServiceAbstraction>;
let client!: Fido2ClientService;
beforeEach(async () => {
authenticator = mock<Fido2AuthenticatorService>();
configService = mock<ConfigServiceAbstraction>();
client = new Fido2ClientService(authenticator, configService);
configService.getFeatureFlagBool.mockResolvedValue(true);
});
describe("createCredential", () => {
describe("input parameters validation", () => {
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
it("should throw error if sameOriginWithAncestors is false", async () => {
const params = createParams({ sameOriginWithAncestors: false });
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
await rejects.toBeInstanceOf(DOMException);
});
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
it("should throw error if user.id is too small", async () => {
const params = createParams({ user: { id: "", displayName: "name" } });
const result = async () => await client.createCredential(params);
await expect(result).rejects.toBeInstanceOf(TypeError);
});
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
it("should throw error if user.id is too large", async () => {
const params = createParams({
user: {
id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ",
displayName: "name",
},
});
const result = async () => await client.createCredential(params);
await expect(result).rejects.toBeInstanceOf(TypeError);
});
// Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm.
// Not sure how to check this, or if it matters.
it.todo("should throw error if origin is an opaque origin");
// Spec: Let effectiveDomain be the callerOrigins effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm.
it("should throw error if origin is not a valid domain name", async () => {
const params = createParams({
origin: "invalid-domain-name",
});
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
it("should throw error if rp.id is not valid for this origin", async () => {
const params = createParams({
origin: "https://passwordless.dev",
rp: { id: "bitwarden.com", name: "Bitwraden" },
});
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});
it("should throw error if origin is not an https domain", async () => {
const params = createParams({
origin: "http://passwordless.dev",
rp: { id: "bitwarden.com", name: "Bitwraden" },
});
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});
// Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm.
it("should throw error if no support key algorithms were found", async () => {
const params = createParams({
pubKeyCredParams: [
{ alg: -9001, type: "public-key" },
{ alg: -7, type: "not-supported" as any },
],
});
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotSupportedError" });
await rejects.toBeInstanceOf(DOMException);
});
});
describe("aborting", () => {
// Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm.
it("should throw error if aborting using abort controller", async () => {
const params = createParams({});
const abortController = new AbortController();
abortController.abort();
const result = async () => await client.createCredential(params, abortController);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "AbortError" });
await rejects.toBeInstanceOf(DOMException);
});
});
describe("creating a new credential", () => {
it("should call authenticator.makeCredential", async () => {
const params = createParams({
authenticatorSelection: { residentKey: "required", userVerification: "required" },
});
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
await client.createCredential(params);
expect(authenticator.makeCredential).toHaveBeenCalledWith(
expect.objectContaining({
requireResidentKey: true,
requireUserVerification: true,
rpEntity: expect.objectContaining({
id: RpId,
}),
userEntity: expect.objectContaining({
displayName: params.user.displayName,
}),
}),
expect.anything()
);
});
// Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm.
it("should throw error if authenticator throws InvalidState", async () => {
const params = createParams();
authenticator.makeCredential.mockRejectedValue(
new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState)
);
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "InvalidStateError" });
await rejects.toBeInstanceOf(DOMException);
});
// This keeps sensetive information form leaking
it("should throw NotAllowedError if authenticator throws unknown error", async () => {
const params = createParams();
authenticator.makeCredential.mockRejectedValue(new Error("unknown error"));
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
await rejects.toBeInstanceOf(DOMException);
});
it("should throw FallbackRequestedError if feature flag is not enabled", async () => {
const params = createParams();
configService.getFeatureFlagBool.mockResolvedValue(false);
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
});
function createParams(params: Partial<CreateCredentialParams> = {}): CreateCredentialParams {
return {
origin: params.origin ?? "https://bitwarden.com",
sameOriginWithAncestors: params.sameOriginWithAncestors ?? true,
attestation: params.attestation,
authenticatorSelection: params.authenticatorSelection,
challenge: params.challenge ?? "MzItYnl0ZXMtYmFzZTY0LWVuY29kZS1jaGFsbGVuZ2U",
excludeCredentials: params.excludeCredentials,
extensions: params.extensions,
pubKeyCredParams: params.pubKeyCredParams ?? [
{
alg: -7,
type: "public-key",
},
],
rp: params.rp ?? {
id: RpId,
name: "Bitwarden",
},
user: params.user ?? {
id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA",
displayName: "User Name",
},
timeout: params.timeout,
};
}
function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult {
return {
credentialId: Utils.guidToRawFormat(Utils.newGuid()),
attestationObject: randomBytes(128),
authData: randomBytes(64),
publicKeyAlgorithm: -7,
};
}
});
describe("assertCredential", () => {
describe("invalid params", () => {
// Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm.
// Not sure how to check this, or if it matters.
it.todo("should throw error if origin is an opaque origin");
// Spec: Let effectiveDomain be the callerOrigins effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm.
it("should throw error if origin is not a valid domain name", async () => {
const params = createParams({
origin: "invalid-domain-name",
});
const result = async () => await client.assertCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
it("should throw error if rp.id is not valid for this origin", async () => {
const params = createParams({
origin: "https://passwordless.dev",
rpId: "bitwarden.com",
});
const result = async () => await client.assertCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});
it("should throw error if origin is not an http domain", async () => {
const params = createParams({
origin: "http://passwordless.dev",
rpId: "bitwarden.com",
});
const result = async () => await client.assertCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "SecurityError" });
await rejects.toBeInstanceOf(DOMException);
});
});
describe("aborting", () => {
// Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm.
it("should throw error if aborting using abort controller", async () => {
const params = createParams({});
const abortController = new AbortController();
abortController.abort();
const result = async () => await client.assertCredential(params, abortController);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "AbortError" });
await rejects.toBeInstanceOf(DOMException);
});
});
describe("assert credential", () => {
// Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm.
it("should throw error if authenticator throws InvalidState", async () => {
const params = createParams();
authenticator.getAssertion.mockRejectedValue(
new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState)
);
const result = async () => await client.assertCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "InvalidStateError" });
await rejects.toBeInstanceOf(DOMException);
});
// This keeps sensetive information form leaking
it("should throw NotAllowedError if authenticator throws unknown error", async () => {
const params = createParams();
authenticator.getAssertion.mockRejectedValue(new Error("unknown error"));
const result = async () => await client.assertCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
await rejects.toBeInstanceOf(DOMException);
});
it("should throw FallbackRequestedError if feature flag is not enabled", async () => {
const params = createParams();
configService.getFeatureFlagBool.mockResolvedValue(false);
const result = async () => await client.assertCredential(params);
const rejects = expect(result).rejects;
await rejects.toThrow(FallbackRequestedError);
});
});
describe("assert non-discoverable credential", () => {
it("should call authenticator.assertCredential", async () => {
const allowedCredentialIds = [
Fido2Utils.bufferToString(Utils.guidToRawFormat(Utils.newGuid())),
Fido2Utils.bufferToString(Utils.guidToRawFormat(Utils.newGuid())),
Fido2Utils.bufferToString(Utils.fromByteStringToArray("not-a-guid")),
];
const params = createParams({
userVerification: "required",
allowedCredentialIds,
});
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
await client.assertCredential(params);
expect(authenticator.getAssertion).toHaveBeenCalledWith(
expect.objectContaining({
requireUserVerification: true,
rpId: RpId,
allowCredentialDescriptorList: [
expect.objectContaining({
id: Fido2Utils.stringToBuffer(allowedCredentialIds[0]),
}),
expect.objectContaining({
id: Fido2Utils.stringToBuffer(allowedCredentialIds[1]),
}),
expect.objectContaining({
id: Fido2Utils.stringToBuffer(allowedCredentialIds[2]),
}),
],
}),
expect.anything()
);
});
});
describe("assert discoverable credential", () => {
it("should call authenticator.assertCredential", async () => {
const params = createParams({
userVerification: "required",
allowedCredentialIds: [],
});
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
await client.assertCredential(params);
expect(authenticator.getAssertion).toHaveBeenCalledWith(
expect.objectContaining({
requireUserVerification: true,
rpId: RpId,
allowCredentialDescriptorList: [],
}),
expect.anything()
);
});
});
function createParams(params: Partial<AssertCredentialParams> = {}): AssertCredentialParams {
return {
allowedCredentialIds: params.allowedCredentialIds ?? [],
challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)),
origin: params.origin ?? "https://bitwarden.com",
rpId: params.rpId ?? RpId,
timeout: params.timeout,
userVerification: params.userVerification,
sameOriginWithAncestors: true,
};
}
function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult {
return {
selectedCredential: {
id: Utils.newGuid(),
userHandle: randomBytes(32),
},
authenticatorData: randomBytes(64),
signature: randomBytes(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));
}