1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-23532] introduce send access guard (#15529)

* document expected auth flows
* implement send access trampoline
This commit is contained in:
✨ Audrey ✨
2025-08-08 14:28:03 -04:00
committed by GitHub
parent a75f9dc9f0
commit 6ab67e9a8f
16 changed files with 1062 additions and 2 deletions

View File

@@ -0,0 +1,75 @@
# Send Authentication Flows
In the below diagrams, activations represent client control flow.
## Public Sends
Anyone can access a public send. The token endpoint automatically issues a token. It never issues a challenge.
```mermaid
sequenceDiagram
participant Visitor
participant TryAccess as try-send-access.guard
participant SendToken as send-token API
participant ViewContent as view-content.component
participant SendAccess as send-access API
Visitor->>TryAccess: Navigate to send URL
activate TryAccess
TryAccess->>SendToken: Request anonymous access token
SendToken-->>TryAccess: OK + Security token
TryAccess->>ViewContent: Redirect with token
deactivate TryAccess
activate ViewContent
ViewContent->>SendAccess: Request send content (with token and key)
SendAccess-->>ViewContent: Return send content
ViewContent->>Visitor: Display send content
deactivate ViewContent
```
## Password Protected Sends
Password protected sends redirect to a password challenge prompt.
```mermaid
sequenceDiagram
participant Visitor
participant TryAccess as try-send-access.guard
participant PasswordAuth as password-authentication.component
participant SendToken as send-token API
participant ViewContent as view-content.component
participant SendAccess as send-access API
Visitor->>TryAccess: Navigate to send URL
activate TryAccess
TryAccess->>SendToken: Request anonymous access token
SendToken-->>TryAccess: Unauthorized + Password challenge
TryAccess->>PasswordAuth: Redirect with send ID and key
deactivate TryAccess
activate PasswordAuth
PasswordAuth->>Visitor: Request password
Visitor-->>PasswordAuth: Enter password
PasswordAuth->>SendToken: Request access token (with password)
SendToken-->>PasswordAuth: OK + Security token
deactivate PasswordAuth
activate ViewContent
PasswordAuth->>ViewContent: Redirect with token and send key
ViewContent->>SendAccess: Request send content (with token)
SendAccess-->>ViewContent: Return send content
ViewContent->>Visitor: Display send content
deactivate ViewContent
```
## Send Access without token
Visiting the view page without a token redirects to a try-access flow, above.
```mermaid
sequenceDiagram
participant Visitor
participant ViewContent as view-content.component
participant TryAccess as try-send-access.guard
Visitor->>ViewContent: Navigate to send URL (with id and key)
ViewContent->>TryAccess: Redirect to try-access (with id and key)
```

View File

@@ -0,0 +1,175 @@
import { TestBed, fakeAsync, tick } from "@angular/core/testing";
import { Router, UrlTree } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, NEVER } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { StateProvider } from "@bitwarden/common/platform/state";
import { mockAccountServiceWith, FakeStateProvider } from "@bitwarden/common/spec";
import { SemanticLogger } from "@bitwarden/common/tools/log";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
import { DefaultSendAccessService } from "./default-send-access-service";
import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory";
describe("DefaultSendAccessService", () => {
let service: DefaultSendAccessService;
let stateProvider: FakeStateProvider;
let sendApiService: MockProxy<SendApiService>;
let router: MockProxy<Router>;
let logger: MockProxy<SemanticLogger>;
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
const accountService = mockAccountServiceWith("user-id" as UserId);
stateProvider = new FakeStateProvider(accountService);
sendApiService = mock<SendApiService>();
router = mock<Router>();
logger = mock<SemanticLogger>();
systemServiceProvider = mock<SystemServiceProvider>();
systemServiceProvider.log.mockReturnValue(logger);
TestBed.configureTestingModule({
providers: [
DefaultSendAccessService,
{ provide: StateProvider, useValue: stateProvider },
{ provide: SendApiService, useValue: sendApiService },
{ provide: Router, useValue: router },
{ provide: SYSTEM_SERVICE_PROVIDER, useValue: systemServiceProvider },
],
});
service = TestBed.inject(DefaultSendAccessService);
});
describe("constructor", () => {
it("creates logger with type 'SendAccessAuthenticationService' when initialized", () => {
expect(systemServiceProvider.log).toHaveBeenCalledWith({
type: "SendAccessAuthenticationService",
});
});
});
describe("redirect$", () => {
const sendId = "test-send-id";
it("returns content page UrlTree and logs info when API returns success", async () => {
const expectedUrlTree = { toString: () => "/send/content/test-send-id" } as UrlTree;
sendApiService.postSendAccess.mockResolvedValue({} as any);
router.createUrlTree.mockReturnValue(expectedUrlTree);
const result = await firstValueFrom(service.redirect$(sendId));
expect(result).toBe(expectedUrlTree);
expect(logger.info).toHaveBeenCalledWith(
"public send detected; redirecting to send access with token.",
);
});
describe("given error responses", () => {
it("returns password flow UrlTree and logs debug when 401 received", async () => {
const expectedUrlTree = { toString: () => "/send/test-send-id" } as UrlTree;
const errorResponse = new ErrorResponse([], 401);
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
router.createUrlTree.mockReturnValue(expectedUrlTree);
const result = await firstValueFrom(service.redirect$(sendId));
expect(result).toBe(expectedUrlTree);
expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to password flow");
});
it("returns 404 page UrlTree and logs debug when 404 received", async () => {
const expectedUrlTree = { toString: () => "/404.html" } as UrlTree;
const errorResponse = new ErrorResponse([], 404);
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
router.parseUrl.mockReturnValue(expectedUrlTree);
const result = await firstValueFrom(service.redirect$(sendId));
expect(result).toBe(expectedUrlTree);
expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to unavailable page");
});
it("logs warning and throws error when 500 received", async () => {
const errorResponse = new ErrorResponse([], 500);
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse);
expect(logger.warn).toHaveBeenCalledWith(
errorResponse,
"received unexpected error response",
);
});
it("throws error when unexpected error code received", async () => {
const errorResponse = new ErrorResponse([], 403);
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse);
expect(logger.warn).toHaveBeenCalledWith(
errorResponse,
"received unexpected error response",
);
});
});
it("throws error when non-ErrorResponse error occurs", async () => {
const regularError = new Error("Network error");
sendApiService.postSendAccess.mockRejectedValue(regularError);
await expect(firstValueFrom(service.redirect$(sendId))).rejects.toThrow("Network error");
expect(logger.warn).not.toHaveBeenCalled();
});
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);
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");
}));
});
describe("setContext", () => {
it("updates global state with send context when called with sendId and key", async () => {
const sendId = "test-send-id";
const key = "test-key";
await service.setContext(sendId, key);
const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$);
expect(context).toEqual({ id: sendId, key });
});
});
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" }));
await service.clear();
const response = await firstValueFrom(stateProvider.getGlobal(SEND_RESPONSE_KEY).state$);
const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$);
expect(response).toBeNull();
expect(context).toBeNull();
});
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable, Inject } from "@angular/core";
import { Router, UrlTree } from "@angular/router";
import { map, of, from, catchError, timeout } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SemanticLogger } from "@bitwarden/common/tools/log";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
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 { SendAccessService } from "./send-access-service.abstraction";
import { isErrorResponse } from "./util";
const TEN_SECONDS = 10_000;
@Injectable({ providedIn: "root" })
export class DefaultSendAccessService implements SendAccessService {
private readonly logger: SemanticLogger;
constructor(
private readonly state: StateProvider,
private readonly api: SendApiService,
private readonly router: Router,
@Inject(SYSTEM_SERVICE_PROVIDER) system: SystemServiceProvider,
) {
this.logger = system.log({ type: "SendAccessAuthenticationService" });
}
redirect$(sendId: string) {
// FIXME: when the send authentication APIs become available, this method
// should delegate to the API
const response$ = from(this.api.postSendAccess(sendId, new SendAccessRequest()));
const redirect$ = response$.pipe(
timeout({ first: TEN_SECONDS }),
map((_response) => {
this.logger.info("public send detected; redirecting to send access with token.");
const url = this.toViewRedirect(sendId);
return url;
}),
catchError((error: unknown) => {
let processed: UrlTree | undefined = undefined;
if (isErrorResponse(error)) {
processed = this.toErrorRedirect(sendId, error);
}
if (processed) {
return of(processed);
}
throw error;
}),
);
return redirect$;
}
private toViewRedirect(sendId: string) {
return this.router.createUrlTree(["send", "content", sendId]);
}
private toErrorRedirect(sendId: string, response: ErrorResponse) {
let url: UrlTree | undefined = undefined;
switch (response.statusCode) {
case 401:
this.logger.debug(response, "redirecting to password flow");
url = this.router.createUrlTree(["send/password", sendId]);
break;
case 404:
this.logger.debug(response, "redirecting to unavailable page");
url = this.router.parseUrl("/404.html");
break;
default:
this.logger.warn(response, "received unexpected error response");
}
return url;
}
async setContext(sendId: string, key: string) {
await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
}
async clear(): Promise<void> {
await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => null);
await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => null);
}
}

