1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 11:43:51 +00:00

introduce DefaultSendAccessService.authenticate$

This commit is contained in:
✨ Audrey ✨
2025-07-21 18:32:29 -04:00
parent d8ddf13e23
commit dfef036cb6
6 changed files with 402 additions and 4 deletions

View File

@@ -3,7 +3,9 @@ import { Router, UrlTree } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, NEVER } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { mockAccountServiceWith, FakeStateProvider } from "@bitwarden/common/spec";
import { SemanticLogger } from "@bitwarden/common/tools/log";
@@ -17,6 +19,7 @@ import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory";
describe("DefaultSendAccessService", () => {
let service: DefaultSendAccessService;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let stateProvider: FakeStateProvider;
let sendApiService: MockProxy<SendApiService>;
let router: MockProxy<Router>;
@@ -24,6 +27,9 @@ describe("DefaultSendAccessService", () => {
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
jest.resetAllMocks();
cryptoFunctionService = mock<CryptoFunctionService>();
const accountService = mockAccountServiceWith("user-id" as UserId);
stateProvider = new FakeStateProvider(accountService);
sendApiService = mock<SendApiService>();
@@ -36,6 +42,7 @@ describe("DefaultSendAccessService", () => {
TestBed.configureTestingModule({
providers: [
DefaultSendAccessService,
{ provide: CryptoFunctionService, useValue: cryptoFunctionService },
{ provide: StateProvider, useValue: stateProvider },
{ provide: SendApiService, useValue: sendApiService },
{ provide: Router, useValue: router },
@@ -127,7 +134,6 @@ describe("DefaultSendAccessService", () => {
});
it("emits timeout error when API response exceeds 10 seconds", fakeAsync(() => {
// Mock API to never resolve (simulating a hung request)
sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER));
const result$ = service.redirect$(sendId);
@@ -159,7 +165,6 @@ describe("DefaultSendAccessService", () => {
describe("clear", () => {
it("sets both SEND_RESPONSE_KEY and SEND_CONTEXT_KEY to null when called", async () => {
// Set initial values
await stateProvider.getGlobal(SEND_RESPONSE_KEY).update(() => ({ some: "response" }) as any);
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: "test", key: "test" }));
@@ -172,4 +177,179 @@ describe("DefaultSendAccessService", () => {
expect(context).toBeNull();
});
});
describe("authenticate$", () => {
const sendId = "test-send-id";
const password = "test-password";
it("returns true and stores response when authentication succeeds", async () => {
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
const expectedPassword = Utils.fromBufferToB64(hashedArray);
const mockResponse = { id: sendId, data: "some data" } as any;
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
sendApiService.postSendAccess.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.authenticate$(sendId, password));
expect(result).toBe(true);
expect(sendApiService.postSendAccess).toHaveBeenCalledWith(
sendId,
expect.objectContaining({ password: expectedPassword }),
);
// Verify response is stored in state
const storedResponse = await firstValueFrom(
stateProvider.getGlobal(SEND_RESPONSE_KEY).state$,
);
expect(storedResponse).toBe(mockResponse);
});
it("returns false and logs debug when authentication fails with 401", async () => {
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
const errorResponse = new ErrorResponse([], 401);
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
const result = await firstValueFrom(service.authenticate$(sendId, password));
expect(result).toBe(false);
expect(logger.debug).toHaveBeenCalledWith("received failed authentication response");
// Verify state is not updated
const storedResponse = await firstValueFrom(
stateProvider.getGlobal(SEND_RESPONSE_KEY).state$,
);
expect(storedResponse).toBeNull();
});
it("panics when sendId mismatch occurs", fakeAsync(async () => {
const wrongSendId = "wrong-send-id";
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: wrongSendId, key }));
logger.panicWhen.mockImplementation((condition, data, message) => {
if (condition) {
throw new Error(`Panic: ${message}`);
}
return true;
});
const result$ = service.authenticate$(sendId, password);
let error: any;
result$.subscribe({
error: (err: unknown) => (error = err),
});
tick();
expect(error).toBeDefined();
expect(error.message).toBe("Panic: unexpected sendId");
expect(logger.panicWhen).toHaveBeenCalledWith(
true, // condition: sendId !== id (test-send-id !== wrong-send-id)
{ expected: sendId, actual: sendId },
"unexpected sendId",
);
}));
it("emits timeout error when context is null", fakeAsync(async () => {
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => null);
const result$ = service.authenticate$(sendId, password);
let error: any;
result$.subscribe({
error: (err: unknown) => (error = err),
});
// Advance time past 10 seconds
tick(10001);
expect(error).toBeDefined();
expect(error.name).toBe("TimeoutError");
}));
it("emits timeout error when context is invalid", fakeAsync(async () => {
// Set context missing required properties
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ missingId: "test" }) as any);
const result$ = service.authenticate$(sendId, password);
let error: any;
result$.subscribe({
error: (err: unknown) => (error = err),
});
// Advance time past 10 seconds
tick(10001);
expect(error).toBeDefined();
expect(error.name).toBe("TimeoutError");
}));
it("throws error and logs when server error occurs", async () => {
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
const errorResponse = new ErrorResponse([], 500);
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
// Mock crypto and API
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
await expect(firstValueFrom(service.authenticate$(sendId, password))).rejects.toBe(
errorResponse,
);
expect(logger.error).toHaveBeenCalledWith("an error occurred during authentication");
});
it("throws error and logs when network error occurs", async () => {
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
const networkError = new Error("Network error");
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
sendApiService.postSendAccess.mockRejectedValue(networkError);
await expect(firstValueFrom(service.authenticate$(sendId, password))).rejects.toThrow(
"Network error",
);
expect(logger.error).toHaveBeenCalledWith("an error occurred during authentication");
});
it("emits timeout error when API response exceeds 10 seconds", fakeAsync(async () => {
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
// Mock API to never resolve (simulating a hung request)
sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER));
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
const result$ = service.authenticate$(sendId, password);
let error: any;
result$.subscribe({
error: (err: unknown) => (error = err),
});
// Advance time past 10 seconds
tick(10001);
expect(error).toBeDefined();
expect(error.name).toBe("TimeoutError");
}));
});
});

