mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
feat(SendAccess): [Auth/PM-22661] SendTokenService + SDK Integration (#16007)
* PM-22661 - Start bringing in code from original PR * PM-22661 - SendTokenService - implement and test hash send password * PM-22661 - Starting to pull in SDK state to SendTokenService * PM-22661 - WIP on default send token service * PM-22661 - Build out TS helpers for TryGetSendAccessTokenError * PM-22661 - WIP * PM-22661 - Decent progress on getting _tryGetSendAccessToken wired up * PM-22661 - Finish service implementation (TODO: test) * PM-22661 - DefaultSendTokenService - clear expired tokens * PM-22661 - SendTokenService - tests for tryGetSendAccessToken$ * PM-22661 - DefaultSendTokenService - more tests. * PM-22661 - Refactor to create domain facing type for send access creds so we can internally map to SDK models instead of exposing them. * PM-22661 - DefaultSendTokenService tests - finish testing error scenarios * PM-22661 - SendAccessToken - add threshold to expired check to prevent tokens from expiring in flight * PM-22661 - clean up docs and add invalidateSendAccessToken * PM-22661 - Add SendAccessToken tests * PM-22661 - Build out barrel files and provide send token service in jslib-services. * PM-22661 - Improve credential validation and test the scenarios * PM-22661 - Add handling for otp_generation_failed * PM-22661 - npm i sdk version 0.2.0-main.298 which has send access client stuff * PM-22661 - Bump to latest sdk changes for send access for testing. * PM-22661 - fix comment to be accurate * PM-22661 - DefaultSendTokenService - hashSendPassword - to fix compile time error with passing a Uint8Array to Utils.fromBufferToB64, add new overloads to Utils.fromBufferToB64 to handle ArrayBuffer and ArrayBufferView (to allow for Uint8Arrays). Then, test new scenarios to ensure feature parity with old fromBufferToB64 method. * PM-22661 - Utils.fromBufferToB64 - remove overloads so ordering doesn't break test spies. * PM-22661 - utils.fromBufferToB64 - re-add overloads to see effects on tests * PM-22661 - revert utils changes as they will be done in a separate PR. * PM-22661 - SendTokenService tests - test invalidateSendAccessToken * PM-22661 - DefaultSendTokenService - add some storage layer tests * PM-22661 - Per PR feedback fix comment * PM-22661 - Per PR feedback, optimize writes to state for send access tokens with shouldUpdate. * PM-22661 - Per PR feedback, update clear to be immutable vs delete (mutation) based. * PM-22661 - Per PR feedback, re-add should update for clear method. * PM-22661 - Update libs/common/src/auth/send-access/services/default-send-token.service.ts Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> * PM-22661 - Update libs/common/src/auth/send-access/services/default-send-token.service.ts Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> * PM-22661 - Update libs/common/src/auth/send-access/services/default-send-token.service.ts Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com> --------- Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com>
This commit is contained in:
@@ -104,6 +104,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
|
|||||||
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
|
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 { 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 { 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 { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
|
||||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
|
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
|
||||||
@@ -1588,6 +1589,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
MessageListener,
|
MessageListener,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: SendTokenService,
|
||||||
|
useClass: DefaultSendTokenService,
|
||||||
|
deps: [GlobalStateProvider, SdkService, SendPasswordService],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: EndUserNotificationService,
|
provide: EndUserNotificationService,
|
||||||
useClass: DefaultEndUserNotificationService,
|
useClass: DefaultEndUserNotificationService,
|
||||||
|
|||||||
1
libs/common/src/auth/send-access/abstractions/index.ts
Normal file
1
libs/common/src/auth/send-access/abstractions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./send-token.service";
|
||||||
@@ -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<SendAccessToken | TryGetSendAccessTokenError>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<SendAccessToken | GetSendAccessTokenError>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<SendHashedPasswordB64>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a send access token from storage.
|
||||||
|
*/
|
||||||
|
abstract invalidateSendAccessToken: (sendId: string) => Promise<void>;
|
||||||
|
}
|
||||||
4
libs/common/src/auth/send-access/index.ts
Normal file
4
libs/common/src/auth/send-access/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./abstractions";
|
||||||
|
export * from "./models";
|
||||||
|
export * from "./services";
|
||||||
|
export * from "./types";
|
||||||
1
libs/common/src/auth/send-access/models/index.ts
Normal file
1
libs/common/src/auth/send-access/models/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./send-access-token";
|
||||||
@@ -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<number, []>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
libs/common/src/auth/send-access/models/send-access-token.ts
Normal file
46
libs/common/src/auth/send-access/models/send-access-token.ts
Normal file
@@ -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>): 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SendPasswordService>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalStateProvider = new FakeGlobalStateProvider();
|
||||||
|
sdkService = new MockSdkService();
|
||||||
|
sendPasswordService = mock<SendPasswordService>();
|
||||||
|
|
||||||
|
service = new DefaultSendTokenService(globalStateProvider, sdkService, sendPasswordService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instantiates", () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Send access token retrieval tests", () => {
|
||||||
|
let sendAccessTokenDictGlobalState: FakeGlobalState<Record<string, SendAccessToken>>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<Record<string, SendAccessToken>> | 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<SendAccessToken | TryGetSendAccessTokenError> {
|
||||||
|
// Defer the execution to ensure that a cold observable is returned.
|
||||||
|
return defer(() => from(this._tryGetSendAccessToken(sendId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _tryGetSendAccessToken(
|
||||||
|
sendId: string,
|
||||||
|
): Promise<SendAccessToken | TryGetSendAccessTokenError> {
|
||||||
|
// 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<SendAccessToken | GetSendAccessTokenError> {
|
||||||
|
// 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<SendAccessToken | GetSendAccessTokenError> {
|
||||||
|
// 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<void> {
|
||||||
|
await this.clearSendAccessTokenFromStorage(sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hashSendPassword(
|
||||||
|
password: string,
|
||||||
|
keyMaterialUrlB64: string,
|
||||||
|
): Promise<SendHashedPasswordB64> {
|
||||||
|
// 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<SendAccessToken | undefined> {
|
||||||
|
if (this.sendAccessTokenDictGlobalState != null) {
|
||||||
|
const sendAccessTokenDict = await firstValueFrom(this.sendAccessTokenDictGlobalState.state$);
|
||||||
|
return sendAccessTokenDict?.[sendId];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setSendAccessTokenInStorage(
|
||||||
|
sendId: string,
|
||||||
|
sendAccessToken: SendAccessToken,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
libs/common/src/auth/send-access/services/index.ts
Normal file
1
libs/common/src/auth/send-access/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./default-send-token.service";
|
||||||
@@ -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<SendAccessToken, string>(
|
||||||
|
SEND_ACCESS_DISK,
|
||||||
|
"accessTokenDict",
|
||||||
|
{
|
||||||
|
deserializer: (sendAccessTokenJson: Jsonify<SendAccessToken>) => {
|
||||||
|
return SendAccessToken.fromJson(sendAccessTokenJson);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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 };
|
||||||
7
libs/common/src/auth/send-access/types/index.ts
Normal file
7
libs/common/src/auth/send-access/types/index.ts
Normal file
@@ -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";
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
|
export type InvalidGrant = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_grant" }>;
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { SendAccessTokenApiErrorResponse } from "@bitwarden/sdk-internal";
|
||||||
|
|
||||||
|
export type InvalidRequest = Extract<SendAccessTokenApiErrorResponse, { error: "invalid_request" }>;
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
|
export type SendHashedPasswordB64 = Opaque<string, "SendHashedPasswordB64">;
|
||||||
3
libs/common/src/auth/send-access/types/send-otp.type.ts
Normal file
3
libs/common/src/auth/send-access/types/send-otp.type.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
|
export type SendOtp = Opaque<string, "SendOtp">;
|
||||||
@@ -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;
|
||||||
@@ -72,6 +72,7 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
|
|||||||
web: "disk-local",
|
web: "disk-local",
|
||||||
});
|
});
|
||||||
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
|
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 TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
|
||||||
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
|
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
|
||||||
export const ORGANIZATION_INVITE_DISK = new StateDefinition("organizationInvite", "disk");
|
export const ORGANIZATION_INVITE_DISK = new StateDefinition("organizationInvite", "disk");
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -23,7 +23,7 @@
|
|||||||
"@angular/platform-browser": "19.2.14",
|
"@angular/platform-browser": "19.2.14",
|
||||||
"@angular/platform-browser-dynamic": "19.2.14",
|
"@angular/platform-browser-dynamic": "19.2.14",
|
||||||
"@angular/router": "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",
|
"@electron/fuses": "1.8.0",
|
||||||
"@emotion/css": "11.13.5",
|
"@emotion/css": "11.13.5",
|
||||||
"@koa/multer": "4.0.0",
|
"@koa/multer": "4.0.0",
|
||||||
@@ -4688,9 +4688,9 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@bitwarden/sdk-internal": {
|
"node_modules/@bitwarden/sdk-internal": {
|
||||||
"version": "0.2.0-main.296",
|
"version": "0.2.0-main.311",
|
||||||
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.296.tgz",
|
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.311.tgz",
|
||||||
"integrity": "sha512-SDTWRwnR+KritfgJVBgWKd27TJxl4IlUdTldVJ/tA0qM5OqGWrY6s4ubtl5eaGIl2X4WYRAvpe+VR93FLakk6A==",
|
"integrity": "sha512-zJdQykNMFOyivpNaCB9jc85wZ1ci2HM8/E4hI+yS7FgRm0sRigK5rieF3+xRjiq7pEsZSD8AucR+u/XK9ADXiw==",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"type-fest": "^4.41.0"
|
"type-fest": "^4.41.0"
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
"@angular/platform-browser": "19.2.14",
|
"@angular/platform-browser": "19.2.14",
|
||||||
"@angular/platform-browser-dynamic": "19.2.14",
|
"@angular/platform-browser-dynamic": "19.2.14",
|
||||||
"@angular/router": "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",
|
"@electron/fuses": "1.8.0",
|
||||||
"@emotion/css": "11.13.5",
|
"@emotion/css": "11.13.5",
|
||||||
"@koa/multer": "4.0.0",
|
"@koa/multer": "4.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user