View File

@@ -1,2 +1,4 @@
export { AccessComponent } from "./access.component";
export { SendAccessExplainerComponent } from "./send-access-explainer.component";
export { SendAccessRoutes } from "./routes";

View File

@@ -0,0 +1,60 @@
import { Routes } from "@angular/router";
import { AnonLayoutWrapperData } from "@bitwarden/components";
import { ActiveSendIcon } from "@bitwarden/send-ui";
import { RouteDataProperties } from "../../../core";
import { SendAccessExplainerComponent } from "./send-access-explainer.component";
import { SendAccessPasswordComponent } from "./send-access-password.component";
import { trySendAccess } from "./try-send-access.guard";
/** Routes to reach send access screens */
export const SendAccessRoutes: Routes = [
{
path: "send/:sendId",
// there are no child pages because `trySendAccess` always performs a redirect
canActivate: [trySendAccess],
},
{
path: "send/password/:sendId",
data: {
pageTitle: {
key: "sendAccessPasswordTitle",
},
pageIcon: ActiveSendIcon,
showReadonlyHostname: true,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: SendAccessPasswordComponent,
},
{
path: "",
outlet: "secondary",
component: SendAccessExplainerComponent,
},
],
},
{
path: "send/content/:sendId",
data: {
pageTitle: {
key: "sendAccessContentTitle",
},
pageIcon: ActiveSendIcon,
showReadonlyHostname: true,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "send/password/:sendId",
},
{
path: "",
outlet: "secondary",
component: SendAccessExplainerComponent,
},
],
},
];