View File

@@ -1,7 +1,8 @@
import { Injectable, Inject } from "@angular/core";
import { Router, UrlTree } from "@angular/router";
import { map, of, from, catchError, timeout } from "rxjs";
import { map, of, from, catchError, timeout, concatMap, filter, tap } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SemanticLogger } from "@bitwarden/common/tools/log";
@@ -10,7 +11,8 @@ import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/s
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory";
import { keyToSendAccessRequest } from "./rx";
import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY, isSendContext } from "./send-access-memory";
import { SendAccessService } from "./send-access-service.abstraction";
import { isErrorResponse } from "./util";
@@ -21,6 +23,7 @@ export class DefaultSendAccessService implements SendAccessService {
private readonly logger: SemanticLogger;
constructor(
private readonly crypto: CryptoFunctionService,
private readonly state: StateProvider,
private readonly api: SendApiService,
private readonly router: Router,
@@ -60,6 +63,40 @@ export class DefaultSendAccessService implements SendAccessService {
return redirect$;
}
authenticate$(sendId: string, password: string) {
const authenticate$ = this.state.getGlobal(SEND_CONTEXT_KEY).state$.pipe(
filter(isSendContext),
tap(({ id }) =>
this.logger.panicWhen(
sendId !== id,
{ expected: sendId, actual: sendId },
"unexpected sendId",
),
),
map(({ key }) => key),
keyToSendAccessRequest(this.crypto, password),
concatMap(async (request) => {
const result = await this.api.postSendAccess(sendId, request);
await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => result);
return true;
}),
catchError((error: unknown) => {
if (isErrorResponse(error) && error.statusCode === 401) {
this.logger.debug("received failed authentication response");
return of(false);
}
this.logger.error("an error occurred during authentication");
throw error;
}),
timeout({ first: TEN_SECONDS }),
);
return authenticate$;
}
private toViewRedirect(sendId: string) {
return this.router.createUrlTree(["send", "content", sendId]);
}

View File

@@ -0,0 +1,126 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom, Subject, toArray } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
import { awaitAsync } from "../../../../../../../libs/common/spec";
import { keyToSendAccessRequest } from "./rx";
describe("keyToSendAccessRequest", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
beforeEach(() => {
jest.resetAllMocks();
});
it("calls pbkdf2 with exact parameters when given a base64 key", async () => {
const password = "test-password";
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
const toRequest = keyToSendAccessRequest(cryptoFunctionService, password);
await firstValueFrom(of(key).pipe(toRequest));
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
password,
keyArray,
"sha256",
SEND_KDF_ITERATIONS,
);
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledTimes(1);
});
it("returns SendAccessRequest with base64 encoded password when pbkdf2 succeeds", async () => {
const password = "test-password";
const keyArray = new Uint8Array([1, 2, 3, 4, 5]);
const key = Utils.fromBufferToB64(keyArray);
const hashedArray = new Uint8Array([10, 20, 30, 40]);
const expectedPassword = Utils.fromBufferToB64(hashedArray);
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
const hasher = keyToSendAccessRequest(cryptoFunctionService, password);
const result = await firstValueFrom(of(key).pipe(hasher));
expect(result.password).toBe(expectedPassword);
});
it("propagates error when pbkdf2 fails", async () => {
const password = "test-password";
const key = Utils.fromBufferToB64(new Uint8Array([1, 2, 3, 4, 5]));
const expectedError = new Error("pbkdf2 failed");
cryptoFunctionService.pbkdf2.mockRejectedValue(expectedError);
const hasher = keyToSendAccessRequest(cryptoFunctionService, password);
await expect(firstValueFrom(of(key).pipe(hasher))).rejects.toThrow("pbkdf2 failed");
});
it("completes when source observable completes", (done) => {
const password = "test-password";
const source$ = new Subject<string>();
const hashedArray = new Uint8Array([10, 20, 30, 40]);
cryptoFunctionService.pbkdf2.mockResolvedValue(hashedArray);
const hasher = keyToSendAccessRequest(cryptoFunctionService, password);
source$.pipe(hasher).subscribe({
complete: () => {
done();
},
});
// if `hasher` fails to pass through complete, `done()` won't be called
// and the test will time out
source$.complete();
});
it("handles multiple emissions when source emits multiple keys", async () => {
const password = "test-password";
const keyArray1 = new Uint8Array([1, 2, 3, 4, 5]);
const keyArray2 = new Uint8Array([6, 7, 8, 9, 10]);
const key1 = Utils.fromBufferToB64(keyArray1);
const key2 = Utils.fromBufferToB64(keyArray2);
const hashedArray1 = new Uint8Array([10, 20, 30, 40]);
const hashedArray2 = new Uint8Array([50, 60, 70, 80]);
const expectedPassword1 = Utils.fromBufferToB64(hashedArray1);
const expectedPassword2 = Utils.fromBufferToB64(hashedArray2);
cryptoFunctionService.pbkdf2
.mockResolvedValueOnce(hashedArray1)
.mockResolvedValueOnce(hashedArray2);
const hasher = keyToSendAccessRequest(cryptoFunctionService, password);
let results: SendAccessRequest[] = [];
of(key1, key2)
.pipe(hasher, toArray())
.subscribe((r) => (results = r));
await awaitAsync();
expect(results).toHaveLength(2);
expect(results[0].password).toBe(expectedPassword1);
expect(results[1].password).toBe(expectedPassword2);
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledTimes(2);
expect(cryptoFunctionService.pbkdf2).toHaveBeenNthCalledWith(
1,
password,
keyArray1,
"sha256",
SEND_KDF_ITERATIONS,
);
expect(cryptoFunctionService.pbkdf2).toHaveBeenNthCalledWith(
2,
password,
keyArray2,
"sha256",
SEND_KDF_ITERATIONS,
);
});
});

