mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-31 22:51:28 +01:00
[EC-598] feat: add initial implementation of UI sessions
This commit is contained in:
parent
55cd736ec3
commit
11d340bc97
@ -3,6 +3,7 @@ import { filter, first, lastValueFrom, Observable, Subject, takeUntil } from "rx
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import {
|
||||
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
|
||||
Fido2UserInterfaceSession,
|
||||
NewCredentialParams,
|
||||
} from "@bitwarden/common/webauthn/abstractions/fido2-user-interface.service.abstraction";
|
||||
|
||||
@ -87,6 +88,10 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
|
||||
|
||||
constructor(private popupUtilsService: PopupUtilsService) {}
|
||||
|
||||
async newSession(abortController?: AbortController): Promise<Fido2UserInterfaceSession> {
|
||||
return await BrowserFido2UserInterfaceSession.create(this, abortController);
|
||||
}
|
||||
|
||||
async confirmCredential(
|
||||
cipherId: string,
|
||||
abortController = new AbortController()
|
||||
@ -265,3 +270,71 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
|
||||
return setTimeout(() => abortController.abort());
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
|
||||
static async create(
|
||||
parentService: BrowserFido2UserInterfaceService,
|
||||
abortController?: AbortController
|
||||
): Promise<BrowserFido2UserInterfaceSession> {
|
||||
return new BrowserFido2UserInterfaceSession(parentService, abortController);
|
||||
}
|
||||
|
||||
readonly abortListener: () => void;
|
||||
|
||||
private constructor(
|
||||
private readonly parentService: BrowserFido2UserInterfaceService,
|
||||
readonly abortController = new AbortController(),
|
||||
readonly sessionId = Utils.newGuid()
|
||||
) {
|
||||
this.abortListener = () => this.abort();
|
||||
abortController.signal.addEventListener("abort", this.abortListener);
|
||||
}
|
||||
|
||||
fallbackRequested = false;
|
||||
|
||||
get aborted() {
|
||||
return this.abortController.signal.aborted;
|
||||
}
|
||||
|
||||
confirmCredential(cipherId: string, abortController?: AbortController): Promise<boolean> {
|
||||
return this.parentService.confirmCredential(cipherId, this.abortController);
|
||||
}
|
||||
|
||||
pickCredential(cipherIds: string[], abortController?: AbortController): Promise<string> {
|
||||
return this.parentService.pickCredential(cipherIds, this.abortController);
|
||||
}
|
||||
|
||||
confirmNewCredential(
|
||||
params: NewCredentialParams,
|
||||
abortController?: AbortController
|
||||
): Promise<boolean> {
|
||||
return this.parentService.confirmNewCredential(params, this.abortController);
|
||||
}
|
||||
|
||||
confirmNewNonDiscoverableCredential(
|
||||
params: NewCredentialParams,
|
||||
abortController?: AbortController
|
||||
): Promise<string> {
|
||||
return this.parentService.confirmNewNonDiscoverableCredential(params, this.abortController);
|
||||
}
|
||||
|
||||
informExcludedCredential(
|
||||
existingCipherIds: string[],
|
||||
newCredential: NewCredentialParams,
|
||||
abortController?: AbortController
|
||||
): Promise<void> {
|
||||
return this.parentService.informExcludedCredential(
|
||||
existingCipherIds,
|
||||
newCredential,
|
||||
this.abortController
|
||||
);
|
||||
}
|
||||
|
||||
private abort() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.abortController.signal.removeEventListener("abort", this.abortListener);
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,29 @@ export interface NewCredentialParams {
|
||||
}
|
||||
|
||||
export abstract class Fido2UserInterfaceService {
|
||||
newSession: (abortController?: AbortController) => Promise<Fido2UserInterfaceSession>;
|
||||
|
||||
// confirmCredential: (cipherId: string, abortController?: AbortController) => Promise<boolean>;
|
||||
// pickCredential: (cipherIds: string[], abortController?: AbortController) => Promise<string>;
|
||||
// confirmNewCredential: (
|
||||
// params: NewCredentialParams,
|
||||
// abortController?: AbortController
|
||||
// ) => Promise<boolean>;
|
||||
// confirmNewNonDiscoverableCredential: (
|
||||
// params: NewCredentialParams,
|
||||
// abortController?: AbortController
|
||||
// ) => Promise<string | undefined>;
|
||||
// informExcludedCredential: (
|
||||
// existingCipherIds: string[],
|
||||
// newCredential: NewCredentialParams,
|
||||
// abortController?: AbortController
|
||||
// ) => Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class Fido2UserInterfaceSession {
|
||||
fallbackRequested = false;
|
||||
aborted = false;
|
||||
|
||||
confirmCredential: (cipherId: string, abortController?: AbortController) => Promise<boolean>;
|
||||
pickCredential: (cipherIds: string[], abortController?: AbortController) => Promise<string>;
|
||||
confirmNewCredential: (
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
} from "../abstractions/fido2-authenticator.service.abstraction";
|
||||
import {
|
||||
Fido2UserInterfaceService,
|
||||
Fido2UserInterfaceSession,
|
||||
NewCredentialParams,
|
||||
} from "../abstractions/fido2-user-interface.service.abstraction";
|
||||
import { Fido2Utils } from "../abstractions/fido2-utils";
|
||||
@ -28,11 +29,14 @@ const RpId = "bitwarden.com";
|
||||
describe("FidoAuthenticatorService", () => {
|
||||
let cipherService!: MockProxy<CipherService>;
|
||||
let userInterface!: MockProxy<Fido2UserInterfaceService>;
|
||||
let userInterfaceSession!: MockProxy<Fido2UserInterfaceSession>;
|
||||
let authenticator!: Fido2AuthenticatorService;
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherService = mock<CipherService>();
|
||||
userInterface = mock<Fido2UserInterfaceService>();
|
||||
userInterfaceSession = mock<Fido2UserInterfaceSession>();
|
||||
userInterface.newSession.mockResolvedValue(userInterfaceSession);
|
||||
authenticator = new Fido2AuthenticatorService(cipherService, userInterface);
|
||||
});
|
||||
|
||||
@ -77,7 +81,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
|
||||
it("should not request confirmation from user", async () => {
|
||||
userInterface.confirmNewCredential.mockResolvedValue(true);
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue(true);
|
||||
const invalidParams = await createInvalidParams();
|
||||
|
||||
for (const p of Object.values(invalidParams)) {
|
||||
@ -86,7 +90,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
expect(userInterface.confirmNewCredential).not.toHaveBeenCalled();
|
||||
expect(userInterfaceSession.confirmNewCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -120,19 +124,19 @@ describe("FidoAuthenticatorService", () => {
|
||||
* Deviation: Consent is not asked and the user is simply informed of the situation.
|
||||
**/
|
||||
it("should inform user", async () => {
|
||||
userInterface.informExcludedCredential.mockResolvedValue();
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
|
||||
try {
|
||||
await authenticator.makeCredential(params);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
expect(userInterface.informExcludedCredential).toHaveBeenCalled();
|
||||
expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error", async () => {
|
||||
userInterface.informExcludedCredential.mockResolvedValue();
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params);
|
||||
|
||||
@ -140,7 +144,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
|
||||
it("should not inform user of duplication when input data does not pass checks", async () => {
|
||||
userInterface.informExcludedCredential.mockResolvedValue();
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
const invalidParams = await createInvalidParams();
|
||||
|
||||
for (const p of Object.values(invalidParams)) {
|
||||
@ -149,7 +153,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
expect(userInterface.informExcludedCredential).not.toHaveBeenCalled();
|
||||
expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.todo(
|
||||
@ -181,19 +185,19 @@ describe("FidoAuthenticatorService", () => {
|
||||
* Deviation: Consent is not asked and the user is simply informed of the situation.
|
||||
**/
|
||||
it("should inform user", async () => {
|
||||
userInterface.informExcludedCredential.mockResolvedValue();
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
|
||||
try {
|
||||
await authenticator.makeCredential(params);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
expect(userInterface.informExcludedCredential).toHaveBeenCalled();
|
||||
expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error", async () => {
|
||||
userInterface.informExcludedCredential.mockResolvedValue();
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params);
|
||||
|
||||
@ -201,7 +205,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
|
||||
it("should not inform user of duplication when input data does not pass checks", async () => {
|
||||
userInterface.informExcludedCredential.mockResolvedValue();
|
||||
userInterfaceSession.informExcludedCredential.mockResolvedValue();
|
||||
const invalidParams = await createInvalidParams();
|
||||
|
||||
for (const p of Object.values(invalidParams)) {
|
||||
@ -210,7 +214,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
expect(userInterface.informExcludedCredential).not.toHaveBeenCalled();
|
||||
expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.todo(
|
||||
@ -231,7 +235,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
* Deviation: Only `rpEntity.name` and `userEntity.name` is shown.
|
||||
* */
|
||||
it("should request confirmation from user", async () => {
|
||||
userInterface.confirmNewCredential.mockResolvedValue(true);
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue(true);
|
||||
cipherService.encrypt.mockResolvedValue({} as unknown as Cipher);
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = Utils.newGuid();
|
||||
@ -240,7 +244,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
await authenticator.makeCredential(params, new AbortController());
|
||||
|
||||
expect(userInterface.confirmNewCredential).toHaveBeenCalledWith(
|
||||
expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
@ -251,7 +255,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = {};
|
||||
userInterface.confirmNewCredential.mockResolvedValue(true);
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue(true);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = Utils.newGuid();
|
||||
@ -284,7 +288,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error if user denies creation request", async () => {
|
||||
userInterface.confirmNewCredential.mockResolvedValue(false);
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue(false);
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params);
|
||||
|
||||
@ -294,7 +298,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
/** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
const encryptedCipher = {};
|
||||
userInterface.confirmNewCredential.mockResolvedValue(true);
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue(true);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.createWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
@ -322,11 +326,13 @@ describe("FidoAuthenticatorService", () => {
|
||||
* Deviation: Only `rpEntity.name` and `userEntity.name` is shown.
|
||||
* */
|
||||
it("should request confirmation from user", async () => {
|
||||
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipher.id);
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(
|
||||
existingCipher.id
|
||||
);
|
||||
|
||||
await authenticator.makeCredential(params, new AbortController());
|
||||
|
||||
expect(userInterface.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith(
|
||||
expect(userInterfaceSession.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
@ -337,7 +343,9 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipher.id);
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(
|
||||
existingCipher.id
|
||||
);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
|
||||
await authenticator.makeCredential(params);
|
||||
@ -368,7 +376,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error if user denies creation request", async () => {
|
||||
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(undefined);
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(undefined);
|
||||
const params = await createParams();
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params);
|
||||
@ -379,7 +387,9 @@ describe("FidoAuthenticatorService", () => {
|
||||
/** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipher.id);
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(
|
||||
existingCipher.id
|
||||
);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
@ -408,8 +418,8 @@ describe("FidoAuthenticatorService", () => {
|
||||
beforeEach(async () => {
|
||||
const cipher = createCipherView({ id: cipherId, type: CipherType.Login });
|
||||
params = await createParams({ requireResidentKey });
|
||||
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(cipherId);
|
||||
userInterface.confirmNewCredential.mockResolvedValue(true);
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(cipherId);
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue(true);
|
||||
cipherService.get.mockImplementation(async (cipherId) =>
|
||||
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined
|
||||
);
|
||||
@ -625,16 +635,16 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */
|
||||
it("should request confirmation from the user", async () => {
|
||||
userInterface.pickCredential.mockResolvedValue(ciphers[0].id);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue(ciphers[0].id);
|
||||
|
||||
await authenticator.getAssertion(params);
|
||||
|
||||
expect(userInterface.pickCredential).toHaveBeenCalledWith(ciphers.map((c) => c.id));
|
||||
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith(ciphers.map((c) => c.id));
|
||||
});
|
||||
|
||||
/** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
it("should throw error", async () => {
|
||||
userInterface.pickCredential.mockResolvedValue(undefined);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue(undefined);
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params);
|
||||
|
||||
@ -690,7 +700,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
}
|
||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||
userInterface.pickCredential.mockResolvedValue(ciphers[0].id);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue(ciphers[0].id);
|
||||
});
|
||||
|
||||
/** Spec: Increment the credential associated signature counter */
|
||||
|
@ -41,6 +41,8 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
params: Fido2AuthenticatorMakeCredentialsParams,
|
||||
abortController?: AbortController
|
||||
): Promise<Fido2AuthenticatorMakeCredentialResult> {
|
||||
const userInterfaceSession = await this.userInterface.newSession(abortController);
|
||||
|
||||
if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) {
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotSupported);
|
||||
}
|
||||
@ -62,7 +64,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
|
||||
const isExcluded = await this.vaultContainsCredentials(params.excludeCredentialDescriptorList);
|
||||
if (isExcluded) {
|
||||
await this.userInterface.informExcludedCredential(
|
||||
await userInterfaceSession.informExcludedCredential(
|
||||
[Utils.guidToStandardFormat(params.excludeCredentialDescriptorList[0].id)],
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
@ -78,7 +80,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
let fido2Key: Fido2KeyView;
|
||||
let keyPair: CryptoKeyPair;
|
||||
if (params.requireResidentKey) {
|
||||
const userVerification = await this.userInterface.confirmNewCredential(
|
||||
const userVerification = await userInterfaceSession.confirmNewCredential(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
@ -104,7 +106,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown);
|
||||
}
|
||||
} else {
|
||||
const cipherId = await this.userInterface.confirmNewNonDiscoverableCredential(
|
||||
const cipherId = await userInterfaceSession.confirmNewNonDiscoverableCredential(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
@ -157,8 +159,11 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
}
|
||||
|
||||
async getAssertion(
|
||||
params: Fido2AuthenticatorGetAssertionParams
|
||||
params: Fido2AuthenticatorGetAssertionParams,
|
||||
abortController?: AbortController
|
||||
): Promise<Fido2AuthenticatorGetAssertionResult> {
|
||||
const userInterfaceSession = await this.userInterface.newSession(abortController);
|
||||
|
||||
if (
|
||||
params.requireUserVerification != undefined &&
|
||||
typeof params.requireUserVerification !== "boolean"
|
||||
@ -186,7 +191,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
|
||||
const selectedCipherId = await this.userInterface.pickCredential(
|
||||
const selectedCipherId = await userInterfaceSession.pickCredential(
|
||||
cipherOptions.map((cipher) => cipher.id)
|
||||
);
|
||||
const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId);
|
||||
|
@ -124,9 +124,11 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||
}
|
||||
throw new DOMException(undefined, "NotAllowedError");
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new DOMException(undefined, "AbortError");
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),
|
||||
|
Loading…
Reference in New Issue
Block a user