View File

@@ -0,0 +1,50 @@
import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SEND_CONTEXT_KEY, SEND_RESPONSE_KEY } from "./send-access-memory";
import { SendContext } from "./types";
describe("send-access-memory", () => {
describe("SEND_CONTEXT_KEY", () => {
it("has correct state definition properties", () => {
expect(SEND_CONTEXT_KEY).toBeInstanceOf(KeyDefinition);
expect(SEND_CONTEXT_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY);
expect(SEND_CONTEXT_KEY.key).toBe("sendContext");
});
it("deserializes data as-is", () => {
const testContext: SendContext = { id: "test-id", key: "test-key" };
const deserializer = SEND_CONTEXT_KEY.deserializer;
expect(deserializer(testContext)).toBe(testContext);
});
it("deserializes null as null", () => {
const deserializer = SEND_CONTEXT_KEY.deserializer;
expect(deserializer(null)).toBe(null);
});
});
describe("SEND_RESPONSE_KEY", () => {
it("has correct state definition properties", () => {
expect(SEND_RESPONSE_KEY).toBeInstanceOf(KeyDefinition);
expect(SEND_RESPONSE_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY);
expect(SEND_RESPONSE_KEY.key).toBe("sendResponse");
});
it("deserializes data into SendAccessResponse instance", () => {
const mockData = { id: "test-id", name: "test-send" } as any;
const deserializer = SEND_RESPONSE_KEY.deserializer;
const result = deserializer(mockData);
expect(result).toBeInstanceOf(SendAccessResponse);
});
it.each([
[null, "null"],
[undefined, "undefined"],
])("deserializes %s as null", (value, _) => {
const deserializer = SEND_RESPONSE_KEY.deserializer;
expect(deserializer(value!)).toBe(null);
});
});
});

