mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 08:43:33 +00:00
* [PM-11419] Login items do not display after adding passkey * [PM-11419] Login items do not display after adding passkey * [PM-11419] Incorporating fixes for deleting a cipher from the inline menu as well as authenticating using passkeys * [PM-11419] Fixing an issue where master password reprompt is ignored for a set passkey cipher * [PM-11419] Fixing an issue where saving a passkey does not trigger a clearing of cached cipher values * [PM-11419] Refactoring implementation * [PM-11419] Ensuring that passkeys must be enabled in order for ciphers to appear * [PM-11419] Adding an abort event from the active request manager * [PM-11419] Adding an abort event from the active request manager * [PM-11419] Working through jest tests within implementation * [PM-11419] Fixing jest tests within Fido2ClientService and Fido2AuthenticatorService * [PM-11419] Adding jest tests for added logic within OverlayBackground * [PM-11419] Adding jest tests for added logic within OverlayBackground * [PM-11419] Reworking how we handle assuming user presence when master password reprompt is required * [PM-11419] Reworking how we handle assuming user presence when master password reprompt is required * [PM-11419] Reworking how we handle assuming user presence when master password reprompt is required * [PM-11419] Refactoring implementation * [PM-11419] Incorporating suggestion for reporting failed passkey authentication from the inline menu * [PM-11419] Reworking positioning of the abort controller that informs the background script of an error * [PM-11419] Scoping down the behavior surrounding master password reprompt a bit more tightly * [PM-11419] Reworking how we handle reacting to active fido2 requests to avoid ambiguity * [PM-11419] Reworking how we handle reacting to active fido2 requests to avoid ambiguity * [PM-11419] Adjusting implementation to ensure we clear any active requests when the passkeys setting is modified
692 lines
27 KiB
TypeScript
692 lines
27 KiB
TypeScript
import { mock, MockProxy } from "jest-mock-extended";
|
||
import { BehaviorSubject, of } from "rxjs";
|
||
|
||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||
import { Utils } from "../../../platform/misc/utils";
|
||
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
|
||
import { ConfigService } from "../../abstractions/config/config.service";
|
||
import {
|
||
ActiveRequest,
|
||
Fido2ActiveRequestEvents,
|
||
Fido2ActiveRequestManager,
|
||
RequestResult,
|
||
} from "../../abstractions/fido2/fido2-active-request-manager.abstraction";
|
||
import {
|
||
Fido2AuthenticatorError,
|
||
Fido2AuthenticatorErrorCode,
|
||
Fido2AuthenticatorGetAssertionResult,
|
||
Fido2AuthenticatorMakeCredentialResult,
|
||
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
|
||
import {
|
||
AssertCredentialParams,
|
||
CreateCredentialParams,
|
||
FallbackRequestedError,
|
||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||
import { TaskSchedulerService } from "../../scheduling/task-scheduler.service";
|
||
|
||
import * as DomainUtils from "./domain-utils";
|
||
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
||
import { Fido2ClientService } from "./fido2-client.service";
|
||
import { Fido2Utils } from "./fido2-utils";
|
||
import { guidToRawFormat } from "./guid-utils";
|
||
|
||
const RpId = "bitwarden.com";
|
||
const Origin = "https://bitwarden.com";
|
||
const VaultUrl = "https://vault.bitwarden.com";
|
||
|
||
describe("FidoAuthenticatorService", () => {
|
||
let authenticator!: MockProxy<Fido2AuthenticatorService>;
|
||
let configService!: MockProxy<ConfigService>;
|
||
let authService!: MockProxy<AuthService>;
|
||
let vaultSettingsService: MockProxy<VaultSettingsService>;
|
||
let domainSettingsService: MockProxy<DomainSettingsService>;
|
||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||
let activeRequest!: MockProxy<ActiveRequest>;
|
||
let requestManager!: MockProxy<Fido2ActiveRequestManager>;
|
||
let client!: Fido2ClientService;
|
||
let tab!: chrome.tabs.Tab;
|
||
let isValidRpId!: jest.SpyInstance;
|
||
|
||
beforeEach(async () => {
|
||
authenticator = mock<Fido2AuthenticatorService>();
|
||
configService = mock<ConfigService>();
|
||
authService = mock<AuthService>();
|
||
vaultSettingsService = mock<VaultSettingsService>();
|
||
domainSettingsService = mock<DomainSettingsService>();
|
||
taskSchedulerService = mock<TaskSchedulerService>();
|
||
activeRequest = mock<ActiveRequest>({
|
||
subject: new BehaviorSubject<RequestResult>({
|
||
type: Fido2ActiveRequestEvents.Continue,
|
||
credentialId: "",
|
||
}),
|
||
});
|
||
requestManager = mock<Fido2ActiveRequestManager>({
|
||
getActiveRequest$: (tabId: number) => new BehaviorSubject(activeRequest),
|
||
getActiveRequest: (tabId: number) => activeRequest,
|
||
});
|
||
|
||
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
|
||
|
||
client = new Fido2ClientService(
|
||
authenticator,
|
||
configService,
|
||
authService,
|
||
vaultSettingsService,
|
||
domainSettingsService,
|
||
taskSchedulerService,
|
||
requestManager,
|
||
);
|
||
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
||
vaultSettingsService.enablePasskeys$ = of(true);
|
||
domainSettingsService.neverDomains$ = of({});
|
||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
|
||
});
|
||
|
||
afterEach(() => {
|
||
isValidRpId.mockRestore();
|
||
});
|
||
|
||
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, tab);
|
||
|
||
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: "displayName", name: "name" } });
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
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: "displayName",
|
||
name: "name",
|
||
},
|
||
});
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
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 callerOrigin’s 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, tab);
|
||
|
||
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.
|
||
// This is actually checked by `isValidRpId` function, but we'll test it here as well
|
||
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: "Bitwarden" },
|
||
});
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "SecurityError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
// Sanity check to make sure that we use `isValidRpId` to validate the rp.id
|
||
it("should throw if isValidRpId returns false", async () => {
|
||
const params = createParams();
|
||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||
// `params` actually has a valid rp.id, but we're mocking the function to return false
|
||
isValidRpId.mockReturnValue(false);
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "SecurityError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
it("should fallback if origin hostname is found in neverDomains", async () => {
|
||
const params = createParams({
|
||
origin: "https://bitwarden.com",
|
||
rp: { id: "bitwarden.com", name: "Bitwarden" },
|
||
});
|
||
domainSettingsService.neverDomains$ = of({ "bitwarden.com": null });
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
await expect(result).rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
|
||
it("should throw error if origin is not an https domain", async () => {
|
||
const params = createParams({
|
||
origin: "http://passwordless.dev",
|
||
rp: { id: "bitwarden.com", name: "Bitwarden" },
|
||
});
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "SecurityError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
it("should not throw error if localhost is http", async () => {
|
||
const params = createParams({
|
||
origin: "http://localhost",
|
||
rp: { id: undefined, name: "localhost" },
|
||
});
|
||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||
|
||
await client.createCredential(params, tab);
|
||
});
|
||
|
||
// 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, tab);
|
||
|
||
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, tab, 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, tab);
|
||
|
||
expect(authenticator.makeCredential).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
requireResidentKey: true,
|
||
requireUserVerification: true,
|
||
rpEntity: expect.objectContaining({
|
||
id: RpId,
|
||
}),
|
||
userEntity: expect.objectContaining({
|
||
displayName: params.user.displayName,
|
||
}),
|
||
}),
|
||
tab,
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("should return credProps.rk = true when creating a discoverable credential", async () => {
|
||
const params = createParams({
|
||
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
||
extensions: { credProps: true },
|
||
});
|
||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||
|
||
const result = await client.createCredential(params, tab);
|
||
|
||
expect(result.extensions.credProps?.rk).toBe(true);
|
||
});
|
||
|
||
it("should return credProps.rk = false when creating a non-discoverable credential", async () => {
|
||
const params = createParams({
|
||
authenticatorSelection: { residentKey: "discouraged", userVerification: "required" },
|
||
extensions: { credProps: true },
|
||
});
|
||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||
|
||
const result = await client.createCredential(params, tab);
|
||
|
||
expect(result.extensions.credProps?.rk).toBe(false);
|
||
});
|
||
|
||
it("should return credProps = undefiend when the extension is not requested", async () => {
|
||
const params = createParams({
|
||
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
||
extensions: {},
|
||
});
|
||
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
|
||
|
||
const result = await client.createCredential(params, tab);
|
||
|
||
expect(result.extensions.credProps).toBeUndefined();
|
||
});
|
||
|
||
// 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 Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState),
|
||
);
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
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, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "NotAllowedError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
|
||
const params = createParams();
|
||
vaultSettingsService.enablePasskeys$ = of(false);
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
|
||
it("should throw FallbackRequestedError if user is logged out", async () => {
|
||
const params = createParams();
|
||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
|
||
it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => {
|
||
const params = createParams({ origin: VaultUrl });
|
||
|
||
const result = async () => await client.createCredential(params, tab);
|
||
|
||
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",
|
||
name: "name",
|
||
},
|
||
fallbackSupported: params.fallbackSupported ?? false,
|
||
timeout: params.timeout,
|
||
};
|
||
}
|
||
|
||
function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult {
|
||
return {
|
||
credentialId: guidToRawFormat(Utils.newGuid()),
|
||
attestationObject: randomBytes(128),
|
||
authData: randomBytes(64),
|
||
publicKey: 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 callerOrigin’s 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, tab);
|
||
|
||
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.
|
||
// This is actually checked by `isValidRpId` function, but we'll test it here as well
|
||
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, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "SecurityError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
// Sanity check to make sure that we use `isValidRpId` to validate the rp.id
|
||
it("should throw if isValidRpId returns false", async () => {
|
||
const params = createParams();
|
||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||
// `params` actually has a valid rp.id, but we're mocking the function to return false
|
||
isValidRpId.mockReturnValue(false);
|
||
|
||
const result = async () => await client.assertCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "SecurityError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
it("should fallback if origin hostname is found in neverDomains", async () => {
|
||
const params = createParams({
|
||
origin: "https://bitwarden.com",
|
||
});
|
||
|
||
domainSettingsService.neverDomains$ = of({ "bitwarden.com": null });
|
||
|
||
const result = async () => await client.assertCredential(params, tab);
|
||
|
||
await expect(result).rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
|
||
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, tab);
|
||
|
||
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, tab, 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 Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState),
|
||
);
|
||
|
||
const result = async () => await client.assertCredential(params, tab);
|
||
|
||
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, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toMatchObject({ name: "NotAllowedError" });
|
||
await rejects.toBeInstanceOf(DOMException);
|
||
});
|
||
|
||
it("should throw FallbackRequestedError if passkeys state is not enabled", async () => {
|
||
const params = createParams();
|
||
vaultSettingsService.enablePasskeys$ = of(false);
|
||
|
||
const result = async () => await client.assertCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
|
||
it("should throw FallbackRequestedError if user is logged out", async () => {
|
||
const params = createParams();
|
||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
|
||
|
||
const result = async () => await client.assertCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
|
||
it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => {
|
||
const params = createParams({ origin: VaultUrl });
|
||
|
||
const result = async () => await client.assertCredential(params, tab);
|
||
|
||
const rejects = expect(result).rejects;
|
||
await rejects.toThrow(FallbackRequestedError);
|
||
});
|
||
});
|
||
|
||
describe("assert non-discoverable credential", () => {
|
||
it("should call authenticator.assertCredential", async () => {
|
||
const allowedCredentialIds = [
|
||
Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())),
|
||
Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())),
|
||
Fido2Utils.bufferToString(Utils.fromByteStringToArray("not-a-guid")),
|
||
];
|
||
const params = createParams({
|
||
userVerification: "required",
|
||
allowedCredentialIds,
|
||
});
|
||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||
|
||
await client.assertCredential(params, tab);
|
||
|
||
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]),
|
||
}),
|
||
],
|
||
}),
|
||
tab,
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("should not throw error if localhost is http", async () => {
|
||
const params = createParams({
|
||
origin: "http://localhost",
|
||
});
|
||
params.rpId = undefined;
|
||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||
|
||
await client.assertCredential(params, tab);
|
||
});
|
||
});
|
||
|
||
describe("assert discoverable credential", () => {
|
||
it("should call authenticator.assertCredential", async () => {
|
||
const params = createParams({
|
||
userVerification: "required",
|
||
allowedCredentialIds: [],
|
||
});
|
||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||
|
||
await client.assertCredential(params, tab);
|
||
|
||
expect(authenticator.getAssertion).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
requireUserVerification: true,
|
||
rpId: RpId,
|
||
allowCredentialDescriptorList: [],
|
||
}),
|
||
tab,
|
||
expect.anything(),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("assert mediated conditional ui credential", () => {
|
||
const params = createParams({
|
||
userVerification: "required",
|
||
mediation: "conditional",
|
||
allowedCredentialIds: [],
|
||
});
|
||
|
||
beforeEach(() => {
|
||
requestManager.newActiveRequest.mockResolvedValue({
|
||
type: Fido2ActiveRequestEvents.Continue,
|
||
credentialId: crypto.randomUUID(),
|
||
});
|
||
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
|
||
});
|
||
|
||
it("creates an active mediated conditional request", async () => {
|
||
await client.assertCredential(params, tab);
|
||
|
||
expect(requestManager.newActiveRequest).toHaveBeenCalled();
|
||
expect(authenticator.getAssertion).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
assumeUserPresence: true,
|
||
rpId: RpId,
|
||
}),
|
||
tab,
|
||
);
|
||
});
|
||
|
||
it("restarts the mediated conditional request if a user aborts the request", async () => {
|
||
authenticator.getAssertion.mockRejectedValueOnce(new Error());
|
||
|
||
await client.assertCredential(params, tab);
|
||
|
||
expect(authenticator.getAssertion).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("restarts the mediated conditional request if a the abort controller aborts the request", async () => {
|
||
const abortController = new AbortController();
|
||
abortController.abort();
|
||
authenticator.getAssertion.mockRejectedValueOnce(new DOMException("AbortError"));
|
||
|
||
await client.assertCredential(params, tab);
|
||
|
||
expect(authenticator.getAssertion).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
function createParams(params: Partial<AssertCredentialParams> = {}): AssertCredentialParams {
|
||
return {
|
||
allowedCredentialIds: params.allowedCredentialIds ?? [],
|
||
challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)),
|
||
origin: params.origin ?? Origin,
|
||
rpId: params.rpId ?? RpId,
|
||
timeout: params.timeout,
|
||
userVerification: params.userVerification,
|
||
sameOriginWithAncestors: true,
|
||
fallbackSupported: params.fallbackSupported ?? false,
|
||
mediation: params.mediation,
|
||
};
|
||
}
|
||
|
||
function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult {
|
||
return {
|
||
selectedCredential: {
|
||
id: randomBytes(32),
|
||
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));
|
||
}
|