diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3304c54a86f..df135dcc0ef 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -104,6 +104,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@ import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction"; import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; +import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access"; import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; @@ -1588,6 +1589,11 @@ const safeProviders: SafeProvider[] = [ MessageListener, ], }), + safeProvider({ + provide: SendTokenService, + useClass: DefaultSendTokenService, + deps: [GlobalStateProvider, SdkService, SendPasswordService], + }), safeProvider({ provide: EndUserNotificationService, useClass: DefaultEndUserNotificationService, diff --git a/libs/common/src/auth/send-access/abstractions/index.ts b/libs/common/src/auth/send-access/abstractions/index.ts new file mode 100644 index 00000000000..be8d6282020 --- /dev/null +++ b/libs/common/src/auth/send-access/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./send-token.service"; diff --git a/libs/common/src/auth/send-access/abstractions/send-token.service.ts b/libs/common/src/auth/send-access/abstractions/send-token.service.ts new file mode 100644 index 00000000000..3ecdc101892 --- /dev/null +++ b/libs/common/src/auth/send-access/abstractions/send-token.service.ts @@ -0,0 +1,57 @@ +import { Observable } from "rxjs"; + +import { SendAccessToken } from "../models/send-access-token"; +import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type"; +import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type"; +import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type"; +import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type"; + +/** + * Service to manage send access tokens. + */ +export abstract class SendTokenService { + /** + * Attempts to retrieve a {@link SendAccessToken} for the given sendId. + * If the access token is found in session storage and is not expired, then it returns the token. + * If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error. + * If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view). + * If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it. + * If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required. + * Any submissions of credentials will be handled by the getSendAccessToken$ method. + * @param sendId The ID of the send to retrieve the access token for. + * @returns An observable that emits a SendAccessToken if successful, or a TryGetSendAccessTokenError if not. + */ + abstract tryGetSendAccessToken$: ( + sendId: string, + ) => Observable; + + /** + * Retrieves a SendAccessToken for the given sendId using the provided credentials. + * If the access token is successfully retrieved from the server, it stores the token in session storage and returns it. + * If the access token cannot be granted due to invalid credentials, it returns a {@link GetSendAccessTokenError}. + * @param sendId The ID of the send to retrieve the access token for. + * @param sendAccessCredentials The credentials to use for accessing the send. + * @returns An observable that emits a SendAccessToken if successful, or a GetSendAccessTokenError if not. + */ + abstract getSendAccessToken$: ( + sendId: string, + sendAccessCredentials: SendAccessDomainCredentials, + ) => Observable; + + /** + * Hashes a password for send access which is required to create a {@link SendAccessTokenRequest} + * (more specifically, to create a {@link SendAccessDomainCredentials} for sends that require a password) + * @param password The raw password string to hash. + * @param keyMaterialUrlB64 The base64 URL encoded key material string. + * @returns A promise that resolves to the hashed password as a SendHashedPasswordB64. + */ + abstract hashSendPassword: ( + password: string, + keyMaterialUrlB64: string, + ) => Promise; + + /** + * Clears a send access token from storage. + */ + abstract invalidateSendAccessToken: (sendId: string) => Promise; +} diff --git a/libs/common/src/auth/send-access/index.ts b/libs/common/src/auth/send-access/index.ts new file mode 100644 index 00000000000..38352451e49 --- /dev/null +++ b/libs/common/src/auth/send-access/index.ts @@ -0,0 +1,4 @@ +export * from "./abstractions"; +export * from "./models"; +export * from "./services"; +export * from "./types"; diff --git a/libs/common/src/auth/send-access/models/index.ts b/libs/common/src/auth/send-access/models/index.ts new file mode 100644 index 00000000000..9748d794715 --- /dev/null +++ b/libs/common/src/auth/send-access/models/index.ts @@ -0,0 +1 @@ +export * from "./send-access-token"; diff --git a/libs/common/src/auth/send-access/models/send-access-token.spec.ts b/libs/common/src/auth/send-access/models/send-access-token.spec.ts new file mode 100644 index 00000000000..6bacac7eb6d --- /dev/null +++ b/libs/common/src/auth/send-access/models/send-access-token.spec.ts @@ -0,0 +1,75 @@ +import { SendAccessTokenResponse } from "@bitwarden/sdk-internal"; + +import { SendAccessToken } from "./send-access-token"; + +describe("SendAccessToken", () => { + const sendId = "sendId"; + + const NOW = 1_000_000; // fixed timestamp for predictable results + + const expiresAt: number = NOW + 1000 * 60 * 5; // 5 minutes from now + + const expiredExpiresAt: number = NOW - 1000 * 60 * 5; // 5 minutes ago + + let nowSpy: jest.SpyInstance; + + beforeAll(() => { + nowSpy = jest.spyOn(Date, "now"); + }); + + beforeEach(() => { + // Ensure every test starts from the same fixed time + nowSpy.mockReturnValue(NOW); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("should create a valid, unexpired token", () => { + const token = new SendAccessToken(sendId, expiresAt); + expect(token).toBeTruthy(); + expect(token.isExpired()).toBe(false); + }); + + it("should be expired after the expiration time", () => { + const token = new SendAccessToken(sendId, expiredExpiresAt); + expect(token.isExpired()).toBe(true); + }); + + it("should be considered expired if within 5 seconds of expiration", () => { + const token = new SendAccessToken(sendId, expiresAt); + nowSpy.mockReturnValue(expiresAt - 4_000); // 4 seconds before expiry + expect(token.isExpired()).toBe(true); + }); + + it("should return the correct time until expiry in seconds", () => { + const token = new SendAccessToken(sendId, expiresAt); + expect(token.timeUntilExpirySeconds()).toBe(300); // 5 minutes + }); + + it("should return 0 if the token is expired", () => { + const token = new SendAccessToken(sendId, expiredExpiresAt); + expect(token.timeUntilExpirySeconds()).toBe(0); + }); + + it("should create a token from JSON", () => { + const json = { + token: sendId, + expiresAt: expiresAt, + }; + const token = SendAccessToken.fromJson(json); + expect(token).toBeTruthy(); + expect(token.isExpired()).toBe(false); + }); + + it("should create a token from SendAccessTokenResponse", () => { + const response = { + token: sendId, + expiresAt: expiresAt, + } as SendAccessTokenResponse; + const token = SendAccessToken.fromSendAccessTokenResponse(response); + expect(token).toBeTruthy(); + expect(token.isExpired()).toBe(false); + }); +}); diff --git a/libs/common/src/auth/send-access/models/send-access-token.ts b/libs/common/src/auth/send-access/models/send-access-token.ts new file mode 100644 index 00000000000..e237243159d --- /dev/null +++ b/libs/common/src/auth/send-access/models/send-access-token.ts @@ -0,0 +1,46 @@ +import { Jsonify } from "type-fest"; + +import { SendAccessTokenResponse } from "@bitwarden/sdk-internal"; + +export class SendAccessToken { + constructor( + /** + * The access token string + */ + readonly token: string, + /** + * The time (in milliseconds since the epoch) when the token expires + */ + readonly expiresAt: number, + ) {} + + /** Returns whether the send access token is expired or not + * Has a 5 second threshold to avoid race conditions with the token + * expiring in flight + */ + isExpired(threshold: number = 5_000): boolean { + return Date.now() >= this.expiresAt - threshold; + } + + /** Returns how many full seconds remain until expiry. Returns 0 if expired. */ + timeUntilExpirySeconds(): number { + return Math.max(0, Math.floor((this.expiresAt - Date.now()) / 1_000)); + } + + static fromJson(parsedJson: Jsonify): SendAccessToken { + return new SendAccessToken(parsedJson.token, parsedJson.expiresAt); + } + + /** + * Creates a SendAccessToken from a SendAccessTokenResponse. + * @param sendAccessTokenResponse The SDK response object containing the token and expiry information. + * @returns A new instance of SendAccessToken. + * note: we need to convert from the SDK response type to our internal type so that we can + * be sure it will serialize/deserialize correctly in state provider. + */ + static fromSendAccessTokenResponse( + sendAccessTokenResponse: SendAccessTokenResponse, + ): SendAccessToken { + return new SendAccessToken(sendAccessTokenResponse.token, sendAccessTokenResponse.expiresAt); + } +} diff --git a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts new file mode 100644 index 00000000000..8db0532911f --- /dev/null +++ b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts @@ -0,0 +1,678 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { + SendAccessTokenApiErrorResponse, + SendAccessTokenError, + SendAccessTokenInvalidGrantError, + SendAccessTokenInvalidRequestError, + SendAccessTokenResponse, + UnexpectedIdentityError, +} from "@bitwarden/sdk-internal"; +import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/state-test-utils"; + +import { + SendHashedPassword, + SendPasswordKeyMaterial, + SendPasswordService, +} from "../../../key-management/sends"; +import { Utils } from "../../../platform/misc/utils"; +import { MockSdkService } from "../../../platform/spec/mock-sdk.service"; +import { SendAccessToken } from "../models/send-access-token"; +import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type"; +import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type"; +import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type"; +import { SendOtp } from "../types/send-otp.type"; + +import { DefaultSendTokenService } from "./default-send-token.service"; +import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state"; + +describe("SendTokenService", () => { + let service: DefaultSendTokenService; + + // Deps + let sdkService: MockSdkService; + let globalStateProvider: FakeGlobalStateProvider; + let sendPasswordService: MockProxy; + + beforeEach(() => { + globalStateProvider = new FakeGlobalStateProvider(); + sdkService = new MockSdkService(); + sendPasswordService = mock(); + + service = new DefaultSendTokenService(globalStateProvider, sdkService, sendPasswordService); + }); + + it("instantiates", () => { + expect(service).toBeTruthy(); + }); + + describe("Send access token retrieval tests", () => { + let sendAccessTokenDictGlobalState: FakeGlobalState>; + + let sendAccessTokenResponse: SendAccessTokenResponse; + + let sendId: string; + let sendAccessToken: SendAccessToken; + let token: string; + let tokenExpiresAt: number; + + const EXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "expected_server"; + const UNEXPECTED_SERVER_KIND: GetSendAccessTokenError["kind"] = "unexpected_server"; + + const INVALID_REQUEST_CODES: SendAccessTokenInvalidRequestError[] = [ + "send_id_required", + "password_hash_b64_required", + "email_required", + "email_and_otp_required_otp_sent", + "unknown", + ]; + + const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [ + "send_id_invalid", + "password_hash_b64_invalid", + "email_invalid", + "otp_invalid", + "otp_generation_failed", + "unknown", + ]; + + const CREDS = [ + { kind: "password", passwordHashB64: "h4sh" as SendHashedPasswordB64 }, + { kind: "email", email: "u@example.com" }, + { kind: "email_otp", email: "u@example.com", otp: "123456" as SendOtp }, + ] as const satisfies readonly SendAccessDomainCredentials[]; + + type SendAccessTokenApiErrorResponseErrorCode = SendAccessTokenApiErrorResponse["error"]; + + type SimpleErrorType = Exclude< + SendAccessTokenApiErrorResponseErrorCode, + "invalid_request" | "invalid_grant" + >; + + // Extract out simple error types which don't have complex send_access_error_types to handle. + const SIMPLE_ERROR_TYPES = [ + "invalid_client", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", + "invalid_target", + ] as const satisfies readonly SimpleErrorType[]; + + beforeEach(() => { + sendId = "sendId"; + token = "sendAccessToken"; + tokenExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes from now + + sendAccessTokenResponse = { + token: token, + expiresAt: tokenExpiresAt, + }; + + sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(sendAccessTokenResponse); + + sendAccessTokenDictGlobalState = globalStateProvider.getFake(SEND_ACCESS_TOKEN_DICT); + // Ensure the state is empty before each test + sendAccessTokenDictGlobalState.stateSubject.next({}); + }); + + describe("tryGetSendAccessToken$", () => { + it("returns the send access token from session storage when token exists and isn't expired", async () => { + // Arrange + // Store the send access token in the global state + sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual(sendAccessToken); + }); + + it("returns expired error and clears token from storage when token is expired", async () => { + // Arrange + const oldDate = new Date("2025-01-01"); + const expiredSendAccessToken = new SendAccessToken(token, oldDate.getTime()); + sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: expiredSendAccessToken }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).not.toBeInstanceOf(SendAccessToken); + expect(result).toStrictEqual({ kind: "expired" }); + + // assert that we removed the expired token from storage. + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).not.toHaveProperty(sendId); + }); + + it("calls to get a new token if none is found in storage and stores the retrieved token in session storage", async () => { + // Arrange + sdkService.client.auth + .mockDeep() + .send_access.mockDeep() + .request_send_access_token.mockResolvedValue(sendAccessTokenResponse); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toBeInstanceOf(SendAccessToken); + expect(result).toEqual(sendAccessToken); + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken); + }); + + describe("handles expected invalid_request scenarios appropriately", () => { + it.each(INVALID_REQUEST_CODES)( + "surfaces %s as an expected invalid_request error", + async (code) => { + // Arrange + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_request", + error_description: code, + send_access_error_type: code, + }; + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual({ + kind: EXPECTED_SERVER_KIND, + error: sendAccessTokenApiErrorResponse, + }); + }, + ); + + it("handles bare expected invalid_request scenario appropriately", async () => { + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_request", + }; + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual({ + kind: EXPECTED_SERVER_KIND, + error: sendAccessTokenApiErrorResponse, + }); + }); + }); + + it.each(SIMPLE_ERROR_TYPES)("handles expected %s error appropriately", async (errorType) => { + const api: SendAccessTokenApiErrorResponse = { + error: errorType, + error_description: `The ${errorType} error occurred`, + }; + mockSdkRejectWith({ kind: "expected", data: api }); + + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api }); + }); + + it.each(SIMPLE_ERROR_TYPES)( + "handles expected %s bare error appropriately", + async (errorType) => { + const api: SendAccessTokenApiErrorResponse = { error: errorType }; + mockSdkRejectWith({ kind: "expected", data: api }); + + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api }); + }, + ); + + describe("handles expected invalid_grant scenarios appropriately", () => { + it.each(INVALID_GRANT_CODES)( + "surfaces %s as an expected invalid_grant error", + async (code) => { + // Arrange + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_grant", + error_description: code, + send_access_error_type: code, + }; + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual({ + kind: EXPECTED_SERVER_KIND, + error: sendAccessTokenApiErrorResponse, + }); + }, + ); + + it("handles bare expected invalid_grant scenario appropriately", async () => { + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_grant", + }; + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual({ + kind: EXPECTED_SERVER_KIND, + error: sendAccessTokenApiErrorResponse, + }); + }); + }); + + it("surfaces unexpected errors as unexpected_server error", async () => { + // Arrange + const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred"; + + mockSdkRejectWith({ + kind: "unexpected", + data: unexpectedIdentityError, + }); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual({ + kind: UNEXPECTED_SERVER_KIND, + error: unexpectedIdentityError, + }); + }); + + it("surfaces an unknown error as an unknown error", async () => { + // Arrange + const unknownError = "unknown error occurred"; + + sdkService.client.auth + .mockDeep() + .send_access.mockDeep() + .request_send_access_token.mockRejectedValue(new Error(unknownError)); + + // Act + const result = await firstValueFrom(service.tryGetSendAccessToken$(sendId)); + + // Assert + expect(result).toEqual({ + kind: "unknown", + error: unknownError, + }); + }); + + describe("getSendAccessTokenFromStorage", () => { + it("returns undefined if no token is found in storage", async () => { + const result = await (service as any).getSendAccessTokenFromStorage("nonexistentSendId"); + expect(result).toBeUndefined(); + }); + + it("returns the token if found in storage", async () => { + sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken }); + const result = await (service as any).getSendAccessTokenFromStorage(sendId); + expect(result).toEqual(sendAccessToken); + }); + + it("returns undefined if the global state isn't initialized yet", async () => { + sendAccessTokenDictGlobalState.stateSubject.next(null); + + const result = await (service as any).getSendAccessTokenFromStorage(sendId); + expect(result).toBeUndefined(); + }); + }); + + describe("setSendAccessTokenInStorage", () => { + it("stores the token in storage", async () => { + await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken); + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken); + }); + + it("initializes the dictionary if it isn't already", async () => { + sendAccessTokenDictGlobalState.stateSubject.next(null); + + await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken); + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken); + }); + + it("merges with existing tokens in storage", async () => { + const anotherSendId = "anotherSendId"; + const anotherSendAccessToken = new SendAccessToken( + "anotherToken", + Date.now() + 1000 * 60, + ); + + sendAccessTokenDictGlobalState.stateSubject.next({ + [anotherSendId]: anotherSendAccessToken, + }); + await (service as any).setSendAccessTokenInStorage(sendId, sendAccessToken); + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken); + expect(sendAccessTokenDict).toHaveProperty(anotherSendId, anotherSendAccessToken); + }); + }); + }); + + describe("getSendAccessToken$", () => { + it("returns a send access token for a password protected send when given valid password credentials", async () => { + // Arrange + const sendPasswordCredentials: SendAccessDomainCredentials = { + kind: "password", + passwordHashB64: "testPassword" as SendHashedPasswordB64, + }; + + sdkService.client.auth + .mockDeep() + .send_access.mockDeep() + .request_send_access_token.mockResolvedValue(sendAccessTokenResponse); + + // Act + const result = await firstValueFrom( + service.getSendAccessToken$(sendId, sendPasswordCredentials), + ); + + // Assert + expect(result).toEqual(sendAccessToken); + + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken); + }); + + // Note: we deliberately aren't testing the "success" scenario of passing + // just SendEmailCredentials as that will never return a send access token on it's own. + + it("returns a send access token for a email + otp protected send when given valid email and otp", async () => { + // Arrange + const sendEmailOtpCredentials: SendAccessDomainCredentials = { + kind: "email_otp", + email: "test@example.com", + otp: "123456" as SendOtp, + }; + + sdkService.client.auth + .mockDeep() + .send_access.mockDeep() + .request_send_access_token.mockResolvedValue(sendAccessTokenResponse); + + // Act + const result = await firstValueFrom( + service.getSendAccessToken$(sendId, sendEmailOtpCredentials), + ); + + // Assert + expect(result).toEqual(sendAccessToken); + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + expect(sendAccessTokenDict).toHaveProperty(sendId, sendAccessToken); + }); + + describe.each(CREDS.map((c) => [c.kind, c] as const))( + "scenarios with %s credentials", + (_label, creds) => { + it.each(INVALID_REQUEST_CODES)( + "handles expected invalid_request.%s scenario appropriately", + async (code) => { + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_request", + error_description: code, + send_access_error_type: code, + }; + + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds)); + + expect(result).toEqual({ + kind: "expected_server", + error: sendAccessTokenApiErrorResponse, + }); + }, + ); + + it("handles expected invalid_request scenario appropriately", async () => { + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_request", + }; + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + // Act + const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds)); + + // Assert + expect(result).toEqual({ + kind: "expected_server", + error: sendAccessTokenApiErrorResponse, + }); + }); + + it.each(INVALID_GRANT_CODES)( + "handles expected invalid_grant.%s scenario appropriately", + async (code) => { + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_grant", + error_description: code, + send_access_error_type: code, + }; + + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + const result = await firstValueFrom(service.getSendAccessToken$("abc123", creds)); + + expect(result).toEqual({ + kind: "expected_server", + error: sendAccessTokenApiErrorResponse, + }); + }, + ); + + it("handles expected invalid_grant scenario appropriately", async () => { + const sendAccessTokenApiErrorResponse: SendAccessTokenApiErrorResponse = { + error: "invalid_grant", + }; + mockSdkRejectWith({ + kind: "expected", + data: sendAccessTokenApiErrorResponse, + }); + + // Act + const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds)); + + // Assert + expect(result).toEqual({ + kind: "expected_server", + error: sendAccessTokenApiErrorResponse, + }); + }); + + it.each(SIMPLE_ERROR_TYPES)( + "handles expected %s error appropriately", + async (errorType) => { + const api: SendAccessTokenApiErrorResponse = { + error: errorType, + error_description: `The ${errorType} error occurred`, + }; + mockSdkRejectWith({ kind: "expected", data: api }); + + const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds)); + + expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api }); + }, + ); + + it.each(SIMPLE_ERROR_TYPES)( + "handles expected %s bare error appropriately", + async (errorType) => { + const api: SendAccessTokenApiErrorResponse = { error: errorType }; + mockSdkRejectWith({ kind: "expected", data: api }); + + const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds)); + + expect(result).toEqual({ kind: EXPECTED_SERVER_KIND, error: api }); + }, + ); + + it("surfaces unexpected errors as unexpected_server error", async () => { + // Arrange + const unexpectedIdentityError: UnexpectedIdentityError = "unexpected error occurred"; + + mockSdkRejectWith({ + kind: "unexpected", + data: unexpectedIdentityError, + }); + + // Act + const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds)); + + // Assert + expect(result).toEqual({ + kind: UNEXPECTED_SERVER_KIND, + error: unexpectedIdentityError, + }); + }); + + it("surfaces an unknown error as an unknown error", async () => { + // Arrange + const unknownError = "unknown error occurred"; + + sdkService.client.auth + .mockDeep() + .send_access.mockDeep() + .request_send_access_token.mockRejectedValue(new Error(unknownError)); + + // Act + const result = await firstValueFrom(service.getSendAccessToken$(sendId, creds)); + + // Assert + expect(result).toEqual({ + kind: "unknown", + error: unknownError, + }); + }); + }, + ); + + it("errors if passwordHashB64 is missing for password credentials", async () => { + const creds: SendAccessDomainCredentials = { + kind: "password", + passwordHashB64: "" as SendHashedPasswordB64, + }; + await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow( + "passwordHashB64 must be provided for password credentials.", + ); + }); + + it("errors if email is missing for email credentials", async () => { + const creds: SendAccessDomainCredentials = { + kind: "email", + email: "", + }; + await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow( + "email must be provided for email credentials.", + ); + }); + + it("errors if email or otp is missing for email_otp credentials", async () => { + const creds: SendAccessDomainCredentials = { + kind: "email_otp", + email: "", + otp: "" as SendOtp, + }; + await expect(firstValueFrom(service.getSendAccessToken$(sendId, creds))).rejects.toThrow( + "email and otp must be provided for email_otp credentials.", + ); + }); + }); + + describe("invalidateSendAccessToken", () => { + it("removes a send access token from storage", async () => { + // Arrange + sendAccessTokenDictGlobalState.stateSubject.next({ [sendId]: sendAccessToken }); + + // Act + await service.invalidateSendAccessToken(sendId); + const sendAccessTokenDict = await firstValueFrom(sendAccessTokenDictGlobalState.state$); + + // Assert + expect(sendAccessTokenDict).not.toHaveProperty(sendId); + }); + }); + }); + + describe("hashSendPassword", () => { + test.each(["", null, undefined])("rejects if password is %p", async (pwd) => { + await expect(service.hashSendPassword(pwd as any, "keyMaterialUrlB64")).rejects.toThrow( + "Password must be provided.", + ); + }); + + test.each(["", null, undefined])( + "rejects if keyMaterialUrlB64 is %p", + async (keyMaterialUrlB64) => { + await expect( + service.hashSendPassword("password", keyMaterialUrlB64 as any), + ).rejects.toThrow("KeyMaterialUrlB64 must be provided."); + }, + ); + + it("correctly hashes the password", async () => { + // Arrange + const password = "testPassword"; + const keyMaterialUrlB64 = "testKeyMaterialUrlB64"; + const keyMaterialArray = new Uint8Array([1, 2, 3]) as SendPasswordKeyMaterial; + const hashedPasswordArray = new Uint8Array([4, 5, 6]) as SendHashedPassword; + const sendHashedPasswordB64 = "hashedPasswordB64" as SendHashedPasswordB64; + + const utilsFromUrlB64ToArraySpy = jest + .spyOn(Utils, "fromUrlB64ToArray") + .mockReturnValue(keyMaterialArray); + + sendPasswordService.hashPassword.mockResolvedValue(hashedPasswordArray); + + const utilsFromBufferToB64Spy = jest + .spyOn(Utils, "fromBufferToB64") + .mockReturnValue(sendHashedPasswordB64); + + // Act + const result = await service.hashSendPassword(password, keyMaterialUrlB64); + + // Assert + expect(sendPasswordService.hashPassword).toHaveBeenCalledWith(password, keyMaterialArray); + expect(utilsFromUrlB64ToArraySpy).toHaveBeenCalledWith(keyMaterialUrlB64); + expect(utilsFromBufferToB64Spy).toHaveBeenCalledWith(hashedPasswordArray); + expect(result).toBe(sendHashedPasswordB64); + }); + }); + + function mockSdkRejectWith(sendAccessTokenError: SendAccessTokenError) { + sdkService.client.auth + .mockDeep() + .send_access.mockDeep() + .request_send_access_token.mockRejectedValue(sendAccessTokenError); + } +}); diff --git a/libs/common/src/auth/send-access/services/default-send-token.service.ts b/libs/common/src/auth/send-access/services/default-send-token.service.ts new file mode 100644 index 00000000000..4d3376fd5b6 --- /dev/null +++ b/libs/common/src/auth/send-access/services/default-send-token.service.ts @@ -0,0 +1,316 @@ +import { Observable, defer, firstValueFrom, from } from "rxjs"; + +import { + BitwardenClient, + SendAccessCredentials, + SendAccessTokenError, + SendAccessTokenRequest, + SendAccessTokenResponse, +} from "@bitwarden/sdk-internal"; +import { GlobalState, GlobalStateProvider } from "@bitwarden/state"; + +import { SendPasswordService } from "../../../key-management/sends/abstractions/send-password.service"; +import { + SendHashedPassword, + SendPasswordKeyMaterial, +} from "../../../key-management/sends/types/send-hashed-password.type"; +import { SdkService } from "../../../platform/abstractions/sdk/sdk.service"; +import { Utils } from "../../../platform/misc/utils"; +import { SendTokenService as SendTokenServiceAbstraction } from "../abstractions/send-token.service"; +import { SendAccessToken } from "../models/send-access-token"; +import { GetSendAccessTokenError } from "../types/get-send-access-token-error.type"; +import { SendAccessDomainCredentials } from "../types/send-access-domain-credentials.type"; +import { SendHashedPasswordB64 } from "../types/send-hashed-password-b64.type"; +import { TryGetSendAccessTokenError } from "../types/try-get-send-access-token-error.type"; + +import { SEND_ACCESS_TOKEN_DICT } from "./send-access-token-dict.state"; + +export class DefaultSendTokenService implements SendTokenServiceAbstraction { + private sendAccessTokenDictGlobalState: GlobalState> | undefined; + + constructor( + private globalStateProvider: GlobalStateProvider, + private sdkService: SdkService, + private sendPasswordService: SendPasswordService, + ) { + this.initializeState(); + } + + private initializeState(): void { + this.sendAccessTokenDictGlobalState = this.globalStateProvider.get(SEND_ACCESS_TOKEN_DICT); + } + + tryGetSendAccessToken$(sendId: string): Observable { + // Defer the execution to ensure that a cold observable is returned. + return defer(() => from(this._tryGetSendAccessToken(sendId))); + } + + private async _tryGetSendAccessToken( + sendId: string, + ): Promise { + // Validate the sendId is a non-empty string. + this.validateSendId(sendId); + + // Check in storage for the access token for the given sendId. + const sendAccessTokenFromStorage = await this.getSendAccessTokenFromStorage(sendId); + + if (sendAccessTokenFromStorage != null) { + // If it is expired, we clear the token from storage and return the expired error + if (sendAccessTokenFromStorage.isExpired()) { + await this.clearSendAccessTokenFromStorage(sendId); + return { kind: "expired" }; + } else { + // If it is not expired, we return it + return sendAccessTokenFromStorage; + } + } + + // If we don't have a token in storage, we can try to request a new token from the server. + const request: SendAccessTokenRequest = { + sendId: sendId, + }; + + const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$); + + try { + const result: SendAccessTokenResponse = await anonSdkClient + .auth() + .send_access() + .request_send_access_token(request); + + // Convert from SDK shape to SendAccessToken so it can be serialized into & out of state provider + const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result); + + // If we get a token back, we need to store it in the global state. + await this.setSendAccessTokenInStorage(sendId, sendAccessToken); + + return sendAccessToken; + } catch (error: unknown) { + return this.normalizeSendAccessTokenError(error); + } + } + + getSendAccessToken$( + sendId: string, + sendCredentials: SendAccessDomainCredentials, + ): Observable { + // Defer the execution to ensure that a cold observable is returned. + return defer(() => from(this._getSendAccessToken(sendId, sendCredentials))); + } + + private async _getSendAccessToken( + sendId: string, + sendAccessCredentials: SendAccessDomainCredentials, + ): Promise { + // Validate inputs to account for non-strict TS call sites. + this.validateCredentialsRequest(sendId, sendAccessCredentials); + + // Convert inputs to SDK request shape + const request: SendAccessTokenRequest = { + sendId: sendId, + sendAccessCredentials: this.convertDomainCredentialsToSdkCredentials(sendAccessCredentials), + }; + + const anonSdkClient: BitwardenClient = await firstValueFrom(this.sdkService.client$); + + try { + const result: SendAccessTokenResponse = await anonSdkClient + .auth() + .send_access() + .request_send_access_token(request); + + // Convert from SDK interface to SendAccessToken class so it can be serialized into & out of state provider + const sendAccessToken = SendAccessToken.fromSendAccessTokenResponse(result); + + // Any time we get a token from the server, we need to store it in the global state. + await this.setSendAccessTokenInStorage(sendId, sendAccessToken); + + return sendAccessToken; + } catch (error: unknown) { + return this.normalizeSendAccessTokenError(error); + } + } + + async invalidateSendAccessToken(sendId: string): Promise { + await this.clearSendAccessTokenFromStorage(sendId); + } + + async hashSendPassword( + password: string, + keyMaterialUrlB64: string, + ): Promise { + // Validate the password and key material + if (password == null || password.trim() === "") { + throw new Error("Password must be provided."); + } + if (keyMaterialUrlB64 == null || keyMaterialUrlB64.trim() === "") { + throw new Error("KeyMaterialUrlB64 must be provided."); + } + + // Convert the base64 URL encoded key material to a Uint8Array + const keyMaterialUrlB64Array = Utils.fromUrlB64ToArray( + keyMaterialUrlB64, + ) as SendPasswordKeyMaterial; + + const sendHashedPasswordArray: SendHashedPassword = await this.sendPasswordService.hashPassword( + password, + keyMaterialUrlB64Array, + ); + + // Convert the Uint8Array to a base64 encoded string which is required + // for the server to be able to compare the password hash. + const sendHashedPasswordB64 = Utils.fromBufferToB64( + sendHashedPasswordArray, + ) as SendHashedPasswordB64; + + return sendHashedPasswordB64; + } + + private async getSendAccessTokenFromStorage( + sendId: string, + ): Promise { + if (this.sendAccessTokenDictGlobalState != null) { + const sendAccessTokenDict = await firstValueFrom(this.sendAccessTokenDictGlobalState.state$); + return sendAccessTokenDict?.[sendId]; + } + return undefined; + } + + private async setSendAccessTokenInStorage( + sendId: string, + sendAccessToken: SendAccessToken, + ): Promise { + if (this.sendAccessTokenDictGlobalState != null) { + await this.sendAccessTokenDictGlobalState.update( + (sendAccessTokenDict) => { + sendAccessTokenDict ??= {}; // Initialize if undefined + + sendAccessTokenDict[sendId] = sendAccessToken; + return sendAccessTokenDict; + }, + { + // only update if the value is different (to avoid unnecessary writes) + shouldUpdate: (prevDict) => { + const prevSendAccessToken = prevDict?.[sendId]; + return ( + prevSendAccessToken?.token !== sendAccessToken.token || + prevSendAccessToken?.expiresAt !== sendAccessToken.expiresAt + ); + }, + }, + ); + } + } + + private async clearSendAccessTokenFromStorage(sendId: string): Promise { + if (this.sendAccessTokenDictGlobalState != null) { + await this.sendAccessTokenDictGlobalState.update( + (sendAccessTokenDict) => { + if (!sendAccessTokenDict) { + // If the dict is empty or undefined, there's nothing to clear + return sendAccessTokenDict; + } + + if (sendAccessTokenDict[sendId] == null) { + // If the specific sendId does not exist, nothing to clear + return sendAccessTokenDict; + } + + // Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [sendId]: _, ...rest } = sendAccessTokenDict; + + return rest; + }, + { + // only update if the value is defined (to avoid unnecessary writes) + shouldUpdate: (prevDict) => prevDict?.[sendId] != null, + }, + ); + } + } + + /** + * Normalizes an error from the SDK send access token request process. + * @param e The error to normalize. + * @returns A normalized GetSendAccessTokenError. + */ + private normalizeSendAccessTokenError(e: unknown): GetSendAccessTokenError { + if (this.isSendAccessTokenError(e)) { + if (e.kind === "unexpected") { + return { kind: "unexpected_server", error: e.data }; + } + return { kind: "expected_server", error: e.data }; + } + + if (e instanceof Error) { + return { kind: "unknown", error: e.message }; + } + + try { + return { kind: "unknown", error: JSON.stringify(e) }; + } catch { + return { kind: "unknown", error: "error cannot be stringified" }; + } + } + + private isSendAccessTokenError(e: unknown): e is SendAccessTokenError { + return ( + typeof e === "object" && + e !== null && + "kind" in e && + (e.kind === "expected" || e.kind === "unexpected") + ); + } + + private validateSendId(sendId: string): void { + if (sendId == null || sendId.trim() === "") { + throw new Error("sendId must be provided."); + } + } + + private validateCredentialsRequest( + sendId: string, + sendAccessCredentials: SendAccessDomainCredentials, + ): void { + this.validateSendId(sendId); + if (sendAccessCredentials == null) { + throw new Error("sendAccessCredentials must be provided."); + } + + if (sendAccessCredentials.kind === "password" && !sendAccessCredentials.passwordHashB64) { + throw new Error("passwordHashB64 must be provided for password credentials."); + } + + if (sendAccessCredentials.kind === "email" && !sendAccessCredentials.email) { + throw new Error("email must be provided for email credentials."); + } + + if ( + sendAccessCredentials.kind === "email_otp" && + (!sendAccessCredentials.email || !sendAccessCredentials.otp) + ) { + throw new Error("email and otp must be provided for email_otp credentials."); + } + } + + private convertDomainCredentialsToSdkCredentials( + sendAccessCredentials: SendAccessDomainCredentials, + ): SendAccessCredentials { + switch (sendAccessCredentials.kind) { + case "password": + return { + passwordHashB64: sendAccessCredentials.passwordHashB64, + }; + case "email": + return { + email: sendAccessCredentials.email, + }; + case "email_otp": + return { + email: sendAccessCredentials.email, + otp: sendAccessCredentials.otp, + }; + } + } +} diff --git a/libs/common/src/auth/send-access/services/index.ts b/libs/common/src/auth/send-access/services/index.ts new file mode 100644 index 00000000000..f45ffb94bab --- /dev/null +++ b/libs/common/src/auth/send-access/services/index.ts @@ -0,0 +1 @@ +export * from "./default-send-token.service"; diff --git a/libs/common/src/auth/send-access/services/send-access-token-dict.state.ts b/libs/common/src/auth/send-access/services/send-access-token-dict.state.ts new file mode 100644 index 00000000000..77e390768fc --- /dev/null +++ b/libs/common/src/auth/send-access/services/send-access-token-dict.state.ts @@ -0,0 +1,15 @@ +import { Jsonify } from "type-fest"; + +import { KeyDefinition, SEND_ACCESS_DISK } from "@bitwarden/state"; + +import { SendAccessToken } from "../models/send-access-token"; + +export const SEND_ACCESS_TOKEN_DICT = KeyDefinition.record( + SEND_ACCESS_DISK, + "accessTokenDict", + { + deserializer: (sendAccessTokenJson: Jsonify) => { + return SendAccessToken.fromJson(sendAccessTokenJson); + }, + }, +); diff --git a/libs/common/src/auth/send-access/types/get-send-access-token-error.type.ts b/libs/common/src/auth/send-access/types/get-send-access-token-error.type.ts new file mode 100644 index 00000000000..224e982c84c --- /dev/null +++ b/libs/common/src/auth/send-access/types/get-send-access-token-error.type.ts @@ -0,0 +1,12 @@ +import { UnexpectedIdentityError, SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal"; + +/** + * Represents the possible errors that can occur when retrieving a SendAccessToken. + * Note: for expected_server errors, see invalid-request-errors.type.ts and + * invalid-grant-errors.type.ts for type guards that identify specific + * SendAccessTokenApiErrorResponse errors + */ +export type GetSendAccessTokenError = + | { kind: "unexpected_server"; error: UnexpectedIdentityError } + | { kind: "expected_server"; error: SendAccessTokenApiErrorResponse } + | { kind: "unknown"; error: string }; diff --git a/libs/common/src/auth/send-access/types/index.ts b/libs/common/src/auth/send-access/types/index.ts new file mode 100644 index 00000000000..344ac00abbe --- /dev/null +++ b/libs/common/src/auth/send-access/types/index.ts @@ -0,0 +1,7 @@ +export * from "./try-get-send-access-token-error.type"; +export * from "./send-otp.type"; +export * from "./send-hashed-password-b64.type"; +export * from "./send-access-domain-credentials.type"; +export * from "./invalid-request-errors.type"; +export * from "./invalid-grant-errors.type"; +export * from "./get-send-access-token-error.type"; diff --git a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts new file mode 100644 index 00000000000..befb869a89e --- /dev/null +++ b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts @@ -0,0 +1,62 @@ +import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal"; + +export type InvalidGrant = Extract; + +export function isInvalidGrant(e: SendAccessTokenApiErrorResponse): e is InvalidGrant { + return e.error === "invalid_grant"; +} + +export type BareInvalidGrant = Extract< + SendAccessTokenApiErrorResponse, + { error: "invalid_grant" } +> & { send_access_error_type?: undefined }; + +export function isBareInvalidGrant(e: SendAccessTokenApiErrorResponse): e is BareInvalidGrant { + return e.error === "invalid_grant" && e.send_access_error_type === undefined; +} + +export type SendIdInvalid = InvalidGrant & { + send_access_error_type: "send_id_invalid"; +}; +export function sendIdInvalid(e: SendAccessTokenApiErrorResponse): e is SendIdInvalid { + return e.error === "invalid_grant" && e.send_access_error_type === "send_id_invalid"; +} + +export type PasswordHashB64Invalid = InvalidGrant & { + send_access_error_type: "password_hash_b64_invalid"; +}; +export function passwordHashB64Invalid( + e: SendAccessTokenApiErrorResponse, +): e is PasswordHashB64Invalid { + return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid"; +} + +export type EmailInvalid = InvalidGrant & { + send_access_error_type: "email_invalid"; +}; +export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid { + return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid"; +} + +export type OtpInvalid = InvalidGrant & { + send_access_error_type: "otp_invalid"; +}; +export function otpInvalid(e: SendAccessTokenApiErrorResponse): e is OtpInvalid { + return e.error === "invalid_grant" && e.send_access_error_type === "otp_invalid"; +} + +export type OtpGenerationFailed = InvalidGrant & { + send_access_error_type: "otp_generation_failed"; +}; +export function otpGenerationFailed(e: SendAccessTokenApiErrorResponse): e is OtpGenerationFailed { + return e.error === "invalid_grant" && e.send_access_error_type === "otp_generation_failed"; +} + +export type UnknownInvalidGrant = InvalidGrant & { + send_access_error_type: "unknown"; +}; +export function isUnknownInvalidGrant( + e: SendAccessTokenApiErrorResponse, +): e is UnknownInvalidGrant { + return e.error === "invalid_grant" && e.send_access_error_type === "unknown"; +} diff --git a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts new file mode 100644 index 00000000000..57a70e62586 --- /dev/null +++ b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts @@ -0,0 +1,62 @@ +import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal"; + +export type InvalidRequest = Extract; + +export function isInvalidRequest(e: SendAccessTokenApiErrorResponse): e is InvalidRequest { + return e.error === "invalid_request"; +} + +export type BareInvalidRequest = Extract< + SendAccessTokenApiErrorResponse, + { error: "invalid_request" } +> & { send_access_error_type?: undefined }; + +export function isBareInvalidRequest(e: SendAccessTokenApiErrorResponse): e is BareInvalidRequest { + return e.error === "invalid_request" && e.send_access_error_type === undefined; +} + +export type SendIdRequired = InvalidRequest & { + send_access_error_type: "send_id_required"; +}; + +export function sendIdRequired(e: SendAccessTokenApiErrorResponse): e is SendIdRequired { + return e.error === "invalid_request" && e.send_access_error_type === "send_id_required"; +} + +export type PasswordHashB64Required = InvalidRequest & { + send_access_error_type: "password_hash_b64_required"; +}; + +export function passwordHashB64Required( + e: SendAccessTokenApiErrorResponse, +): e is PasswordHashB64Required { + return e.error === "invalid_request" && e.send_access_error_type === "password_hash_b64_required"; +} + +export type EmailRequired = InvalidRequest & { send_access_error_type: "email_required" }; + +export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailRequired { + return e.error === "invalid_request" && e.send_access_error_type === "email_required"; +} + +export type EmailAndOtpRequiredEmailSent = InvalidRequest & { + send_access_error_type: "email_and_otp_required_otp_sent"; +}; + +export function emailAndOtpRequiredEmailSent( + e: SendAccessTokenApiErrorResponse, +): e is EmailAndOtpRequiredEmailSent { + return ( + e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent" + ); +} + +export type UnknownInvalidRequest = InvalidRequest & { + send_access_error_type: "unknown"; +}; + +export function isUnknownInvalidRequest( + e: SendAccessTokenApiErrorResponse, +): e is UnknownInvalidRequest { + return e.error === "invalid_request" && e.send_access_error_type === "unknown"; +} diff --git a/libs/common/src/auth/send-access/types/send-access-domain-credentials.type.ts b/libs/common/src/auth/send-access/types/send-access-domain-credentials.type.ts new file mode 100644 index 00000000000..966117dc64e --- /dev/null +++ b/libs/common/src/auth/send-access/types/send-access-domain-credentials.type.ts @@ -0,0 +1,11 @@ +import { SendHashedPasswordB64 } from "./send-hashed-password-b64.type"; +import { SendOtp } from "./send-otp.type"; + +/** + * The domain facing send access credentials + * Will be internally mapped to the SDK types + */ +export type SendAccessDomainCredentials = + | { kind: "password"; passwordHashB64: SendHashedPasswordB64 } + | { kind: "email"; email: string } + | { kind: "email_otp"; email: string; otp: SendOtp }; diff --git a/libs/common/src/auth/send-access/types/send-hashed-password-b64.type.ts b/libs/common/src/auth/send-access/types/send-hashed-password-b64.type.ts new file mode 100644 index 00000000000..9d55bdfb671 --- /dev/null +++ b/libs/common/src/auth/send-access/types/send-hashed-password-b64.type.ts @@ -0,0 +1,3 @@ +import { Opaque } from "type-fest"; + +export type SendHashedPasswordB64 = Opaque; diff --git a/libs/common/src/auth/send-access/types/send-otp.type.ts b/libs/common/src/auth/send-access/types/send-otp.type.ts new file mode 100644 index 00000000000..b5dfaa95ac5 --- /dev/null +++ b/libs/common/src/auth/send-access/types/send-otp.type.ts @@ -0,0 +1,3 @@ +import { Opaque } from "type-fest"; + +export type SendOtp = Opaque; diff --git a/libs/common/src/auth/send-access/types/try-get-send-access-token-error.type.ts b/libs/common/src/auth/send-access/types/try-get-send-access-token-error.type.ts new file mode 100644 index 00000000000..e82b426654c --- /dev/null +++ b/libs/common/src/auth/send-access/types/try-get-send-access-token-error.type.ts @@ -0,0 +1,7 @@ +import { GetSendAccessTokenError } from "./get-send-access-token-error.type"; + +/** + * Represents the possible errors that can occur when trying to retrieve a SendAccessToken by + * just a sendId. Extends {@link GetSendAccessTokenError}. + */ +export type TryGetSendAccessTokenError = { kind: "expired" } | GetSendAccessTokenError; diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 9968908a06f..e3ffa457e10 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -72,6 +72,7 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", { web: "disk-local", }); export const TOKEN_MEMORY = new StateDefinition("token", "memory"); +export const SEND_ACCESS_DISK = new StateDefinition("sendAccess", "disk"); export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory"); export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk"); export const ORGANIZATION_INVITE_DISK = new StateDefinition("organizationInvite", "disk"); diff --git a/package-lock.json b/package-lock.json index 1b126255e63..52c156d25eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.296", + "@bitwarden/sdk-internal": "0.2.0-main.311", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4688,9 +4688,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.296", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.296.tgz", - "integrity": "sha512-SDTWRwnR+KritfgJVBgWKd27TJxl4IlUdTldVJ/tA0qM5OqGWrY6s4ubtl5eaGIl2X4WYRAvpe+VR93FLakk6A==", + "version": "0.2.0-main.311", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.311.tgz", + "integrity": "sha512-zJdQykNMFOyivpNaCB9jc85wZ1ci2HM8/E4hI+yS7FgRm0sRigK5rieF3+xRjiq7pEsZSD8AucR+u/XK9ADXiw==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index e94d0e98522..127e375b92c 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.296", + "@bitwarden/sdk-internal": "0.2.0-main.311", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0",