View File

@@ -0,0 +1,25 @@
import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state";
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
import { SendContext } from "./types";
export const SEND_CONTEXT_KEY = new KeyDefinition<SendContext | null>(
SEND_ACCESS_AUTH_MEMORY,
"sendContext",
{
deserializer: (data) => data,
},
);
/** When send authentication succeeds, this stores the result so that
* multiple access attempts don't accrue due to the send workflow.
*/
// FIXME: replace this with the send authentication token once it's
// available
export const SEND_RESPONSE_KEY = new KeyDefinition<SendAccessResponse | null>(
SEND_ACCESS_AUTH_MEMORY,
"sendResponse",
{
deserializer: (data) => (data ? new SendAccessResponse(data) : null),
},
);

View File

@@ -0,0 +1,10 @@
import { UrlTree } from "@angular/router";
import { Observable } from "rxjs";
export abstract class SendAccessService {
abstract redirect$: (sendId: string) => Observable<UrlTree>;
abstract setContext: (sendId: string, key: string) => Promise<void>;
abstract clear: () => Promise<void>;
}

View File

@@ -0,0 +1,426 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from "@angular/router";
import { firstValueFrom, Observable, of } from "rxjs";
import { SemanticLogger } from "@bitwarden/common/tools/log";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
import { SendAccessService } from "./send-access-service.abstraction";
import { trySendAccess } from "./try-send-access.guard";
function createMockRoute(params: Record<string, any>): ActivatedRouteSnapshot {
return { params } as ActivatedRouteSnapshot;
}
function createMockLogger(): SemanticLogger {
return {
warn: jest.fn(),
panic: jest.fn().mockImplementation(() => {
throw new Error("Logger panic called");
}),
} as any as SemanticLogger;
}
function createMockSystemServiceProvider(): SystemServiceProvider {
return {
log: jest.fn().mockReturnValue(createMockLogger()),
} as any as SystemServiceProvider;
}
function createMockSendAccessService() {
return {
setContext: jest.fn().mockResolvedValue(undefined),
redirect$: jest.fn().mockReturnValue(of({} as UrlTree)),
clear: jest.fn().mockResolvedValue(undefined),
};
}
describe("trySendAccess", () => {
let mockSendAccessService: ReturnType<typeof createMockSendAccessService>;
let mockSystemServiceProvider: SystemServiceProvider;
let mockRouterState: RouterStateSnapshot;
beforeEach(() => {
mockSendAccessService = createMockSendAccessService();
mockSystemServiceProvider = createMockSystemServiceProvider();
mockRouterState = {} as RouterStateSnapshot;
TestBed.configureTestingModule({
providers: [
{ provide: SendAccessService, useValue: mockSendAccessService },
{ provide: SYSTEM_SERVICE_PROVIDER, useValue: mockSystemServiceProvider },
],
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("canActivate", () => {
describe("given valid route parameters", () => {
it("extracts sendId and key from route params when both are valid strings", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
// need to cast the result because `CanActivateFn` performs type erasure
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key);
expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1);
await expect(firstValueFrom(result$)).resolves.toEqual(expectedUrlTree);
});
it("does not throw validation errors when sendId and key are valid strings", async () => {
const sendId = "valid-send-id";
const key = "valid-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
// Should not throw any errors during guard execution
let guardResult: Observable<UrlTree> | undefined;
expect(() => {
guardResult = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
}).not.toThrow();
// Verify the observable can be subscribed to without errors
expect(guardResult).toBeDefined();
await expect(firstValueFrom(guardResult!)).resolves.toEqual(expectedUrlTree);
// Logger methods should not be called for warnings or panics
const mockLogger = (mockSystemServiceProvider.log as jest.Mock).mock.results[0].value;
expect(mockLogger.warn).not.toHaveBeenCalled();
expect(mockLogger.panic).not.toHaveBeenCalled();
});
});
describe("given invalid route parameters", () => {
describe("given invalid sendId", () => {
it.each([
["undefined", undefined],
["null", null],
])(
"logs warning with correct message when sendId is %s",
async (description, sendIdValue) => {
const key = "valid-key";
const mockRoute = createMockRoute(
sendIdValue === undefined ? { key } : { sendId: sendIdValue, key },
);
const mockLogger = createMockLogger();
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
await expect(async () => {
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
}).rejects.toThrow("Logger panic called");
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({
function: "trySendAccess",
});
expect(mockLogger.warn).toHaveBeenCalledWith(
"sendId missing from the route parameters; redirecting to 404",
);
},
);
it.each([
["number", 123],
["object", {}],
["boolean", true],
])("logs panic with expected/actual type info when sendId is %s", async (type, value) => {
const key = "valid-key";
const mockRoute = createMockRoute({ sendId: value, key });
const mockLogger = createMockLogger();
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
await expect(async () => {
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
}).rejects.toThrow("Logger panic called");
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" });
expect(mockLogger.panic).toHaveBeenCalledWith(
{ expected: "string", actual: type },
"sendId has invalid type",
);
});
it("throws when sendId is not a string", async () => {
const key = "valid-key";
const invalidSendIdValues = [123, {}, true, null, undefined];
for (const invalidSendId of invalidSendIdValues) {
const mockRoute = createMockRoute(
invalidSendId === undefined ? { key } : { sendId: invalidSendId, key },
);
const mockLogger = createMockLogger();
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
await expect(async () => {
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
}).rejects.toThrow("Logger panic called");
}
});
});
describe("given invalid key", () => {
it.each([
["undefined", undefined],
["null", null],
])("logs panic with correct message when key is %s", async (description, keyValue) => {
const sendId = "valid-send-id";
const mockRoute = createMockRoute(
keyValue === undefined ? { sendId } : { sendId, key: keyValue },
);
const mockLogger = createMockLogger();
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
await expect(async () => {
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
}).rejects.toThrow("Logger panic called");
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" });
expect(mockLogger.panic).toHaveBeenCalledWith("key missing from the route parameters");
});
it.each([
["number", 123],
["object", {}],
["boolean", true],
])("logs panic with expected/actual type info when key is %s", async (type, value) => {
const sendId = "valid-send-id";
const mockRoute = createMockRoute({ sendId, key: value });
const mockLogger = createMockLogger();
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
await expect(async () => {
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
}).rejects.toThrow("Logger panic called");
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" });
expect(mockLogger.panic).toHaveBeenCalledWith(
{ expected: "string", actual: type },
"key has invalid type",
);
});
it("throws when key is not a string", async () => {
const sendId = "valid-send-id";
const invalidKeyValues = [123, {}, true, null, undefined];
for (const invalidKey of invalidKeyValues) {
const mockRoute = createMockRoute(
invalidKey === undefined ? { sendId } : { sendId, key: invalidKey },
);
const mockLogger = createMockLogger();
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
await expect(async () => {
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
}).rejects.toThrow("Logger panic called");
}
});
});
});
describe("given service interactions", () => {
it("calls setContext with extracted sendId and key when parameters are valid", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key);
expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1);
});
it("calls redirect$ with extracted sendId when setContext completes", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
expect(mockSendAccessService.redirect$).toHaveBeenCalledTimes(1);
});
});
describe("given observable behavior", () => {
it("returns redirect$ emissions when setContext completes successfully", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
const actualResult = await firstValueFrom(result$);
expect(actualResult).toEqual(expectedUrlTree);
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
});
it("does not emit setContext values when using ignoreElements", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
const setContextValue = "should-not-be-emitted";
// Mock setContext to return a value
mockSendAccessService.setContext.mockResolvedValue(setContextValue);
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
const actualResult = await firstValueFrom(result$);
// Should only emit the redirect$ value, not the setContext value
expect(actualResult).toEqual(expectedUrlTree);
expect(actualResult).not.toEqual(setContextValue);
});
it("ensures setContext completes before redirect$ executes (sequencing)", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
let setContextResolved = false;
// Mock setContext to track when it resolves
mockSendAccessService.setContext.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay
setContextResolved = true;
});
// Mock redirect$ to return a delayed observable and check if setContext resolved
mockSendAccessService.redirect$.mockImplementation((id) => {
return new Observable((subscriber) => {
// Check if setContext has resolved when redirect$ subscription starts
setTimeout(() => {
expect(setContextResolved).toBe(true);
subscriber.next(expectedUrlTree);
subscriber.complete();
}, 0);
});
});
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
await firstValueFrom(result$);
});
});
describe("given error scenarios", () => {
it("does not call redirect$ when setContext rejects", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const setContextError = new Error("setContext failed");
// Reset mocks to ensure clean state
jest.clearAllMocks();
// Mock setContext to reject
mockSendAccessService.setContext.mockRejectedValue(setContextError);
// Create a mock observable that we can spy on subscription
const mockRedirectObservable = of({} as UrlTree);
const subscribeSpy = jest.spyOn(mockRedirectObservable, "subscribe");
mockSendAccessService.redirect$.mockReturnValue(mockRedirectObservable);
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
// Expect the observable to reject when setContext fails
await expect(firstValueFrom(result$)).rejects.toThrow("setContext failed");
// The redirect$ method will be called (since it's called synchronously)
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
// But the returned observable should not be subscribed to due to the error
// Note: This test verifies the error propagation behavior
expect(subscribeSpy).not.toHaveBeenCalled();
});
it("propagates error to guard return value when redirect$ throws", async () => {
const sendId = "test-send-id";
const key = "test-key";
const mockRoute = createMockRoute({ sendId, key });
const redirectError = new Error("redirect$ failed");
// Reset mocks to ensure clean state
jest.clearAllMocks();
// Mock setContext to succeed and redirect$ to throw
mockSendAccessService.setContext.mockResolvedValue(undefined);
mockSendAccessService.redirect$.mockReturnValue(
new Observable((subscriber) => {
subscriber.error(redirectError);
}),
);
const result$ = TestBed.runInInjectionContext(() =>
trySendAccess(mockRoute, mockRouterState),
) as unknown as Observable<UrlTree>;
// Expect the observable to propagate the redirect$ error
await expect(firstValueFrom(result$)).rejects.toThrow("redirect$ failed");
// Verify that setContext was called (should succeed)
expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key);
// Verify that redirect$ was called (but it throws)
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
});
});
});
});

