1
0
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:
Andreas Coroiu 2023-04-05 11:38:32 +02:00
parent 55cd736ec3
commit 11d340bc97
No known key found for this signature in database
GPG Key ID: E70B5FFC81DFEC1A
5 changed files with 146 additions and 33 deletions

View File

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

View File

@ -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: (

View File

@ -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 */

View File

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

View File

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