View File

@@ -0,0 +1,22 @@
import { map, concatMap, pipe, OperatorFunction } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
export function keyToSendAccessRequest(
crypto: CryptoFunctionService,
password: string,
): OperatorFunction<string, SendAccessRequest> {
return pipe(
map((key) => Utils.fromUrlB64ToArray(key)),
// FIXME: support kdf iteration and/or hash updates
concatMap((key) => crypto.pbkdf2(password, key, "sha256", SEND_KDF_ITERATIONS)),
map((hash) => {
const request = new SendAccessRequest();
request.password = Utils.fromBufferToB64(hash);
return request;
}),
);
}

View File

@@ -48,6 +48,19 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
throw new Error(panicMessage);
}
panicWhen<T>(
when: boolean,
content: Jsonify<T> | string,
message?: string,
): when is false | never {
if (when) {
// type conversion safe because Jsonify<string> === string
this.panic(content as Jsonify<T | string>, message);
}
return !when;
}
private log<T>(content: Jsonify<T>, level: LogLevelType, message?: string) {
const log = {
...this.context,

View File

@@ -92,4 +92,24 @@ export interface SemanticLogger {
/** combined signature for overloaded methods */
panic<T>(content: Jsonify<T> | string, message?: string): never;
/** Conditionally panics.
* @param when - when this condition is true, the logger panics.
* @param message - a message to record in the log's `message` field.
*/
panicWhen(when: boolean, message: string): when is false | never;
/** Conditionally panics.
* @param when - when this condition is true, the logger panics.
* @param content - JSON content included in the log's `content` field.
* @param message - a message to record in the log's `message` field.
*/
panicWhen<T>(when: boolean, content: Jsonify<T>, message?: string): when is false | never;
/** combined signature for overloaded methods */
panicWhen<T>(
when: boolean,
content: Jsonify<T> | string,
message?: string,
): when is false | never;
}