View File

@@ -0,0 +1,38 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
import { from, ignoreElements, concat } from "rxjs";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
import { SendAccessService } from "./send-access-service.abstraction";
export const trySendAccess: CanActivateFn = (
route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot,
) => {
const sendAccess = inject(SendAccessService);
const system = inject<SystemServiceProvider>(SYSTEM_SERVICE_PROVIDER);
const logger = system.log({ function: "trySendAccess" });
const { sendId, key } = route.params;
if (!sendId) {
logger.warn("sendId missing from the route parameters; redirecting to 404");
}
if (typeof sendId !== "string") {
logger.panic({ expected: "string", actual: typeof sendId }, "sendId has invalid type");
}
if (!key) {
logger.panic("key missing from the route parameters");
}
if (typeof key !== "string") {
logger.panic({ expected: "string", actual: typeof key }, "key has invalid type");
}
const contextUpdated$ = from(sendAccess.setContext(sendId, key)).pipe(ignoreElements());
const redirect$ = sendAccess.redirect$(sendId);
// ensure the key has loaded before redirecting
return concat(contextUpdated$, redirect$);
};

View File

@@ -0,0 +1,8 @@
/** global contextual information for the current send access page. */
export type SendContext = {
/** identifies the send */
id: string;
/** decrypts the send content */
key: string;
};

View File

@@ -0,0 +1,69 @@
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { isErrorResponse, isSendContext } from "./util";
describe("util", () => {
describe("isErrorResponse", () => {
it("returns true when value is an ErrorResponse instance", () => {
const error = new ErrorResponse(["Error message"], 400);
expect(isErrorResponse(error)).toBe(true);
});
it.each([
[null, "null"],
[undefined, "undefined"],
])("returns false when value is %s", (value, description) => {
expect(isErrorResponse(value)).toBe(false);
});
it.each([
["string", "string"],
[123, "number"],
[true, "boolean"],
[{}, "plain object"],
[[], "array"],
])("returns false when value is not an ErrorResponse (%s)", (value, description) => {
expect(isErrorResponse(value)).toBe(false);
});
it("returns false when value is a different Error type", () => {
const error = new Error("test");
expect(isErrorResponse(error)).toBe(false);
});
});
describe("isSendContext", () => {
it("returns true when value has id and key properties", () => {
const validContext = { id: "test-id", key: "test-key" };
expect(isSendContext(validContext)).toBe(true);
});
it("returns true even with additional properties", () => {
const contextWithExtras = { id: "test-id", key: "test-key", extra: "data" };
expect(isSendContext(contextWithExtras)).toBe(true);
});
it.each([
[null, "null"],
[undefined, "undefined"],
])("returns false when value is %s", (value, _) => {
expect(isSendContext(value)).toBe(false);
});
it.each([
["string", "string"],
[123, "number"],
[true, "boolean"],
])("returns false when value is not an object (%s)", (value, _) => {
expect(isSendContext(value)).toBe(false);
});
it.each([
[{ key: "test-key" }, "missing id"],
[{ id: "test-id" }, "missing key"],
[{}, "empty object"],
])("returns false when value is %s", (value, _) => {
expect(isSendContext(value)).toBe(false);
});
});
});

View File

@@ -0,0 +1,13 @@
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { SendContext } from "./types";
/** narrows a type to an `ErrorResponse` */
export function isErrorResponse(value: unknown): value is ErrorResponse {
return value instanceof ErrorResponse;
}
/** narrows a type to a `SendContext` */
export function isSendContext(value: unknown): value is SendContext {
return !!value && typeof value === "object" && "id" in value && "key" in value;
}

View File

@@ -5269,6 +5269,14 @@
"downloadAttachments": {
"message": "Download attachments"
},
"sendAccessPasswordTitle": {
"message": "Enter the password to view this Send",
"description": "Title of the Send password authentication screen."
},
"sendAccessContentTitle": {
"message": "View Send",
"description": "Title of the Send view content screen."
},
"sendAccessUnavailable": {
"message": "The Send you are trying to access does not exist or is no longer available.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."

View File

@@ -43,7 +43,12 @@ export const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken<providers.CredentialGeneratorProviders>(
"CredentialGeneratorProviders",
);
const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>("SystemServices");
// FIXME: relocate the system service provider to a more general module once
// NX migration is complete.
export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>(
"SystemServices",
);
/** Shared module containing generator component dependencies */
@NgModule({

View File

@@ -2,4 +2,4 @@ export { CredentialGeneratorHistoryComponent } from "./credential-generator-hist
export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { GeneratorModule } from "./generator.module";
export { GeneratorServicesModule } from "./generator-services.module";
export { GeneratorServicesModule, SYSTEM_SERVICE_PROVIDER } from "./generator-services.module";