mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 02:23:25 +00:00
Merge remote-tracking branch 'origin/main' into beeep/developer-tooling-feature-flags + merge conflict resolutions
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionDetailsResponse,
|
||||
CollectionRequest,
|
||||
CollectionResponse,
|
||||
CreateCollectionRequest,
|
||||
UpdateCollectionRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
|
||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
OrganizationConnectionConfigApis,
|
||||
OrganizationConnectionResponse,
|
||||
} from "../admin-console/models/response/organization-connection.response";
|
||||
import { OrganizationExportResponse } from "../admin-console/models/response/organization-export.response";
|
||||
import { OrganizationSponsorshipSyncStatusResponse } from "../admin-console/models/response/organization-sponsorship-sync-status.response";
|
||||
import { PreValidateSponsorshipResponse } from "../admin-console/models/response/pre-validate-sponsorship.response";
|
||||
import {
|
||||
@@ -39,8 +37,6 @@ import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
import { EmailRequest } from "../auth/models/request/email.request";
|
||||
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
|
||||
@@ -50,43 +46,20 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token
|
||||
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
|
||||
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
|
||||
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
|
||||
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
|
||||
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
|
||||
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
|
||||
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
|
||||
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
|
||||
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request";
|
||||
import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request";
|
||||
import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request";
|
||||
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
|
||||
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
|
||||
import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response";
|
||||
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
|
||||
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
|
||||
import { PreloginResponse } from "../auth/models/response/prelogin.response";
|
||||
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
|
||||
import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response";
|
||||
import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response";
|
||||
import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response";
|
||||
import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response";
|
||||
import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response";
|
||||
import {
|
||||
ChallengeResponse,
|
||||
TwoFactorWebAuthnResponse,
|
||||
} from "../auth/models/response/two-factor-web-authn.response";
|
||||
import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response";
|
||||
import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request";
|
||||
import { PaymentRequest } from "../billing/models/request/payment.request";
|
||||
import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request";
|
||||
import { BillingHistoryResponse } from "../billing/models/response/billing-history.response";
|
||||
import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response";
|
||||
import { PaymentResponse } from "../billing/models/response/payment.response";
|
||||
import { PlanResponse } from "../billing/models/response/plan.response";
|
||||
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
|
||||
import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
|
||||
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
|
||||
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
|
||||
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
||||
@@ -99,7 +72,6 @@ import { UpdateAvatarRequest } from "../models/request/update-avatar.request";
|
||||
import { UpdateDomainsRequest } from "../models/request/update-domains.request";
|
||||
import { VerifyDeleteRecoverRequest } from "../models/request/verify-delete-recover.request";
|
||||
import { VerifyEmailRequest } from "../models/request/verify-email.request";
|
||||
import { BreachAccountResponse } from "../models/response/breach-account.response";
|
||||
import { DomainsResponse } from "../models/response/domains.response";
|
||||
import { EventResponse } from "../models/response/event.response";
|
||||
import { ListResponse } from "../models/response/list.response";
|
||||
@@ -128,204 +100,192 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher
|
||||
* of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service.
|
||||
*/
|
||||
export abstract class ApiService {
|
||||
send: (
|
||||
/** @deprecated Use the overload accepting the user you want the request authenticated for. */
|
||||
abstract send(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
authed: boolean,
|
||||
authed: true,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (header: Headers) => void,
|
||||
): Promise<any>;
|
||||
|
||||
/** Sends an unauthenticated API request. */
|
||||
abstract send(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
authed: false,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (header: Headers) => void,
|
||||
): Promise<any>;
|
||||
|
||||
/** Sends an API request authenticated with the given users ID. */
|
||||
abstract send(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
userId: UserId,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
) => Promise<any>;
|
||||
): Promise<any>;
|
||||
|
||||
postIdentityToken: (
|
||||
abstract postIdentityToken(
|
||||
request:
|
||||
| PasswordTokenRequest
|
||||
| SsoTokenRequest
|
||||
| UserApiTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
) => Promise<
|
||||
): Promise<
|
||||
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
|
||||
>;
|
||||
refreshIdentityToken: () => Promise<any>;
|
||||
abstract refreshIdentityToken(userId?: UserId): Promise<any>;
|
||||
|
||||
getProfile: () => Promise<ProfileResponse>;
|
||||
getUserSubscription: () => Promise<SubscriptionResponse>;
|
||||
getTaxInfo: () => Promise<TaxInfoResponse>;
|
||||
putProfile: (request: UpdateProfileRequest) => Promise<ProfileResponse>;
|
||||
putAvatar: (request: UpdateAvatarRequest) => Promise<ProfileResponse>;
|
||||
putTaxInfo: (request: TaxInfoUpdateRequest) => Promise<any>;
|
||||
postPrelogin: (request: PreloginRequest) => Promise<PreloginResponse>;
|
||||
postEmailToken: (request: EmailTokenRequest) => Promise<any>;
|
||||
postEmail: (request: EmailRequest) => Promise<any>;
|
||||
postSetKeyConnectorKey: (request: SetKeyConnectorKeyRequest) => Promise<any>;
|
||||
postSecurityStamp: (request: SecretVerificationRequest) => Promise<any>;
|
||||
getAccountRevisionDate: () => Promise<number>;
|
||||
postPasswordHint: (request: PasswordHintRequest) => Promise<any>;
|
||||
postPremium: (data: FormData) => Promise<PaymentResponse>;
|
||||
postReinstatePremium: () => Promise<any>;
|
||||
postAccountStorage: (request: StorageRequest) => Promise<PaymentResponse>;
|
||||
postAccountPayment: (request: PaymentRequest) => Promise<void>;
|
||||
postAccountLicense: (data: FormData) => Promise<any>;
|
||||
postAccountKeys: (request: KeysRequest) => Promise<any>;
|
||||
postAccountVerifyEmail: () => Promise<any>;
|
||||
postAccountVerifyEmailToken: (request: VerifyEmailRequest) => Promise<any>;
|
||||
postAccountRecoverDelete: (request: DeleteRecoverRequest) => Promise<any>;
|
||||
postAccountRecoverDeleteToken: (request: VerifyDeleteRecoverRequest) => Promise<any>;
|
||||
postAccountKdf: (request: KdfRequest) => Promise<any>;
|
||||
postUserApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
|
||||
postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
|
||||
postConvertToKeyConnector: () => Promise<void>;
|
||||
abstract getProfile(): Promise<ProfileResponse>;
|
||||
abstract getUserSubscription(): Promise<SubscriptionResponse>;
|
||||
abstract putProfile(request: UpdateProfileRequest): Promise<ProfileResponse>;
|
||||
abstract putAvatar(request: UpdateAvatarRequest): Promise<ProfileResponse>;
|
||||
abstract postPrelogin(request: PreloginRequest): Promise<PreloginResponse>;
|
||||
abstract postEmailToken(request: EmailTokenRequest): Promise<any>;
|
||||
abstract postEmail(request: EmailRequest): Promise<any>;
|
||||
abstract postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any>;
|
||||
abstract postSecurityStamp(request: SecretVerificationRequest): Promise<any>;
|
||||
abstract getAccountRevisionDate(): Promise<number>;
|
||||
abstract postPasswordHint(request: PasswordHintRequest): Promise<any>;
|
||||
abstract postPremium(data: FormData): Promise<PaymentResponse>;
|
||||
abstract postReinstatePremium(): Promise<any>;
|
||||
abstract postAccountStorage(request: StorageRequest): Promise<PaymentResponse>;
|
||||
abstract postAccountLicense(data: FormData): Promise<any>;
|
||||
abstract postAccountKeys(request: KeysRequest): Promise<any>;
|
||||
abstract postAccountVerifyEmail(): Promise<any>;
|
||||
abstract postAccountVerifyEmailToken(request: VerifyEmailRequest): Promise<any>;
|
||||
abstract postAccountRecoverDelete(request: DeleteRecoverRequest): Promise<any>;
|
||||
abstract postAccountRecoverDeleteToken(request: VerifyDeleteRecoverRequest): Promise<any>;
|
||||
abstract postAccountKdf(request: KdfRequest): Promise<any>;
|
||||
abstract postUserApiKey(id: string, request: SecretVerificationRequest): Promise<ApiKeyResponse>;
|
||||
abstract postUserRotateApiKey(
|
||||
id: string,
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<ApiKeyResponse>;
|
||||
abstract postConvertToKeyConnector(): Promise<void>;
|
||||
//passwordless
|
||||
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
|
||||
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
|
||||
getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>;
|
||||
getLastAuthRequest: () => Promise<AuthRequestResponse>;
|
||||
abstract getAuthRequest(id: string): Promise<AuthRequestResponse>;
|
||||
abstract putAuthRequest(
|
||||
id: string,
|
||||
request: PasswordlessAuthRequest,
|
||||
): Promise<AuthRequestResponse>;
|
||||
abstract getAuthRequests(): Promise<ListResponse<AuthRequestResponse>>;
|
||||
abstract getLastAuthRequest(): Promise<AuthRequestResponse>;
|
||||
|
||||
getUserBillingHistory: () => Promise<BillingHistoryResponse>;
|
||||
getUserBillingPayment: () => Promise<BillingPaymentResponse>;
|
||||
abstract getUserBillingHistory(): Promise<BillingHistoryResponse>;
|
||||
|
||||
getCipher: (id: string) => Promise<CipherResponse>;
|
||||
getFullCipherDetails: (id: string) => Promise<CipherResponse>;
|
||||
getCipherAdmin: (id: string) => Promise<CipherResponse>;
|
||||
getAttachmentData: (
|
||||
abstract getCipher(id: string): Promise<CipherResponse>;
|
||||
abstract getFullCipherDetails(id: string): Promise<CipherResponse>;
|
||||
abstract getCipherAdmin(id: string): Promise<CipherResponse>;
|
||||
abstract getAttachmentData(
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
emergencyAccessId?: string,
|
||||
) => Promise<AttachmentResponse>;
|
||||
getAttachmentDataAdmin: (cipherId: string, attachmentId: string) => Promise<AttachmentResponse>;
|
||||
getCiphersOrganization: (organizationId: string) => Promise<ListResponse<CipherResponse>>;
|
||||
postCipher: (request: CipherRequest) => Promise<CipherResponse>;
|
||||
postCipherCreate: (request: CipherCreateRequest) => Promise<CipherResponse>;
|
||||
postCipherAdmin: (request: CipherCreateRequest) => Promise<CipherResponse>;
|
||||
putCipher: (id: string, request: CipherRequest) => Promise<CipherResponse>;
|
||||
putPartialCipher: (id: string, request: CipherPartialRequest) => Promise<CipherResponse>;
|
||||
putCipherAdmin: (id: string, request: CipherRequest) => Promise<CipherResponse>;
|
||||
deleteCipher: (id: string) => Promise<any>;
|
||||
deleteCipherAdmin: (id: string) => Promise<any>;
|
||||
deleteManyCiphers: (request: CipherBulkDeleteRequest) => Promise<any>;
|
||||
deleteManyCiphersAdmin: (request: CipherBulkDeleteRequest) => Promise<any>;
|
||||
putMoveCiphers: (request: CipherBulkMoveRequest) => Promise<any>;
|
||||
putShareCipher: (id: string, request: CipherShareRequest) => Promise<CipherResponse>;
|
||||
putShareCiphers: (request: CipherBulkShareRequest) => Promise<ListResponse<CipherResponse>>;
|
||||
putCipherCollections: (
|
||||
): Promise<AttachmentResponse>;
|
||||
abstract getAttachmentDataAdmin(
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentResponse>;
|
||||
abstract getCiphersOrganization(organizationId: string): Promise<ListResponse<CipherResponse>>;
|
||||
abstract postCipher(request: CipherRequest): Promise<CipherResponse>;
|
||||
abstract postCipherCreate(request: CipherCreateRequest): Promise<CipherResponse>;
|
||||
abstract postCipherAdmin(request: CipherCreateRequest): Promise<CipherResponse>;
|
||||
abstract putCipher(id: string, request: CipherRequest): Promise<CipherResponse>;
|
||||
abstract putPartialCipher(id: string, request: CipherPartialRequest): Promise<CipherResponse>;
|
||||
abstract putCipherAdmin(id: string, request: CipherRequest): Promise<CipherResponse>;
|
||||
abstract deleteCipher(id: string): Promise<any>;
|
||||
abstract deleteCipherAdmin(id: string): Promise<any>;
|
||||
abstract deleteManyCiphers(request: CipherBulkDeleteRequest): Promise<any>;
|
||||
abstract deleteManyCiphersAdmin(request: CipherBulkDeleteRequest): Promise<any>;
|
||||
abstract putMoveCiphers(request: CipherBulkMoveRequest): Promise<any>;
|
||||
abstract putShareCipher(id: string, request: CipherShareRequest): Promise<CipherResponse>;
|
||||
abstract putShareCiphers(request: CipherBulkShareRequest): Promise<ListResponse<CipherResponse>>;
|
||||
abstract putCipherCollections(
|
||||
id: string,
|
||||
request: CipherCollectionsRequest,
|
||||
) => Promise<OptionalCipherResponse>;
|
||||
putCipherCollectionsAdmin: (id: string, request: CipherCollectionsRequest) => Promise<any>;
|
||||
postPurgeCiphers: (request: SecretVerificationRequest, organizationId?: string) => Promise<any>;
|
||||
putDeleteCipher: (id: string) => Promise<any>;
|
||||
putDeleteCipherAdmin: (id: string) => Promise<any>;
|
||||
putDeleteManyCiphers: (request: CipherBulkDeleteRequest) => Promise<any>;
|
||||
putDeleteManyCiphersAdmin: (request: CipherBulkDeleteRequest) => Promise<any>;
|
||||
putRestoreCipher: (id: string) => Promise<CipherResponse>;
|
||||
putRestoreCipherAdmin: (id: string) => Promise<CipherResponse>;
|
||||
putRestoreManyCiphers: (
|
||||
): Promise<OptionalCipherResponse>;
|
||||
abstract putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any>;
|
||||
abstract postPurgeCiphers(
|
||||
request: SecretVerificationRequest,
|
||||
organizationId?: string,
|
||||
): Promise<any>;
|
||||
abstract putDeleteCipher(id: string): Promise<any>;
|
||||
abstract putDeleteCipherAdmin(id: string): Promise<any>;
|
||||
abstract putDeleteManyCiphers(request: CipherBulkDeleteRequest): Promise<any>;
|
||||
abstract putDeleteManyCiphersAdmin(request: CipherBulkDeleteRequest): Promise<any>;
|
||||
abstract putRestoreCipher(id: string): Promise<CipherResponse>;
|
||||
abstract putRestoreCipherAdmin(id: string): Promise<CipherResponse>;
|
||||
abstract putRestoreManyCiphers(
|
||||
request: CipherBulkRestoreRequest,
|
||||
) => Promise<ListResponse<CipherResponse>>;
|
||||
putRestoreManyCiphersAdmin: (
|
||||
): Promise<ListResponse<CipherResponse>>;
|
||||
abstract putRestoreManyCiphersAdmin(
|
||||
request: CipherBulkRestoreRequest,
|
||||
) => Promise<ListResponse<CipherResponse>>;
|
||||
): Promise<ListResponse<CipherResponse>>;
|
||||
|
||||
postCipherAttachment: (
|
||||
abstract postCipherAttachment(
|
||||
id: string,
|
||||
request: AttachmentRequest,
|
||||
) => Promise<AttachmentUploadDataResponse>;
|
||||
deleteCipherAttachment: (id: string, attachmentId: string) => Promise<any>;
|
||||
deleteCipherAttachmentAdmin: (id: string, attachmentId: string) => Promise<any>;
|
||||
postShareCipherAttachment: (
|
||||
): Promise<AttachmentUploadDataResponse>;
|
||||
abstract deleteCipherAttachment(id: string, attachmentId: string): Promise<any>;
|
||||
abstract deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any>;
|
||||
abstract postShareCipherAttachment(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
data: FormData,
|
||||
organizationId: string,
|
||||
) => Promise<any>;
|
||||
renewAttachmentUploadUrl: (
|
||||
): Promise<any>;
|
||||
abstract renewAttachmentUploadUrl(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
) => Promise<AttachmentUploadDataResponse>;
|
||||
postAttachmentFile: (id: string, attachmentId: string, data: FormData) => Promise<any>;
|
||||
): Promise<AttachmentUploadDataResponse>;
|
||||
abstract postAttachmentFile(id: string, attachmentId: string, data: FormData): Promise<any>;
|
||||
|
||||
getUserCollections: () => Promise<ListResponse<CollectionResponse>>;
|
||||
getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>;
|
||||
getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>;
|
||||
getCollectionAccessDetails: (
|
||||
abstract getUserCollections(): Promise<ListResponse<CollectionResponse>>;
|
||||
abstract getCollections(organizationId: string): Promise<ListResponse<CollectionResponse>>;
|
||||
abstract getCollectionUsers(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
) => Promise<CollectionAccessDetailsResponse>;
|
||||
getManyCollectionsWithAccessDetails: (
|
||||
): Promise<SelectionReadOnlyResponse[]>;
|
||||
abstract getCollectionAccessDetails(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
): Promise<CollectionAccessDetailsResponse>;
|
||||
abstract getManyCollectionsWithAccessDetails(
|
||||
orgId: string,
|
||||
) => Promise<ListResponse<CollectionAccessDetailsResponse>>;
|
||||
postCollection: (
|
||||
): Promise<ListResponse<CollectionAccessDetailsResponse>>;
|
||||
abstract postCollection(
|
||||
organizationId: string,
|
||||
request: CollectionRequest,
|
||||
) => Promise<CollectionDetailsResponse>;
|
||||
putCollection: (
|
||||
request: CreateCollectionRequest,
|
||||
): Promise<CollectionDetailsResponse>;
|
||||
abstract putCollection(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
request: CollectionRequest,
|
||||
) => Promise<CollectionDetailsResponse>;
|
||||
deleteCollection: (organizationId: string, id: string) => Promise<any>;
|
||||
deleteManyCollections: (organizationId: string, collectionIds: string[]) => Promise<any>;
|
||||
request: UpdateCollectionRequest,
|
||||
): Promise<CollectionDetailsResponse>;
|
||||
abstract deleteCollection(organizationId: string, id: string): Promise<any>;
|
||||
abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>;
|
||||
|
||||
getGroupUsers: (organizationId: string, id: string) => Promise<string[]>;
|
||||
deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>;
|
||||
|
||||
getSync: () => Promise<SyncResponse>;
|
||||
|
||||
getSettingsDomains: () => Promise<DomainsResponse>;
|
||||
putSettingsDomains: (request: UpdateDomainsRequest) => Promise<DomainsResponse>;
|
||||
|
||||
getTwoFactorProviders: () => Promise<ListResponse<TwoFactorProviderResponse>>;
|
||||
getTwoFactorOrganizationProviders: (
|
||||
abstract getGroupUsers(organizationId: string, id: string): Promise<string[]>;
|
||||
abstract deleteGroupUser(
|
||||
organizationId: string,
|
||||
) => Promise<ListResponse<TwoFactorProviderResponse>>;
|
||||
getTwoFactorAuthenticator: (
|
||||
request: SecretVerificationRequest,
|
||||
) => Promise<TwoFactorAuthenticatorResponse>;
|
||||
getTwoFactorEmail: (request: SecretVerificationRequest) => Promise<TwoFactorEmailResponse>;
|
||||
getTwoFactorDuo: (request: SecretVerificationRequest) => Promise<TwoFactorDuoResponse>;
|
||||
getTwoFactorOrganizationDuo: (
|
||||
organizationId: string,
|
||||
request: SecretVerificationRequest,
|
||||
) => Promise<TwoFactorDuoResponse>;
|
||||
getTwoFactorYubiKey: (request: SecretVerificationRequest) => Promise<TwoFactorYubiKeyResponse>;
|
||||
getTwoFactorWebAuthn: (request: SecretVerificationRequest) => Promise<TwoFactorWebAuthnResponse>;
|
||||
getTwoFactorWebAuthnChallenge: (request: SecretVerificationRequest) => Promise<ChallengeResponse>;
|
||||
getTwoFactorRecover: (request: SecretVerificationRequest) => Promise<TwoFactorRecoverResponse>;
|
||||
putTwoFactorAuthenticator: (
|
||||
request: UpdateTwoFactorAuthenticatorRequest,
|
||||
) => Promise<TwoFactorAuthenticatorResponse>;
|
||||
deleteTwoFactorAuthenticator: (
|
||||
request: DisableTwoFactorAuthenticatorRequest,
|
||||
) => Promise<TwoFactorProviderResponse>;
|
||||
putTwoFactorEmail: (request: UpdateTwoFactorEmailRequest) => Promise<TwoFactorEmailResponse>;
|
||||
putTwoFactorDuo: (request: UpdateTwoFactorDuoRequest) => Promise<TwoFactorDuoResponse>;
|
||||
putTwoFactorOrganizationDuo: (
|
||||
organizationId: string,
|
||||
request: UpdateTwoFactorDuoRequest,
|
||||
) => Promise<TwoFactorDuoResponse>;
|
||||
putTwoFactorYubiKey: (
|
||||
request: UpdateTwoFactorYubikeyOtpRequest,
|
||||
) => Promise<TwoFactorYubiKeyResponse>;
|
||||
putTwoFactorWebAuthn: (
|
||||
request: UpdateTwoFactorWebAuthnRequest,
|
||||
) => Promise<TwoFactorWebAuthnResponse>;
|
||||
deleteTwoFactorWebAuthn: (
|
||||
request: UpdateTwoFactorWebAuthnDeleteRequest,
|
||||
) => Promise<TwoFactorWebAuthnResponse>;
|
||||
putTwoFactorDisable: (request: TwoFactorProviderRequest) => Promise<TwoFactorProviderResponse>;
|
||||
putTwoFactorOrganizationDisable: (
|
||||
organizationId: string,
|
||||
request: TwoFactorProviderRequest,
|
||||
) => Promise<TwoFactorProviderResponse>;
|
||||
postTwoFactorEmailSetup: (request: TwoFactorEmailRequest) => Promise<any>;
|
||||
postTwoFactorEmail: (request: TwoFactorEmailRequest) => Promise<any>;
|
||||
getDeviceVerificationSettings: () => Promise<DeviceVerificationResponse>;
|
||||
putDeviceVerificationSettings: (
|
||||
request: DeviceVerificationRequest,
|
||||
) => Promise<DeviceVerificationResponse>;
|
||||
id: string,
|
||||
organizationUserId: string,
|
||||
): Promise<any>;
|
||||
|
||||
getCloudCommunicationsEnabled: () => Promise<boolean>;
|
||||
abstract getSync(): Promise<SyncResponse>;
|
||||
|
||||
abstract getSettingsDomains(): Promise<DomainsResponse>;
|
||||
abstract putSettingsDomains(request: UpdateDomainsRequest): Promise<DomainsResponse>;
|
||||
|
||||
abstract getCloudCommunicationsEnabled(): Promise<boolean>;
|
||||
abstract getOrganizationConnection<TConfig extends OrganizationConnectionConfigApis>(
|
||||
id: string,
|
||||
type: OrganizationConnectionType,
|
||||
@@ -340,136 +300,170 @@ export abstract class ApiService {
|
||||
configType: { new (response: any): TConfig },
|
||||
organizationConnectionId: string,
|
||||
): Promise<OrganizationConnectionResponse<TConfig>>;
|
||||
deleteOrganizationConnection: (id: string) => Promise<void>;
|
||||
getPlans: () => Promise<ListResponse<PlanResponse>>;
|
||||
abstract deleteOrganizationConnection(id: string): Promise<void>;
|
||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||
|
||||
getProviderUsers: (providerId: string) => Promise<ListResponse<ProviderUserUserDetailsResponse>>;
|
||||
getProviderUser: (providerId: string, id: string) => Promise<ProviderUserResponse>;
|
||||
postProviderUserInvite: (providerId: string, request: ProviderUserInviteRequest) => Promise<any>;
|
||||
postProviderUserReinvite: (providerId: string, id: string) => Promise<any>;
|
||||
postManyProviderUserReinvite: (
|
||||
abstract getProviderUsers(
|
||||
providerId: string,
|
||||
): Promise<ListResponse<ProviderUserUserDetailsResponse>>;
|
||||
abstract getProviderUser(providerId: string, id: string): Promise<ProviderUserResponse>;
|
||||
abstract postProviderUserInvite(
|
||||
providerId: string,
|
||||
request: ProviderUserInviteRequest,
|
||||
): Promise<any>;
|
||||
abstract postProviderUserReinvite(providerId: string, id: string): Promise<any>;
|
||||
abstract postManyProviderUserReinvite(
|
||||
providerId: string,
|
||||
request: ProviderUserBulkRequest,
|
||||
) => Promise<ListResponse<ProviderUserBulkResponse>>;
|
||||
postProviderUserAccept: (
|
||||
): Promise<ListResponse<ProviderUserBulkResponse>>;
|
||||
abstract postProviderUserAccept(
|
||||
providerId: string,
|
||||
id: string,
|
||||
request: ProviderUserAcceptRequest,
|
||||
) => Promise<any>;
|
||||
postProviderUserConfirm: (
|
||||
): Promise<any>;
|
||||
|
||||
abstract postProviderUserConfirm(
|
||||
providerId: string,
|
||||
id: string,
|
||||
request: ProviderUserConfirmRequest,
|
||||
) => Promise<any>;
|
||||
postProviderUsersPublicKey: (
|
||||
): Promise<any>;
|
||||
|
||||
abstract postProviderUsersPublicKey(
|
||||
providerId: string,
|
||||
request: ProviderUserBulkRequest,
|
||||
) => Promise<ListResponse<ProviderUserBulkPublicKeyResponse>>;
|
||||
postProviderUserBulkConfirm: (
|
||||
): Promise<ListResponse<ProviderUserBulkPublicKeyResponse>>;
|
||||
|
||||
abstract postProviderUserBulkConfirm(
|
||||
providerId: string,
|
||||
request: ProviderUserBulkConfirmRequest,
|
||||
) => Promise<ListResponse<ProviderUserBulkResponse>>;
|
||||
putProviderUser: (
|
||||
): Promise<ListResponse<ProviderUserBulkResponse>>;
|
||||
|
||||
abstract putProviderUser(
|
||||
providerId: string,
|
||||
id: string,
|
||||
request: ProviderUserUpdateRequest,
|
||||
) => Promise<any>;
|
||||
deleteProviderUser: (organizationId: string, id: string) => Promise<any>;
|
||||
deleteManyProviderUsers: (
|
||||
): Promise<any>;
|
||||
abstract deleteProviderUser(organizationId: string, id: string): Promise<any>;
|
||||
abstract deleteManyProviderUsers(
|
||||
providerId: string,
|
||||
request: ProviderUserBulkRequest,
|
||||
) => Promise<ListResponse<ProviderUserBulkResponse>>;
|
||||
getProviderClients: (
|
||||
): Promise<ListResponse<ProviderUserBulkResponse>>;
|
||||
abstract getProviderClients(
|
||||
providerId: string,
|
||||
) => Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>;
|
||||
postProviderAddOrganization: (
|
||||
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>;
|
||||
abstract postProviderAddOrganization(
|
||||
providerId: string,
|
||||
request: ProviderAddOrganizationRequest,
|
||||
) => Promise<any>;
|
||||
postProviderCreateOrganization: (
|
||||
): Promise<any>;
|
||||
abstract postProviderCreateOrganization(
|
||||
providerId: string,
|
||||
request: ProviderOrganizationCreateRequest,
|
||||
) => Promise<ProviderOrganizationResponse>;
|
||||
deleteProviderOrganization: (providerId: string, organizationId: string) => Promise<any>;
|
||||
): Promise<ProviderOrganizationResponse>;
|
||||
abstract deleteProviderOrganization(providerId: string, organizationId: string): Promise<any>;
|
||||
|
||||
getEvents: (start: string, end: string, token: string) => Promise<ListResponse<EventResponse>>;
|
||||
getEventsCipher: (
|
||||
abstract getEvents(
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsCipher(
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
) => Promise<ListResponse<EventResponse>>;
|
||||
getEventsOrganization: (
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
|
||||
abstract getEventsSecret(
|
||||
orgId: string,
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
) => Promise<ListResponse<EventResponse>>;
|
||||
getEventsOrganizationUser: (
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsServiceAccount(
|
||||
orgId: string,
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsProject(
|
||||
orgId: string,
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsOrganization(
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsOrganizationUser(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
) => Promise<ListResponse<EventResponse>>;
|
||||
getEventsProvider: (
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsProvider(
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
) => Promise<ListResponse<EventResponse>>;
|
||||
getEventsProviderUser: (
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
abstract getEventsProviderUser(
|
||||
providerId: string,
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
token: string,
|
||||
) => Promise<ListResponse<EventResponse>>;
|
||||
): Promise<ListResponse<EventResponse>>;
|
||||
|
||||
/**
|
||||
* Posts events for a user
|
||||
* @param request The array of events to upload
|
||||
* @param userId The optional user id the events belong to. If no user id is provided the active user id is used.
|
||||
*/
|
||||
postEventsCollect: (request: EventRequest[], userId?: UserId) => Promise<any>;
|
||||
abstract postEventsCollect(request: EventRequest[], userId?: UserId): Promise<any>;
|
||||
|
||||
deleteSsoUser: (organizationId: string) => Promise<void>;
|
||||
getSsoUserIdentifier: () => Promise<string>;
|
||||
abstract deleteSsoUser(organizationId: string): Promise<void>;
|
||||
abstract getSsoUserIdentifier(): Promise<string>;
|
||||
|
||||
getUserPublicKey: (id: string) => Promise<UserKeyResponse>;
|
||||
abstract getUserPublicKey(id: string): Promise<UserKeyResponse>;
|
||||
|
||||
getHibpBreach: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
|
||||
abstract postSetupPayment(): Promise<string>;
|
||||
|
||||
postBitPayInvoice: (request: BitPayInvoiceRequest) => Promise<string>;
|
||||
postSetupPayment: () => Promise<string>;
|
||||
abstract getActiveBearerToken(userId: UserId): Promise<string>;
|
||||
abstract fetch(request: Request): Promise<Response>;
|
||||
abstract nativeFetch(request: Request): Promise<Response>;
|
||||
|
||||
getActiveBearerToken: () => Promise<string>;
|
||||
fetch: (request: Request) => Promise<Response>;
|
||||
nativeFetch: (request: Request) => Promise<Response>;
|
||||
abstract preValidateSso(identifier: string): Promise<SsoPreValidateResponse>;
|
||||
|
||||
preValidateSso: (identifier: string) => Promise<SsoPreValidateResponse>;
|
||||
|
||||
postCreateSponsorship: (
|
||||
abstract postCreateSponsorship(
|
||||
sponsorshipOrgId: string,
|
||||
request: OrganizationSponsorshipCreateRequest,
|
||||
) => Promise<void>;
|
||||
getSponsorshipSyncStatus: (
|
||||
): Promise<void>;
|
||||
abstract getSponsorshipSyncStatus(
|
||||
sponsoredOrgId: string,
|
||||
) => Promise<OrganizationSponsorshipSyncStatusResponse>;
|
||||
deleteRemoveSponsorship: (sponsoringOrgId: string) => Promise<void>;
|
||||
postPreValidateSponsorshipToken: (
|
||||
): Promise<OrganizationSponsorshipSyncStatusResponse>;
|
||||
abstract deleteRemoveSponsorship(sponsoringOrgId: string): Promise<void>;
|
||||
abstract postPreValidateSponsorshipToken(
|
||||
sponsorshipToken: string,
|
||||
) => Promise<PreValidateSponsorshipResponse>;
|
||||
postRedeemSponsorship: (
|
||||
): Promise<PreValidateSponsorshipResponse>;
|
||||
abstract postRedeemSponsorship(
|
||||
sponsorshipToken: string,
|
||||
request: OrganizationSponsorshipRedeemRequest,
|
||||
) => Promise<void>;
|
||||
): Promise<void>;
|
||||
|
||||
getMasterKeyFromKeyConnector: (keyConnectorUrl: string) => Promise<KeyConnectorUserKeyResponse>;
|
||||
postUserKeyToKeyConnector: (
|
||||
abstract getMasterKeyFromKeyConnector(
|
||||
keyConnectorUrl: string,
|
||||
): Promise<KeyConnectorUserKeyResponse>;
|
||||
abstract postUserKeyToKeyConnector(
|
||||
keyConnectorUrl: string,
|
||||
request: KeyConnectorUserKeyRequest,
|
||||
) => Promise<void>;
|
||||
getKeyConnectorAlive: (keyConnectorUrl: string) => Promise<void>;
|
||||
getOrganizationExport: (organizationId: string) => Promise<OrganizationExportResponse>;
|
||||
): Promise<void>;
|
||||
abstract getKeyConnectorAlive(keyConnectorUrl: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BreachAccountResponse } from "../models/response/breach-account.response";
|
||||
import { BreachAccountResponse } from "../dirt/models/response/breach-account.response";
|
||||
|
||||
export abstract class AuditService {
|
||||
/**
|
||||
@@ -14,4 +14,10 @@ export abstract class AuditService {
|
||||
* @returns A promise that resolves to an array of BreachAccountResponse objects.
|
||||
*/
|
||||
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
/**
|
||||
* Checks if a domain is known for phishing.
|
||||
* @param domain The domain to check.
|
||||
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
|
||||
*/
|
||||
abstract getKnownPhishingDomains: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EventType } from "../../enums";
|
||||
import { CipherView } from "../../vault/models/view/cipher.view";
|
||||
|
||||
export abstract class EventCollectionService {
|
||||
collectMany: (
|
||||
abstract collectMany(
|
||||
eventType: EventType,
|
||||
ciphers: CipherView[],
|
||||
uploadImmediately?: boolean,
|
||||
) => Promise<any>;
|
||||
collect: (
|
||||
): Promise<any>;
|
||||
abstract collect(
|
||||
eventType: EventType,
|
||||
cipherId?: string,
|
||||
uploadImmediately?: boolean,
|
||||
organizationId?: string,
|
||||
) => Promise<any>;
|
||||
): Promise<any>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
export abstract class EventUploadService {
|
||||
uploadEvents: (userId?: UserId) => Promise<void>;
|
||||
abstract uploadEvents(userId?: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { OrganizationDomainRequest } from "../../services/organization-domain/requests/organization-domain.request";
|
||||
|
||||
@@ -8,19 +6,19 @@ import { OrganizationDomainResponse } from "./responses/organization-domain.resp
|
||||
import { VerifiedOrganizationDomainSsoDetailsResponse } from "./responses/verified-organization-domain-sso-details.response";
|
||||
|
||||
export abstract class OrgDomainApiServiceAbstraction {
|
||||
getAllByOrgId: (orgId: string) => Promise<Array<OrganizationDomainResponse>>;
|
||||
getByOrgIdAndOrgDomainId: (
|
||||
abstract getAllByOrgId(orgId: string): Promise<Array<OrganizationDomainResponse>>;
|
||||
abstract getByOrgIdAndOrgDomainId(
|
||||
orgId: string,
|
||||
orgDomainId: string,
|
||||
) => Promise<OrganizationDomainResponse>;
|
||||
post: (
|
||||
): Promise<OrganizationDomainResponse>;
|
||||
abstract post(
|
||||
orgId: string,
|
||||
orgDomain: OrganizationDomainRequest,
|
||||
) => Promise<OrganizationDomainResponse>;
|
||||
verify: (orgId: string, orgDomainId: string) => Promise<OrganizationDomainResponse>;
|
||||
delete: (orgId: string, orgDomainId: string) => Promise<any>;
|
||||
getClaimedOrgDomainByEmail: (email: string) => Promise<OrganizationDomainSsoDetailsResponse>;
|
||||
getVerifiedOrgDomainsByEmail: (
|
||||
): Promise<OrganizationDomainResponse>;
|
||||
abstract verify(orgId: string, orgDomainId: string): Promise<OrganizationDomainResponse>;
|
||||
abstract delete(orgId: string, orgDomainId: string): Promise<any>;
|
||||
abstract getClaimedOrgDomainByEmail(email: string): Promise<OrganizationDomainSsoDetailsResponse>;
|
||||
abstract getVerifiedOrgDomainsByEmail(
|
||||
email: string,
|
||||
) => Promise<ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>>;
|
||||
): Promise<ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>>;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationDomainResponse } from "./responses/organization-domain.response";
|
||||
|
||||
export abstract class OrgDomainServiceAbstraction {
|
||||
orgDomains$: Observable<OrganizationDomainResponse[]>;
|
||||
abstract orgDomains$: Observable<OrganizationDomainResponse[]>;
|
||||
|
||||
get: (orgDomainId: string) => OrganizationDomainResponse;
|
||||
abstract get(orgDomainId: string): OrganizationDomainResponse;
|
||||
|
||||
copyDnsTxt: (dnsTxt: string) => void;
|
||||
abstract copyDnsTxt(dnsTxt: string): void;
|
||||
}
|
||||
|
||||
// Note: this separate class is designed to hold methods that are not
|
||||
// meant to be used in components (e.g., data write methods)
|
||||
export abstract class OrgDomainInternalServiceAbstraction extends OrgDomainServiceAbstraction {
|
||||
upsert: (orgDomains: OrganizationDomainResponse[]) => void;
|
||||
replace: (orgDomains: OrganizationDomainResponse[]) => void;
|
||||
clearCache: () => void;
|
||||
delete: (orgDomainIds: string[]) => void;
|
||||
abstract upsert(orgDomains: OrganizationDomainResponse[]): void;
|
||||
abstract replace(orgDomains: OrganizationDomainResponse[]): void;
|
||||
abstract clearCache(): void;
|
||||
abstract delete(orgDomainIds: string[]): void;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { OrganizationApiKeyRequest } from "../../../admin-console/models/request/organization-api-key.request";
|
||||
import { OrganizationSsoRequest } from "../../../auth/models/request/organization-sso.request";
|
||||
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
|
||||
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
|
||||
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
|
||||
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
|
||||
import { PaymentRequest } from "../../../billing/models/request/payment.request";
|
||||
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
|
||||
import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response";
|
||||
import { BillingResponse } from "../../../billing/models/response/billing.response";
|
||||
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
|
||||
import { PaymentResponse } from "../../../billing/models/response/payment.response";
|
||||
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
|
||||
import { ImportDirectoryRequest } from "../../../models/request/import-directory.request";
|
||||
import { SeatRequest } from "../../../models/request/seat.request";
|
||||
import { StorageRequest } from "../../../models/request/storage.request";
|
||||
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { OrganizationApiKeyType } from "../../enums";
|
||||
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
|
||||
@@ -34,60 +28,62 @@ import { OrganizationKeysResponse } from "../../models/response/organization-key
|
||||
import { OrganizationResponse } from "../../models/response/organization.response";
|
||||
import { ProfileOrganizationResponse } from "../../models/response/profile-organization.response";
|
||||
|
||||
export class OrganizationApiServiceAbstraction {
|
||||
get: (id: string) => Promise<OrganizationResponse>;
|
||||
getBilling: (id: string) => Promise<BillingResponse>;
|
||||
getBillingHistory: (id: string) => Promise<BillingHistoryResponse>;
|
||||
getSubscription: (id: string) => Promise<OrganizationSubscriptionResponse>;
|
||||
getLicense: (id: string, installationId: string) => Promise<unknown>;
|
||||
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
|
||||
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
|
||||
createWithoutPayment: (
|
||||
export abstract class OrganizationApiServiceAbstraction {
|
||||
abstract get(id: string): Promise<OrganizationResponse>;
|
||||
abstract getBilling(id: string): Promise<BillingResponse>;
|
||||
abstract getBillingHistory(id: string): Promise<BillingHistoryResponse>;
|
||||
abstract getSubscription(id: string): Promise<OrganizationSubscriptionResponse>;
|
||||
abstract getLicense(id: string, installationId: string): Promise<unknown>;
|
||||
abstract getAutoEnrollStatus(identifier: string): Promise<OrganizationAutoEnrollStatusResponse>;
|
||||
abstract create(request: OrganizationCreateRequest): Promise<OrganizationResponse>;
|
||||
abstract createWithoutPayment(
|
||||
request: OrganizationNoPaymentMethodCreateRequest,
|
||||
) => Promise<OrganizationResponse>;
|
||||
createLicense: (data: FormData) => Promise<OrganizationResponse>;
|
||||
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
|
||||
updatePayment: (id: string, request: PaymentRequest) => Promise<void>;
|
||||
upgrade: (id: string, request: OrganizationUpgradeRequest) => Promise<PaymentResponse>;
|
||||
updatePasswordManagerSeats: (
|
||||
): Promise<OrganizationResponse>;
|
||||
abstract createLicense(data: FormData): Promise<OrganizationResponse>;
|
||||
abstract save(id: string, request: OrganizationUpdateRequest): Promise<OrganizationResponse>;
|
||||
abstract upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse>;
|
||||
abstract updatePasswordManagerSeats(
|
||||
id: string,
|
||||
request: OrganizationSubscriptionUpdateRequest,
|
||||
) => Promise<ProfileOrganizationResponse>;
|
||||
updateSecretsManagerSubscription: (
|
||||
): Promise<ProfileOrganizationResponse>;
|
||||
abstract updateSecretsManagerSubscription(
|
||||
id: string,
|
||||
request: OrganizationSmSubscriptionUpdateRequest,
|
||||
) => Promise<ProfileOrganizationResponse>;
|
||||
updateSeats: (id: string, request: SeatRequest) => Promise<PaymentResponse>;
|
||||
updateStorage: (id: string, request: StorageRequest) => Promise<PaymentResponse>;
|
||||
verifyBank: (id: string, request: VerifyBankRequest) => Promise<void>;
|
||||
reinstate: (id: string) => Promise<void>;
|
||||
leave: (id: string) => Promise<void>;
|
||||
delete: (id: string, request: SecretVerificationRequest) => Promise<void>;
|
||||
deleteUsingToken: (
|
||||
): Promise<ProfileOrganizationResponse>;
|
||||
abstract updateSeats(id: string, request: SeatRequest): Promise<PaymentResponse>;
|
||||
abstract updateStorage(id: string, request: StorageRequest): Promise<PaymentResponse>;
|
||||
abstract reinstate(id: string): Promise<void>;
|
||||
abstract leave(id: string): Promise<void>;
|
||||
abstract delete(id: string, request: SecretVerificationRequest): Promise<void>;
|
||||
abstract deleteUsingToken(
|
||||
organizationId: string,
|
||||
request: OrganizationVerifyDeleteRecoverRequest,
|
||||
) => Promise<any>;
|
||||
updateLicense: (id: string, data: FormData) => Promise<void>;
|
||||
importDirectory: (organizationId: string, request: ImportDirectoryRequest) => Promise<void>;
|
||||
getOrCreateApiKey: (id: string, request: OrganizationApiKeyRequest) => Promise<ApiKeyResponse>;
|
||||
getApiKeyInformation: (
|
||||
): Promise<any>;
|
||||
abstract updateLicense(id: string, data: FormData): Promise<void>;
|
||||
abstract importDirectory(organizationId: string, request: ImportDirectoryRequest): Promise<void>;
|
||||
abstract getOrCreateApiKey(
|
||||
id: string,
|
||||
request: OrganizationApiKeyRequest,
|
||||
): Promise<ApiKeyResponse>;
|
||||
abstract getApiKeyInformation(
|
||||
id: string,
|
||||
organizationApiKeyType?: OrganizationApiKeyType,
|
||||
) => Promise<ListResponse<OrganizationApiKeyInformationResponse>>;
|
||||
rotateApiKey: (id: string, request: OrganizationApiKeyRequest) => Promise<ApiKeyResponse>;
|
||||
getTaxInfo: (id: string) => Promise<TaxInfoResponse>;
|
||||
updateTaxInfo: (id: string, request: ExpandedTaxInfoUpdateRequest) => Promise<void>;
|
||||
getKeys: (id: string) => Promise<OrganizationKeysResponse>;
|
||||
updateKeys: (id: string, request: OrganizationKeysRequest) => Promise<OrganizationKeysResponse>;
|
||||
getSso: (id: string) => Promise<OrganizationSsoResponse>;
|
||||
updateSso: (id: string, request: OrganizationSsoRequest) => Promise<OrganizationSsoResponse>;
|
||||
selfHostedSyncLicense: (id: string) => Promise<void>;
|
||||
subscribeToSecretsManager: (
|
||||
): Promise<ListResponse<OrganizationApiKeyInformationResponse>>;
|
||||
abstract rotateApiKey(id: string, request: OrganizationApiKeyRequest): Promise<ApiKeyResponse>;
|
||||
abstract getKeys(id: string): Promise<OrganizationKeysResponse>;
|
||||
abstract updateKeys(
|
||||
id: string,
|
||||
request: OrganizationKeysRequest,
|
||||
): Promise<OrganizationKeysResponse>;
|
||||
abstract getSso(id: string): Promise<OrganizationSsoResponse>;
|
||||
abstract updateSso(id: string, request: OrganizationSsoRequest): Promise<OrganizationSsoResponse>;
|
||||
abstract selfHostedSyncLicense(id: string): Promise<void>;
|
||||
abstract subscribeToSecretsManager(
|
||||
id: string,
|
||||
request: SecretsManagerSubscribeRequest,
|
||||
) => Promise<ProfileOrganizationResponse>;
|
||||
updateCollectionManagement: (
|
||||
): Promise<ProfileOrganizationResponse>;
|
||||
abstract updateCollectionManagement(
|
||||
id: string,
|
||||
request: OrganizationCollectionManagementUpdateRequest,
|
||||
) => Promise<OrganizationResponse>;
|
||||
): Promise<OrganizationResponse>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -53,6 +51,9 @@ export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Please use the general `getById` custom rxjs operator instead.
|
||||
*/
|
||||
export function getOrganizationById(id: string) {
|
||||
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
|
||||
}
|
||||
@@ -68,20 +69,20 @@ export abstract class OrganizationService {
|
||||
* Publishes state for all organizations under the specified user.
|
||||
* @returns An observable list of organizations
|
||||
*/
|
||||
organizations$: (userId: UserId) => Observable<Organization[]>;
|
||||
abstract organizations$(userId: UserId): Observable<Organization[]>;
|
||||
|
||||
// @todo Clean these up. Continuing to expand them is not recommended.
|
||||
// @see https://bitwarden.atlassian.net/browse/AC-2252
|
||||
memberOrganizations$: (userId: UserId) => Observable<Organization[]>;
|
||||
abstract memberOrganizations$(userId: UserId): Observable<Organization[]>;
|
||||
/**
|
||||
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
|
||||
*/
|
||||
canManageSponsorships$: (userId: UserId) => Observable<boolean>;
|
||||
abstract canManageSponsorships$(userId: UserId): Observable<boolean>;
|
||||
/**
|
||||
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
|
||||
*/
|
||||
familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>;
|
||||
hasOrganizations: (userId: UserId) => Observable<boolean>;
|
||||
abstract familySponsorshipAvailable$(userId: UserId): Observable<boolean>;
|
||||
abstract hasOrganizations(userId: UserId): Observable<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +97,7 @@ export abstract class InternalOrganizationServiceAbstraction extends Organizatio
|
||||
* @param organization The organization state being saved.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
|
||||
abstract upsert(OrganizationData: OrganizationData, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Replaces state for the entire registered organization list for the specified user.
|
||||
@@ -107,5 +108,8 @@ export abstract class InternalOrganizationServiceAbstraction extends Organizatio
|
||||
* user.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
|
||||
abstract replace(
|
||||
organizations: { [id: string]: OrganizationData },
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -24,4 +24,5 @@ export abstract class PolicyApiServiceAbstraction {
|
||||
type: PolicyType,
|
||||
request: PolicyRequest,
|
||||
) => Promise<any>;
|
||||
abstract putPolicyVNext: (organizationId: string, type: PolicyType, request: any) => Promise<any>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
@@ -7,8 +5,7 @@ import { ProviderData } from "../models/data/provider.data";
|
||||
import { Provider } from "../models/domain/provider";
|
||||
|
||||
export abstract class ProviderService {
|
||||
get$: (id: string) => Observable<Provider>;
|
||||
get: (id: string) => Promise<Provider>;
|
||||
getAll: () => Promise<Provider[]>;
|
||||
save: (providers: { [id: string]: ProviderData }, userId?: UserId) => Promise<any>;
|
||||
abstract providers$(userId: UserId): Observable<Provider[]>;
|
||||
abstract get$(id: string, userId: UserId): Observable<Provider | undefined>;
|
||||
abstract save(providers: { [id: string]: ProviderData }, userId: UserId): Promise<any>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
|
||||
|
||||
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
|
||||
@@ -7,21 +5,23 @@ import { ProviderUpdateRequest } from "../../models/request/provider/provider-up
|
||||
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
|
||||
import { ProviderResponse } from "../../models/response/provider/provider.response";
|
||||
|
||||
export class ProviderApiServiceAbstraction {
|
||||
postProviderSetup: (id: string, request: ProviderSetupRequest) => Promise<ProviderResponse>;
|
||||
getProvider: (id: string) => Promise<ProviderResponse>;
|
||||
putProvider: (id: string, request: ProviderUpdateRequest) => Promise<ProviderResponse>;
|
||||
providerRecoverDeleteToken: (
|
||||
export abstract class ProviderApiServiceAbstraction {
|
||||
abstract postProviderSetup(id: string, request: ProviderSetupRequest): Promise<ProviderResponse>;
|
||||
abstract getProvider(id: string): Promise<ProviderResponse>;
|
||||
abstract putProvider(id: string, request: ProviderUpdateRequest): Promise<ProviderResponse>;
|
||||
abstract providerRecoverDeleteToken(
|
||||
organizationId: string,
|
||||
request: ProviderVerifyRecoverDeleteRequest,
|
||||
) => Promise<any>;
|
||||
deleteProvider: (id: string) => Promise<void>;
|
||||
getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>;
|
||||
addOrganizationToProvider: (
|
||||
): Promise<any>;
|
||||
abstract deleteProvider(id: string): Promise<void>;
|
||||
abstract getProviderAddableOrganizations(
|
||||
providerId: string,
|
||||
): Promise<AddableOrganizationResponse[]>;
|
||||
abstract addOrganizationToProvider(
|
||||
providerId: string,
|
||||
request: {
|
||||
key: string;
|
||||
organizationId: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,5 @@ export enum PolicyType {
|
||||
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
|
||||
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
|
||||
RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
|
||||
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ describe("ORGANIZATIONS state", () => {
|
||||
useOrganizationDomains: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
ssoMemberDecryptionType: undefined,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { MemberDecryptionType } from "../../../auth/enums/sso";
|
||||
import { ProductTierType } from "../../../billing/enums";
|
||||
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
|
||||
import { PermissionsApi } from "../api/permissions.api";
|
||||
@@ -63,6 +64,8 @@ export class OrganizationData {
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -128,6 +131,8 @@ export class OrganizationData {
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { OrgKey, UserPrivateKey } from "../../../types/key";
|
||||
import { EncryptedOrganizationKeyData } from "../data/encrypted-organization-key.data";
|
||||
|
||||
160
libs/common/src/admin-console/models/domain/organization.spec.ts
Normal file
160
libs/common/src/admin-console/models/domain/organization.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { MemberDecryptionType } from "../../../auth/enums/sso";
|
||||
import { ProductTierType } from "../../../billing/enums";
|
||||
import { OrganizationUserStatusType, OrganizationUserType } from "../../enums";
|
||||
import { PermissionsApi } from "../api/permissions.api";
|
||||
import { OrganizationData } from "../data/organization.data";
|
||||
|
||||
import { Organization } from "./organization";
|
||||
|
||||
describe("Organization", () => {
|
||||
let data: OrganizationData;
|
||||
|
||||
beforeEach(() => {
|
||||
data = {
|
||||
id: "test-org-id",
|
||||
name: "Test Organization",
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
type: OrganizationUserType.Admin,
|
||||
enabled: true,
|
||||
usePolicies: true,
|
||||
useGroups: true,
|
||||
useDirectory: true,
|
||||
useEvents: true,
|
||||
useTotp: true,
|
||||
use2fa: true,
|
||||
useApi: true,
|
||||
useSso: true,
|
||||
useOrganizationDomains: true,
|
||||
useKeyConnector: false,
|
||||
useScim: true,
|
||||
useCustomPermissions: false,
|
||||
useResetPassword: true,
|
||||
useSecretsManager: true,
|
||||
usePasswordManager: true,
|
||||
useActivateAutofillPolicy: false,
|
||||
selfHost: false,
|
||||
usersGetPremium: false,
|
||||
seats: 10,
|
||||
maxCollections: 100,
|
||||
maxStorageGb: 1,
|
||||
ssoBound: false,
|
||||
identifier: "test-identifier",
|
||||
permissions: new PermissionsApi({
|
||||
accessEventLogs: false,
|
||||
accessImportExport: false,
|
||||
accessReports: false,
|
||||
createNewCollections: false,
|
||||
editAnyCollection: false,
|
||||
deleteAnyCollection: false,
|
||||
editAssignedCollections: false,
|
||||
deleteAssignedCollections: false,
|
||||
manageCiphers: false,
|
||||
manageGroups: false,
|
||||
managePolicies: false,
|
||||
manageSso: false,
|
||||
manageUsers: false,
|
||||
manageResetPassword: false,
|
||||
manageScim: false,
|
||||
}),
|
||||
resetPasswordEnrolled: false,
|
||||
userId: "user-id",
|
||||
organizationUserId: "org-user-id",
|
||||
hasPublicAndPrivateKeys: false,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerType: null,
|
||||
isProviderUser: false,
|
||||
isMember: true,
|
||||
familySponsorshipFriendlyName: null,
|
||||
familySponsorshipAvailable: false,
|
||||
productTierType: ProductTierType.Enterprise,
|
||||
keyConnectorEnabled: false,
|
||||
keyConnectorUrl: null,
|
||||
familySponsorshipLastSyncDate: null,
|
||||
familySponsorshipValidUntil: null,
|
||||
familySponsorshipToDelete: null,
|
||||
accessSecretsManager: false,
|
||||
limitCollectionCreation: false,
|
||||
limitCollectionDeletion: false,
|
||||
limitItemDeletion: false,
|
||||
allowAdminAccessToAllCollectionItems: true,
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
ssoMemberDecryptionType: MemberDecryptionType.MasterPassword,
|
||||
} as OrganizationData;
|
||||
});
|
||||
|
||||
describe("canManageDeviceApprovals", () => {
|
||||
it("should return false when user is not admin and has no manageResetPassword permission", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.useSso = true;
|
||||
data.ssoEnabled = true;
|
||||
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
data.permissions.manageResetPassword = false;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canManageDeviceApprovals).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when useSso is false", () => {
|
||||
data.type = OrganizationUserType.Admin;
|
||||
data.useSso = false;
|
||||
data.ssoEnabled = true;
|
||||
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canManageDeviceApprovals).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when admin has all required SSO settings enabled", () => {
|
||||
data.type = OrganizationUserType.Admin;
|
||||
data.useSso = true;
|
||||
data.ssoEnabled = true;
|
||||
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canManageDeviceApprovals).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when owner has all required SSO settings enabled", () => {
|
||||
data.type = OrganizationUserType.Owner;
|
||||
data.useSso = true;
|
||||
data.ssoEnabled = true;
|
||||
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canManageDeviceApprovals).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user has manageResetPassword permission and all SSO settings enabled", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.useSso = true;
|
||||
data.ssoEnabled = true;
|
||||
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
data.permissions.manageResetPassword = true;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canManageDeviceApprovals).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when provider user has all required SSO settings enabled", () => {
|
||||
data.type = OrganizationUserType.User;
|
||||
data.isProviderUser = true;
|
||||
data.useSso = true;
|
||||
data.ssoEnabled = true;
|
||||
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
|
||||
const organization = new Organization(data);
|
||||
|
||||
expect(organization.canManageDeviceApprovals).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,15 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { MemberDecryptionType } from "../../../auth/enums/sso";
|
||||
import { ProductTierType } from "../../../billing/enums";
|
||||
import { OrganizationId } from "../../../types/guid";
|
||||
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
|
||||
import { PermissionsApi } from "../api/permissions.api";
|
||||
import { OrganizationData } from "../data/organization.data";
|
||||
|
||||
export class Organization {
|
||||
id: string;
|
||||
id: OrganizationId;
|
||||
name: string;
|
||||
status: OrganizationUserStatusType;
|
||||
|
||||
@@ -93,13 +95,15 @@ export class Organization {
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.id = obj.id;
|
||||
this.id = obj.id as OrganizationId;
|
||||
this.name = obj.name;
|
||||
this.status = obj.status;
|
||||
this.type = obj.type;
|
||||
@@ -154,6 +158,8 @@ export class Organization {
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import Domain from "../../../platform/models/domain/domain-base";
|
||||
import { PolicyId } from "../../../types/guid";
|
||||
import { OrganizationId, PolicyId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../data/policy.data";
|
||||
import { PolicyResponse } from "../response/policy.response";
|
||||
|
||||
export class Policy extends Domain {
|
||||
id: PolicyId;
|
||||
organizationId: string;
|
||||
organizationId: OrganizationId;
|
||||
type: PolicyType;
|
||||
data: any;
|
||||
|
||||
@@ -26,7 +26,7 @@ export class Policy extends Domain {
|
||||
}
|
||||
|
||||
this.id = obj.id;
|
||||
this.organizationId = obj.organizationId;
|
||||
this.organizationId = obj.organizationId as OrganizationId;
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
@@ -36,7 +36,7 @@ export class Policy extends Domain {
|
||||
return new Policy(new PolicyData(response));
|
||||
}
|
||||
|
||||
static fromListResponse(response: ListResponse<PolicyResponse>): Policy[] | undefined {
|
||||
return response.data?.map((d) => Policy.fromResponse(d)) ?? undefined;
|
||||
static fromListResponse(response: ListResponse<PolicyResponse>): Policy[] {
|
||||
return response.data.map((d) => Policy.fromResponse(d));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { PolicyType } from "../../enums";
|
||||
|
||||
export class PolicyRequest {
|
||||
export type PolicyRequest = {
|
||||
type: PolicyType;
|
||||
enabled: boolean;
|
||||
data: any;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request";
|
||||
interface TokenizedPaymentMethod {
|
||||
type: "bankAccount" | "card" | "payPal";
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface BillingAddress {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
line1: string | null;
|
||||
line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
taxId: { code: string; value: string } | null;
|
||||
}
|
||||
|
||||
export class ProviderSetupRequest {
|
||||
name: string;
|
||||
@@ -9,6 +21,6 @@ export class ProviderSetupRequest {
|
||||
billingEmail: string;
|
||||
token: string;
|
||||
key: string;
|
||||
taxInfo: ExpandedTaxInfoUpdateRequest;
|
||||
paymentSource?: TokenizedPaymentSourceRequest;
|
||||
paymentMethod: TokenizedPaymentMethod;
|
||||
billingAddress: BillingAddress;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemberDecryptionType } from "../../../auth/enums/sso";
|
||||
import { ProductTierType } from "../../../billing/enums";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
|
||||
@@ -58,6 +59,8 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -127,5 +130,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio
|
||||
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
|
||||
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
|
||||
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
|
||||
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
|
||||
import { PaymentRequest } from "../../../billing/models/request/payment.request";
|
||||
import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request";
|
||||
import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response";
|
||||
import { BillingResponse } from "../../../billing/models/response/billing.response";
|
||||
import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response";
|
||||
import { PaymentResponse } from "../../../billing/models/response/payment.response";
|
||||
import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response";
|
||||
import { ImportDirectoryRequest } from "../../../models/request/import-directory.request";
|
||||
import { SeatRequest } from "../../../models/request/seat.request";
|
||||
import { StorageRequest } from "../../../models/request/storage.request";
|
||||
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
|
||||
@@ -143,10 +139,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
return data;
|
||||
}
|
||||
|
||||
async updatePayment(id: string, request: PaymentRequest): Promise<void> {
|
||||
return this.apiService.send("POST", "/organizations/" + id + "/payment", request, true, false);
|
||||
}
|
||||
|
||||
async upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
@@ -208,16 +200,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
return new PaymentResponse(r);
|
||||
}
|
||||
|
||||
async verifyBank(id: string, request: VerifyBankRequest): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + id + "/verify-bank",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async reinstate(id: string): Promise<void> {
|
||||
return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false);
|
||||
}
|
||||
@@ -299,16 +281,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
return new ApiKeyResponse(r);
|
||||
}
|
||||
|
||||
async getTaxInfo(id: string): Promise<TaxInfoResponse> {
|
||||
const r = await this.apiService.send("GET", "/organizations/" + id + "/tax", null, true, true);
|
||||
return new TaxInfoResponse(r);
|
||||
}
|
||||
|
||||
async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise<void> {
|
||||
// Can't broadcast anything because the response doesn't have content
|
||||
return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false);
|
||||
}
|
||||
|
||||
async getKeys(id: string): Promise<OrganizationKeysResponse> {
|
||||
const r = await this.apiService.send("GET", "/organizations/" + id + "/keys", null, true, true);
|
||||
return new OrganizationKeysResponse(r);
|
||||
|
||||
@@ -490,6 +490,26 @@ describe("PolicyService", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
PolicyType.PasswordGenerator,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy,
|
||||
PolicyType.RestrictedItemTypes,
|
||||
PolicyType.RemoveUnlockWithPin,
|
||||
])("returns true and owners are not exempt from policy %s", async (policyType) => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org2", PolicyType.PasswordGenerator, true),
|
||||
policyData("policy2", "org2", PolicyType.FreeFamiliesSponsorshipPolicy, true),
|
||||
policyData("policy3", "org2", PolicyType.RestrictedItemTypes, true),
|
||||
policyData("policy4", "org2", PolicyType.RemoveUnlockWithPin, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.policyAppliesToUser$(policyType, userId));
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when policyType is disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
|
||||
|
||||
import { POLICIES } from "./policy-state";
|
||||
|
||||
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
|
||||
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }): Policy[] {
|
||||
return Object.values(policiesMap || {}).map((f) => new Policy(f));
|
||||
}
|
||||
|
||||
@@ -89,8 +89,7 @@ export class DefaultPolicyService implements PolicyService {
|
||||
const policies$ = policies ? of(policies) : this.policies$(userId);
|
||||
return policies$.pipe(
|
||||
map((obsPolicies) => {
|
||||
// TODO: replace with this.combinePoliciesIntoMasterPasswordPolicyOptions(obsPolicies)) once
|
||||
// FeatureFlag.PM16117_ChangeExistingPasswordRefactor is removed.
|
||||
// TODO ([PM-23777]): replace with this.combinePoliciesIntoMasterPasswordPolicyOptions(obsPolicies))
|
||||
let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined;
|
||||
const filteredPolicies =
|
||||
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
|
||||
@@ -281,6 +280,9 @@ export class DefaultPolicyService implements PolicyService {
|
||||
case PolicyType.RestrictedItemTypes:
|
||||
// restricted item types policy
|
||||
return false;
|
||||
case PolicyType.RemoveUnlockWithPin:
|
||||
// Remove Unlock with PIN policy
|
||||
return false;
|
||||
case PolicyType.OrganizationDataOwnership:
|
||||
// organization data ownership policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
|
||||
@@ -116,16 +116,32 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async putPolicy(organizationId: string, type: PolicyType, request: PolicyRequest): Promise<any> {
|
||||
const r = await this.apiService.send(
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
"/organizations/" + organizationId + "/policies/" + type,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
await this.handleResponse(response);
|
||||
}
|
||||
|
||||
async putPolicyVNext(organizationId: string, type: PolicyType, request: any): Promise<any> {
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
`/organizations/${organizationId}/policies/${type}/vnext`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
await this.handleResponse(response);
|
||||
}
|
||||
|
||||
private async handleResponse(response: any): Promise<void> {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const response = new PolicyResponse(r);
|
||||
const data = new PolicyData(response);
|
||||
const policyResponse = new PolicyResponse(response);
|
||||
const data = new PolicyData(policyResponse);
|
||||
await this.policyService.upsert(data, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
import {
|
||||
@@ -20,11 +20,11 @@ import { PROVIDERS, ProviderService } from "./provider.service";
|
||||
* in state. This helper methods lets us build provider arrays in tests
|
||||
* and easily map them to records before storing them in state.
|
||||
*/
|
||||
function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
|
||||
if (input == null) {
|
||||
return undefined;
|
||||
function arrayToRecord(input: ProviderData[] | undefined): Record<string, ProviderData> | null {
|
||||
if (input == null || input.length < 1) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(input?.map((i) => [i.id, i]));
|
||||
return Object.fromEntries(input.map((i) => [i.id, i]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,7 @@ function arrayToRecord(input: ProviderData[]): Record<string, ProviderData> {
|
||||
*/
|
||||
function buildMockProviders(count = 1, suffix?: string): ProviderData[] {
|
||||
if (count < 1) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildMockProvider(id: string, name: string): ProviderData {
|
||||
@@ -87,30 +87,28 @@ describe("ProviderService", () => {
|
||||
let fakeAccountService: FakeAccountService;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
let fakeUserState: FakeSingleUserState<Record<string, ProviderData>>;
|
||||
let fakeActiveUserState: FakeActiveUserState<Record<string, ProviderData>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeAccountService = mockAccountServiceWith(fakeUserId);
|
||||
fakeStateProvider = new FakeStateProvider(fakeAccountService);
|
||||
fakeUserState = fakeStateProvider.singleUser.getFake(fakeUserId, PROVIDERS);
|
||||
fakeActiveUserState = fakeStateProvider.activeUser.getFake(PROVIDERS);
|
||||
|
||||
providerService = new ProviderService(fakeStateProvider);
|
||||
});
|
||||
|
||||
describe("getAll()", () => {
|
||||
describe("providers$()", () => {
|
||||
it("Returns an array of all providers stored in state", async () => {
|
||||
const mockData: ProviderData[] = buildMockProviders(5);
|
||||
const mockData = buildMockProviders(5);
|
||||
fakeUserState.nextState(arrayToRecord(mockData));
|
||||
const providers = await providerService.getAll();
|
||||
const providers = await firstValueFrom(providerService.providers$(fakeUserId));
|
||||
expect(providers).toHaveLength(5);
|
||||
expect(providers).toEqual(mockData.map((x) => new Provider(x)));
|
||||
});
|
||||
|
||||
it("Returns an empty array if no providers are found in state", async () => {
|
||||
const mockData: ProviderData[] = undefined;
|
||||
let mockData;
|
||||
fakeUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await providerService.getAll();
|
||||
const result = await firstValueFrom(providerService.providers$(fakeUserId));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -119,50 +117,38 @@ describe("ProviderService", () => {
|
||||
it("Returns an observable of a single provider from state that matches the specified id", async () => {
|
||||
const mockData = buildMockProviders(5);
|
||||
fakeUserState.nextState(arrayToRecord(mockData));
|
||||
const result = providerService.get$(mockData[3].id);
|
||||
const result = providerService.get$(mockData[3].id, fakeUserId);
|
||||
const provider = await firstValueFrom(result);
|
||||
expect(provider).toEqual(new Provider(mockData[3]));
|
||||
});
|
||||
|
||||
it("Returns an observable of undefined if the specified provider is not found", async () => {
|
||||
const result = providerService.get$("this-provider-does-not-exist");
|
||||
const result = providerService.get$("this-provider-does-not-exist", fakeUserId);
|
||||
const provider = await firstValueFrom(result);
|
||||
expect(provider).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get()", () => {
|
||||
it("Returns a single provider from state that matches the specified id", async () => {
|
||||
const mockData = buildMockProviders(5);
|
||||
fakeUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await providerService.get(mockData[3].id);
|
||||
expect(result).toEqual(new Provider(mockData[3]));
|
||||
});
|
||||
|
||||
it("Returns undefined if the specified provider id is not found", async () => {
|
||||
const result = await providerService.get("this-provider-does-not-exist");
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save()", () => {
|
||||
it("replaces the entire provider list in state for the active user", async () => {
|
||||
it("replaces the entire provider list in state for the specified user", async () => {
|
||||
const originalData = buildMockProviders(10);
|
||||
fakeUserState.nextState(arrayToRecord(originalData));
|
||||
|
||||
const newData = arrayToRecord(buildMockProviders(10, "newData"));
|
||||
await providerService.save(newData);
|
||||
if (newData) {
|
||||
await providerService.save(newData, fakeUserId);
|
||||
}
|
||||
|
||||
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, newData]);
|
||||
expect(fakeUserState.nextMock).toHaveBeenCalledWith(newData);
|
||||
});
|
||||
|
||||
// This is more or less a test for logouts
|
||||
it("can replace state with null", async () => {
|
||||
const originalData = buildMockProviders(2);
|
||||
fakeActiveUserState.nextState(arrayToRecord(originalData));
|
||||
await providerService.save(null);
|
||||
fakeUserState.nextState(arrayToRecord(originalData));
|
||||
await providerService.save(null, fakeUserId);
|
||||
|
||||
expect(fakeActiveUserState.nextMock).toHaveBeenCalledWith([fakeUserId, null]);
|
||||
expect(fakeUserState.nextMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable, of, switchMap, take } from "rxjs";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { getById } from "../../platform/misc";
|
||||
import { PROVIDERS_DISK, StateProvider, UserKeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service";
|
||||
@@ -13,46 +12,26 @@ export const PROVIDERS = UserKeyDefinition.record<ProviderData>(PROVIDERS_DISK,
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
function mapToSingleProvider(providerId: string) {
|
||||
return map<Provider[], Provider>((providers) => providers?.find((p) => p.id === providerId));
|
||||
}
|
||||
|
||||
export class ProviderService implements ProviderServiceAbstraction {
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
private providers$(userId?: UserId): Observable<Provider[] | undefined> {
|
||||
// FIXME: Can be replaced with `getUserStateOrDefault$` if we weren't trying to pick this.
|
||||
return (
|
||||
userId != null
|
||||
? this.stateProvider.getUser(userId, PROVIDERS).state$
|
||||
: this.stateProvider.activeUserId$.pipe(
|
||||
take(1),
|
||||
switchMap((userId) =>
|
||||
userId != null ? this.stateProvider.getUser(userId, PROVIDERS).state$ : of(null),
|
||||
),
|
||||
)
|
||||
).pipe(this.mapProviderRecordToArray());
|
||||
providers$(userId: UserId): Observable<Provider[]> {
|
||||
return this.stateProvider
|
||||
.getUser(userId, PROVIDERS)
|
||||
.state$.pipe(this.mapProviderRecordToArray());
|
||||
}
|
||||
|
||||
private mapProviderRecordToArray() {
|
||||
return map<Record<string, ProviderData>, Provider[]>((providers) =>
|
||||
Object.values(providers ?? {})?.map((o) => new Provider(o)),
|
||||
return map<Record<string, ProviderData> | null, Provider[]>((providers) =>
|
||||
Object.values(providers ?? {}).map((o) => new Provider(o)),
|
||||
);
|
||||
}
|
||||
|
||||
get$(id: string): Observable<Provider> {
|
||||
return this.providers$().pipe(mapToSingleProvider(id));
|
||||
get$(id: string, userId: UserId): Observable<Provider | undefined> {
|
||||
return this.providers$(userId).pipe(getById(id));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Provider> {
|
||||
return await firstValueFrom(this.providers$().pipe(mapToSingleProvider(id)));
|
||||
}
|
||||
|
||||
async getAll(): Promise<Provider[]> {
|
||||
return await firstValueFrom(this.providers$());
|
||||
}
|
||||
|
||||
async save(providers: { [id: string]: ProviderData }, userId?: UserId) {
|
||||
async save(providers: { [id: string]: ProviderData }, userId: UserId) {
|
||||
await this.stateProvider.setUserState(PROVIDERS, providers, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
@@ -35,20 +33,20 @@ export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||
}
|
||||
|
||||
export abstract class AccountService {
|
||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
abstract accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
|
||||
activeAccount$: Observable<Account | null>;
|
||||
abstract activeAccount$: Observable<Account | null>;
|
||||
|
||||
/**
|
||||
* Observable of the last activity time for each account.
|
||||
*/
|
||||
accountActivity$: Observable<Record<UserId, Date>>;
|
||||
abstract accountActivity$: Observable<Record<UserId, Date>>;
|
||||
/** Observable of the new device login verification property for the account. */
|
||||
accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
abstract accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
/** Account list in order of descending recency */
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
abstract sortedUserIds$: Observable<UserId[]>;
|
||||
/** Next account that is not the current active account */
|
||||
nextUpAccount$: Observable<Account>;
|
||||
abstract nextUpAccount$: Observable<Account>;
|
||||
/**
|
||||
* Updates the `accounts$` observable with the new account data.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
export abstract class AnonymousHubService {
|
||||
createHubConnection: (token: string) => Promise<void>;
|
||||
stopHubConnection: () => Promise<void>;
|
||||
abstract createHubConnection(token: string): Promise<void>;
|
||||
abstract stopHubConnection(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Auth Request Answering Service
|
||||
|
||||
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
|
||||
be acted on when the user loads up a client. Currently only implemented with the browser client.
|
||||
|
||||
See diagram for the high level picture of how this is wired up.
|
||||
|
||||
## Diagram
|
||||
|
||||

|
||||
@@ -0,0 +1,30 @@
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AuthRequestAnsweringServiceAbstraction {
|
||||
/**
|
||||
* Tries to either display the dialog for the user or will preserve its data and show it at a
|
||||
* later time. Even in the event the dialog is shown immediately, this will write to global state
|
||||
* so that even if someone closes a window or a popup and comes back, it could be processed later.
|
||||
* Only way to clear out the global state is to respond to the auth request.
|
||||
*
|
||||
* Currently, this is only implemented for browser extension.
|
||||
*
|
||||
* @param userId The UserId that the auth request is for.
|
||||
* @param authRequestId The id of the auth request that is to be processed.
|
||||
*/
|
||||
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this function is used to process that event.
|
||||
*
|
||||
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
|
||||
*/
|
||||
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
abstract processPendingAuthRequests(): Promise<void>;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 670 KiB |
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
@@ -9,7 +7,7 @@ export abstract class AvatarService {
|
||||
* An observable monitoring the active user's avatar color.
|
||||
* The observable updates when the avatar color changes.
|
||||
*/
|
||||
avatarColor$: Observable<string | null>;
|
||||
abstract avatarColor$: Observable<string | null>;
|
||||
/**
|
||||
* Sets the avatar color of the active user
|
||||
*
|
||||
|
||||
@@ -1,47 +1,45 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
export abstract class DevicesApiServiceAbstraction {
|
||||
getKnownDevice: (email: string, deviceIdentifier: string) => Promise<boolean>;
|
||||
abstract getKnownDevice(email: string, deviceIdentifier: string): Promise<boolean>;
|
||||
|
||||
getDeviceByIdentifier: (deviceIdentifier: string) => Promise<DeviceResponse>;
|
||||
abstract getDeviceByIdentifier(deviceIdentifier: string): Promise<DeviceResponse>;
|
||||
|
||||
getDevices: () => Promise<ListResponse<DeviceResponse>>;
|
||||
abstract getDevices(): Promise<ListResponse<DeviceResponse>>;
|
||||
|
||||
updateTrustedDeviceKeys: (
|
||||
abstract updateTrustedDeviceKeys(
|
||||
deviceIdentifier: string,
|
||||
devicePublicKeyEncryptedUserKey: string,
|
||||
userKeyEncryptedDevicePublicKey: string,
|
||||
deviceKeyEncryptedDevicePrivateKey: string,
|
||||
) => Promise<DeviceResponse>;
|
||||
): Promise<DeviceResponse>;
|
||||
|
||||
updateTrust: (
|
||||
abstract updateTrust(
|
||||
updateDevicesTrustRequestModel: UpdateDevicesTrustRequest,
|
||||
deviceIdentifier: string,
|
||||
) => Promise<void>;
|
||||
): Promise<void>;
|
||||
|
||||
getDeviceKeys: (deviceIdentifier: string) => Promise<ProtectedDeviceResponse>;
|
||||
abstract getDeviceKeys(deviceIdentifier: string): Promise<ProtectedDeviceResponse>;
|
||||
|
||||
/**
|
||||
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.
|
||||
* Note: For debugging purposes only.
|
||||
* @param deviceIdentifier - current device identifier
|
||||
*/
|
||||
postDeviceTrustLoss: (deviceIdentifier: string) => Promise<void>;
|
||||
abstract postDeviceTrustLoss(deviceIdentifier: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deactivates a device
|
||||
* @param deviceId - The device ID
|
||||
*/
|
||||
deactivateDevice: (deviceId: string) => Promise<void>;
|
||||
abstract deactivateDevice(deviceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Removes trust from a list of devices
|
||||
* @param deviceIds - The device IDs to be untrusted
|
||||
*/
|
||||
untrustDevices: (deviceIds: string[]) => Promise<void>;
|
||||
abstract untrustDevices(deviceIds: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { DeviceResponse } from "./responses/device.response";
|
||||
import { DeviceView } from "./views/device.view";
|
||||
|
||||
@@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction {
|
||||
): Observable<DeviceView>;
|
||||
abstract deactivateDevice$(deviceId: string): Observable<void>;
|
||||
abstract getCurrentDevice$(): Observable<DeviceResponse>;
|
||||
abstract getReadableDeviceTypeName(deviceType: DeviceType): string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export abstract class SsoLoginServiceAbstraction {
|
||||
@@ -70,6 +72,10 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
*
|
||||
*/
|
||||
abstract setSsoEmail: (email: string) => Promise<void>;
|
||||
/**
|
||||
* Clear the SSO email
|
||||
*/
|
||||
abstract clearSsoEmail: () => Promise<void>;
|
||||
/**
|
||||
* Gets the value of the active user's organization sso identifier.
|
||||
*
|
||||
@@ -86,4 +92,24 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
organizationIdentifier: string,
|
||||
userId: UserId | undefined,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* A cache list of user emails for whom the `PolicyType.RequireSso` policy is applied (that is, a list
|
||||
* of users who are required to authenticate via SSO only). The cache lives on the current device only.
|
||||
*/
|
||||
abstract ssoRequiredCache$: Observable<Set<string> | null>;
|
||||
|
||||
/**
|
||||
* Remove an email from the cached list of emails that must authenticate via SSO.
|
||||
*/
|
||||
abstract removeFromSsoRequiredCacheIfPresent: (email: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if the user is required to authenticate via SSO. If so, add their email to a cache list.
|
||||
* We'll use this cache list to display ONLY the "Use single sign-on" button to the
|
||||
* user the next time they are on the /login page.
|
||||
*
|
||||
* If the user is not required to authenticate via SSO, remove their email from the cache list if it is present.
|
||||
*/
|
||||
abstract updateSsoRequiredCache: (ssoLoginEmail: string, userId: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { VaultTimeout, VaultTimeoutAction } from "../../key-management/vault-timeout";
|
||||
@@ -27,20 +25,20 @@ export abstract class TokenService {
|
||||
*
|
||||
* @returns A promise that resolves with the SetTokensResult containing the tokens that were set.
|
||||
*/
|
||||
setTokens: (
|
||||
abstract setTokens(
|
||||
accessToken: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: VaultTimeout,
|
||||
refreshToken?: string,
|
||||
clientIdClientSecret?: [string, string],
|
||||
) => Promise<SetTokensResult>;
|
||||
): Promise<SetTokensResult>;
|
||||
|
||||
/**
|
||||
* Clears the access token, refresh token, API Key Client ID, and API Key Client Secret out of memory, disk, and secure storage if supported.
|
||||
* @param userId The optional user id to clear the tokens for; if not provided, the active user id is used.
|
||||
* @returns A promise that resolves when the tokens have been cleared.
|
||||
*/
|
||||
clearTokens: (userId?: UserId) => Promise<void>;
|
||||
abstract clearTokens(userId?: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the access token in memory or disk based on the given vaultTimeoutAction and vaultTimeout
|
||||
@@ -51,11 +49,11 @@ export abstract class TokenService {
|
||||
* @param vaultTimeout The timeout for the vault.
|
||||
* @returns A promise that resolves with the access token that has been set.
|
||||
*/
|
||||
setAccessToken: (
|
||||
abstract setAccessToken(
|
||||
accessToken: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: VaultTimeout,
|
||||
) => Promise<string>;
|
||||
): Promise<string>;
|
||||
|
||||
// TODO: revisit having this public clear method approach once the state service is fully deprecated.
|
||||
/**
|
||||
@@ -67,21 +65,21 @@ export abstract class TokenService {
|
||||
* pass in the vaultTimeoutAction and vaultTimeout.
|
||||
* This avoids a circular dependency between the StateService, TokenService, and VaultTimeoutSettingsService.
|
||||
*/
|
||||
clearAccessToken: (userId?: UserId) => Promise<void>;
|
||||
abstract clearAccessToken(userId?: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the access token
|
||||
* @param userId - The optional user id to get the access token for; if not provided, the active user is used.
|
||||
* @returns A promise that resolves with the access token or null.
|
||||
*/
|
||||
getAccessToken: (userId?: UserId) => Promise<string | null>;
|
||||
abstract getAccessToken(userId: UserId): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Gets the refresh token.
|
||||
* @param userId - The optional user id to get the refresh token for; if not provided, the active user is used.
|
||||
* @returns A promise that resolves with the refresh token or null.
|
||||
*/
|
||||
getRefreshToken: (userId?: UserId) => Promise<string | null>;
|
||||
abstract getRefreshToken(userId: UserId): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.
|
||||
@@ -90,18 +88,18 @@ export abstract class TokenService {
|
||||
* @param vaultTimeout The timeout for the vault.
|
||||
* @returns A promise that resolves with the API Key Client ID that has been set.
|
||||
*/
|
||||
setClientId: (
|
||||
abstract setClientId(
|
||||
clientId: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: VaultTimeout,
|
||||
userId?: UserId,
|
||||
) => Promise<string>;
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets the API Key Client ID for the active user.
|
||||
* Gets the API Key Client ID for the given user.
|
||||
* @returns A promise that resolves with the API Key Client ID or undefined
|
||||
*/
|
||||
getClientId: (userId?: UserId) => Promise<string | undefined>;
|
||||
abstract getClientId(userId: UserId): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout.
|
||||
@@ -110,18 +108,18 @@ export abstract class TokenService {
|
||||
* @param vaultTimeout The timeout for the vault.
|
||||
* @returns A promise that resolves with the client secret that has been set.
|
||||
*/
|
||||
setClientSecret: (
|
||||
abstract setClientSecret(
|
||||
clientSecret: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: VaultTimeout,
|
||||
userId?: UserId,
|
||||
) => Promise<string>;
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets the API Key Client Secret for the active user.
|
||||
* Gets the API Key Client Secret for the given user.
|
||||
* @returns A promise that resolves with the API Key Client Secret or undefined
|
||||
*/
|
||||
getClientSecret: (userId?: UserId) => Promise<string | undefined>;
|
||||
abstract getClientSecret(userId: UserId): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Sets the two factor token for the given email in global state.
|
||||
@@ -131,21 +129,21 @@ export abstract class TokenService {
|
||||
* @param twoFactorToken The two factor token to set.
|
||||
* @returns A promise that resolves when the two factor token has been set.
|
||||
*/
|
||||
setTwoFactorToken: (email: string, twoFactorToken: string) => Promise<void>;
|
||||
abstract setTwoFactorToken(email: string, twoFactorToken: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the two factor token for the given email.
|
||||
* @param email The email to get the two factor token for.
|
||||
* @returns A promise that resolves with the two factor token for the given email or null if it isn't found.
|
||||
*/
|
||||
getTwoFactorToken: (email: string) => Promise<string | null>;
|
||||
abstract getTwoFactorToken(email: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Clears the two factor token for the given email out of global state.
|
||||
* @param email The email to clear the two factor token for.
|
||||
* @returns A promise that resolves when the two factor token has been cleared.
|
||||
*/
|
||||
clearTwoFactorToken: (email: string) => Promise<void>;
|
||||
abstract clearTwoFactorToken(email: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Decodes the access token.
|
||||
@@ -153,13 +151,13 @@ export abstract class TokenService {
|
||||
* If null, the currently active user's token is used.
|
||||
* @returns A promise that resolves with the decoded access token.
|
||||
*/
|
||||
decodeAccessToken: (tokenOrUserId?: string | UserId) => Promise<DecodedAccessToken>;
|
||||
abstract decodeAccessToken(tokenOrUserId?: string | UserId): Promise<DecodedAccessToken>;
|
||||
|
||||
/**
|
||||
* Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration
|
||||
* @returns A promise that resolves with the expiration date for the access token.
|
||||
*/
|
||||
getTokenExpirationDate: () => Promise<Date | null>;
|
||||
abstract getTokenExpirationDate(userId: UserId): Promise<Date | null>;
|
||||
|
||||
/**
|
||||
* Calculates the adjusted time in seconds until the access token expires, considering an optional offset.
|
||||
@@ -170,58 +168,58 @@ export abstract class TokenService {
|
||||
* based on the actual expiration.
|
||||
* @returns {Promise<number>} Promise resolving to the adjusted seconds remaining.
|
||||
*/
|
||||
tokenSecondsRemaining: (offsetSeconds?: number) => Promise<number>;
|
||||
abstract tokenSecondsRemaining(userId: UserId, offsetSeconds?: number): Promise<number>;
|
||||
|
||||
/**
|
||||
* Checks if the access token needs to be refreshed.
|
||||
* @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it.
|
||||
* @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed.
|
||||
*/
|
||||
tokenNeedsRefresh: (minutes?: number) => Promise<boolean>;
|
||||
abstract tokenNeedsRefresh(userId: UserId, minutes?: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the user id for the active user from the access token.
|
||||
* @returns A promise that resolves with the user id for the active user.
|
||||
* @deprecated Use AccountService.activeAccount$ instead.
|
||||
*/
|
||||
getUserId: () => Promise<UserId>;
|
||||
abstract getUserId(): Promise<UserId>;
|
||||
|
||||
/**
|
||||
* Gets the email for the active user from the access token.
|
||||
* @returns A promise that resolves with the email for the active user.
|
||||
* @deprecated Use AccountService.activeAccount$ instead.
|
||||
*/
|
||||
getEmail: () => Promise<string>;
|
||||
abstract getEmail(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets the email verified status for the active user from the access token.
|
||||
* @returns A promise that resolves with the email verified status for the active user.
|
||||
*/
|
||||
getEmailVerified: () => Promise<boolean>;
|
||||
abstract getEmailVerified(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets the name for the active user from the access token.
|
||||
* @returns A promise that resolves with the name for the active user.
|
||||
* @deprecated Use AccountService.activeAccount$ instead.
|
||||
*/
|
||||
getName: () => Promise<string>;
|
||||
abstract getName(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets the issuer for the active user from the access token.
|
||||
* @returns A promise that resolves with the issuer for the active user.
|
||||
*/
|
||||
getIssuer: () => Promise<string>;
|
||||
abstract getIssuer(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Gets whether or not the user authenticated via an external mechanism.
|
||||
* @param userId The optional user id to check for external authN status; if not provided, the active user is used.
|
||||
* @returns A promise that resolves with a boolean representing the user's external authN status.
|
||||
*/
|
||||
getIsExternal: (userId: UserId) => Promise<boolean>;
|
||||
abstract getIsExternal(userId: UserId): Promise<boolean>;
|
||||
|
||||
/** Gets the active or passed in user's security stamp */
|
||||
getSecurityStamp: (userId?: UserId) => Promise<string | null>;
|
||||
abstract getSecurityStamp(userId?: UserId): Promise<string | null>;
|
||||
|
||||
/** Sets the security stamp for the active or passed in user */
|
||||
setSecurityStamp: (securityStamp: string, userId?: UserId) => Promise<void>;
|
||||
abstract setSecurityStamp(securityStamp: string, userId?: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
|
||||
import { VerifyOTPRequest } from "../../models/request/verify-otp.request";
|
||||
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";
|
||||
|
||||
export abstract class UserVerificationApiServiceAbstraction {
|
||||
postAccountVerifyOTP: (request: VerifyOTPRequest) => Promise<void>;
|
||||
postAccountRequestOTP: () => Promise<void>;
|
||||
postAccountVerifyPassword: (
|
||||
abstract postAccountVerifyOTP(request: VerifyOTPRequest): Promise<void>;
|
||||
abstract postAccountRequestOTP(): Promise<void>;
|
||||
abstract postAccountVerifyPassword(
|
||||
request: SecretVerificationRequest,
|
||||
) => Promise<MasterPasswordPolicyResponse>;
|
||||
): Promise<MasterPasswordPolicyResponse>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
|
||||
import { UserVerificationOptions } from "../../types/user-verification-options";
|
||||
@@ -16,9 +14,9 @@ export abstract class UserVerificationService {
|
||||
* @param verificationType Type of verification to restrict the options to
|
||||
* @returns Available verification options for the user
|
||||
*/
|
||||
getAvailableVerificationOptions: (
|
||||
abstract getAvailableVerificationOptions(
|
||||
verificationType: keyof UserVerificationOptions,
|
||||
) => Promise<UserVerificationOptions>;
|
||||
): Promise<UserVerificationOptions>;
|
||||
/**
|
||||
* Create a new request model to be used for server-side verification
|
||||
* @param verification User-supplied verification data (Master Password or OTP)
|
||||
@@ -26,11 +24,11 @@ export abstract class UserVerificationService {
|
||||
* @param alreadyHashed Whether the master password is already hashed
|
||||
* @throws Error if the verification data is invalid
|
||||
*/
|
||||
buildRequest: <T extends SecretVerificationRequest>(
|
||||
abstract buildRequest<T extends SecretVerificationRequest>(
|
||||
verification: Verification,
|
||||
requestClass?: new () => T,
|
||||
alreadyHashed?: boolean,
|
||||
) => Promise<T>;
|
||||
): Promise<T>;
|
||||
/**
|
||||
* Verifies the user using the provided verification data.
|
||||
* PIN or biometrics are verified client-side.
|
||||
@@ -39,11 +37,11 @@ export abstract class UserVerificationService {
|
||||
* @param verification User-supplied verification data (OTP, MP, PIN, or biometrics)
|
||||
* @throws Error if the verification data is invalid or the verification fails
|
||||
*/
|
||||
verifyUser: (verification: Verification) => Promise<boolean>;
|
||||
abstract verifyUser(verification: Verification): Promise<boolean>;
|
||||
/**
|
||||
* Request a one-time password (OTP) to be sent to the user's email
|
||||
*/
|
||||
requestOTP: () => Promise<void>;
|
||||
abstract requestOTP(): Promise<void>;
|
||||
/**
|
||||
* Check if user has master password or can only use passwordless technologies to log in
|
||||
* Note: This only checks the server, not the local state
|
||||
@@ -51,13 +49,13 @@ export abstract class UserVerificationService {
|
||||
* @returns True if the user has a master password
|
||||
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
|
||||
*/
|
||||
hasMasterPassword: (userId?: string) => Promise<boolean>;
|
||||
abstract hasMasterPassword(userId?: string): Promise<boolean>;
|
||||
/**
|
||||
* Check if the user has a master password and has used it during their current session
|
||||
* @param userId The user id to check. If not provided, the current user id used
|
||||
* @returns True if the user has a master password and has used it in the current session
|
||||
*/
|
||||
hasMasterPasswordAndMasterKeyHash: (userId?: string) => Promise<boolean>;
|
||||
abstract hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean>;
|
||||
/**
|
||||
* Verifies the user using the provided master password.
|
||||
* Attempts to verify client-side first, then server-side if necessary.
|
||||
@@ -68,9 +66,9 @@ export abstract class UserVerificationService {
|
||||
* @throws Error if the master password is invalid
|
||||
* @returns An object containing the master key, and master password policy options if verified on server.
|
||||
*/
|
||||
verifyUserByMasterPassword: (
|
||||
abstract verifyUserByMasterPassword(
|
||||
verification: MasterPasswordVerification,
|
||||
userId: UserId,
|
||||
email: string,
|
||||
) => Promise<MasterPasswordVerificationResponse>;
|
||||
): Promise<MasterPasswordVerificationResponse>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response";
|
||||
|
||||
export class WebAuthnLoginApiServiceAbstraction {
|
||||
getCredentialAssertionOptions: () => Promise<CredentialAssertionOptionsResponse>;
|
||||
export abstract class WebAuthnLoginApiServiceAbstraction {
|
||||
abstract getCredentialAssertionOptions(): Promise<CredentialAssertionOptionsResponse>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { PrfKey } from "../../../types/key";
|
||||
|
||||
/**
|
||||
@@ -9,11 +7,11 @@ export abstract class WebAuthnLoginPrfKeyServiceAbstraction {
|
||||
/**
|
||||
* Get the salt used to generate the PRF-output used when logging in with WebAuthn.
|
||||
*/
|
||||
getLoginWithPrfSalt: () => Promise<ArrayBuffer>;
|
||||
abstract getLoginWithPrfSalt(): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Create a symmetric key from the PRF-output by stretching it.
|
||||
* This should be used as `ExternalKey` with `RotateableKeySet`.
|
||||
*/
|
||||
createSymmetricKeyFromPrf: (prf: ArrayBuffer) => Promise<PrfKey>;
|
||||
abstract createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AuthResult } from "../../models/domain/auth-result";
|
||||
import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
@@ -14,7 +12,7 @@ export abstract class WebAuthnLoginServiceAbstraction {
|
||||
* (whether FIDO2 user verification is required, the relying party id, timeout duration for the process to complete, etc.)
|
||||
* for the authenticator.
|
||||
*/
|
||||
getCredentialAssertionOptions: () => Promise<WebAuthnLoginCredentialAssertionOptionsView>;
|
||||
abstract getCredentialAssertionOptions(): Promise<WebAuthnLoginCredentialAssertionOptionsView>;
|
||||
|
||||
/**
|
||||
* Asserts the credential. This involves user interaction with the authenticator
|
||||
@@ -27,9 +25,9 @@ export abstract class WebAuthnLoginServiceAbstraction {
|
||||
* @returns {WebAuthnLoginCredentialAssertionView} The assertion obtained from the authenticator.
|
||||
* If the assertion is not successfully obtained, it returns undefined.
|
||||
*/
|
||||
assertCredential: (
|
||||
abstract assertCredential(
|
||||
credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView,
|
||||
) => Promise<WebAuthnLoginCredentialAssertionView | undefined>;
|
||||
): Promise<WebAuthnLoginCredentialAssertionView | undefined>;
|
||||
|
||||
/**
|
||||
* Logs the user in using the assertion obtained from the authenticator.
|
||||
@@ -39,5 +37,5 @@ export abstract class WebAuthnLoginServiceAbstraction {
|
||||
* @param {WebAuthnLoginCredentialAssertionView} assertion - The assertion obtained from the authenticator
|
||||
* that needs to be validated for login.
|
||||
*/
|
||||
logIn: (assertion: WebAuthnLoginCredentialAssertionView) => Promise<AuthResult>;
|
||||
abstract logIn(assertion: WebAuthnLoginCredentialAssertionView): Promise<AuthResult>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const AuthServerNotificationTags = Object.freeze({
|
||||
AuthRequest: "authRequest",
|
||||
});
|
||||
@@ -1,7 +1,28 @@
|
||||
/**
|
||||
* The authentication status of the user
|
||||
*
|
||||
* See `AuthService.authStatusFor$` for details on how we determine the user's `AuthenticationStatus`
|
||||
*/
|
||||
// FIXME: update to use a const object instead of a typescript enum
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum AuthenticationStatus {
|
||||
/**
|
||||
* User is not authenticated
|
||||
* - The user does not have an active account userId and/or an access token in state
|
||||
*/
|
||||
LoggedOut = 0,
|
||||
|
||||
/**
|
||||
* User is authenticated but not decrypted
|
||||
* - The user has an access token, but no user key in state
|
||||
* - Vault data cannot be decrypted (because there is no user key)
|
||||
*/
|
||||
Locked = 1,
|
||||
|
||||
/**
|
||||
* User is authenticated and decrypted
|
||||
* - The user has an access token and a user key in state
|
||||
* - Vault data can be decrypted (via user key)
|
||||
*/
|
||||
Unlocked = 2,
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export class DeviceVerificationRequest {
|
||||
unknownDeviceVerificationEnabled: boolean;
|
||||
|
||||
constructor(unknownDeviceVerificationEnabled: boolean) {
|
||||
this.unknownDeviceVerificationEnabled = unknownDeviceVerificationEnabled;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,27 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
|
||||
import { EmailTokenRequest } from "./email-token.request";
|
||||
|
||||
export class EmailRequest extends EmailTokenRequest {
|
||||
newMasterPasswordHash: string;
|
||||
token: string;
|
||||
key: string;
|
||||
|
||||
// This will eventually be changed to be an actual constructor, once all callers are updated.
|
||||
// The body of this request will be changed to carry the authentication data and unlock data.
|
||||
// https://bitwarden.atlassian.net/browse/PM-23234
|
||||
static newConstructor(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
unlockData: MasterPasswordUnlockData,
|
||||
): EmailRequest {
|
||||
const request = new EmailRequest();
|
||||
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
|
||||
request.key = unlockData.masterKeyWrappedUserKey;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ClientType } from "../../../../enums";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
|
||||
import { DeviceRequest } from "./device.request";
|
||||
import { TokenTwoFactorRequest } from "./token-two-factor.request";
|
||||
@@ -30,10 +29,6 @@ export class PasswordTokenRequest extends TokenRequest {
|
||||
return obj;
|
||||
}
|
||||
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
headers.set("Auth-Email", Utils.fromUtf8ToUrlB64(this.email));
|
||||
}
|
||||
|
||||
static fromJSON(json: any) {
|
||||
return Object.assign(Object.create(PasswordTokenRequest.prototype), json, {
|
||||
device: json.device ? DeviceRequest.fromJSON(json.device) : undefined,
|
||||
|
||||
@@ -14,10 +14,6 @@ export abstract class TokenRequest {
|
||||
this.device = device != null ? device : null;
|
||||
}
|
||||
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
// Implemented in subclass if required
|
||||
}
|
||||
|
||||
setTwoFactor(twoFactor: TokenTwoFactorRequest | undefined) {
|
||||
this.twoFactor = twoFactor;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
|
||||
import { SecretVerificationRequest } from "./secret-verification.request";
|
||||
|
||||
export class PasswordRequest extends SecretVerificationRequest {
|
||||
newMasterPasswordHash: string;
|
||||
masterPasswordHint: string;
|
||||
key: string;
|
||||
|
||||
authenticationData?: MasterPasswordAuthenticationData;
|
||||
unlockData?: MasterPasswordUnlockData;
|
||||
|
||||
// This will eventually be changed to be an actual constructor, once all callers are updated.
|
||||
// https://bitwarden.atlassian.net/browse/PM-23234
|
||||
static newConstructor(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
unlockData: MasterPasswordUnlockData,
|
||||
): PasswordRequest {
|
||||
const request = new PasswordRequest();
|
||||
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
|
||||
request.key = unlockData.masterKeyWrappedUserKey;
|
||||
request.authenticationData = authenticationData;
|
||||
request.unlockData = unlockData;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { EncryptedString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { KeysRequest } from "../../../../models/request/keys.request";
|
||||
import { EncryptedString } from "../../../../platform/models/domain/enc-string";
|
||||
|
||||
export class RegisterFinishRequest {
|
||||
constructor(
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
|
||||
import { MasterPasswordAuthenticationData } from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export class SecretVerificationRequest {
|
||||
masterPasswordHash: string;
|
||||
otp: string;
|
||||
authRequestAccessCode: string;
|
||||
|
||||
/**
|
||||
* Mutates this request to include the master password authentication data, to authenticate the request.
|
||||
*/
|
||||
authenticateWith(
|
||||
masterPasswordAuthenticationData: MasterPasswordAuthenticationData,
|
||||
): SecretVerificationRequest {
|
||||
this.masterPasswordHash = masterPasswordAuthenticationData.masterPasswordAuthenticationHash;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
import { KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { KeysRequest } from "../../../models/request/keys.request";
|
||||
|
||||
@@ -21,19 +26,45 @@ export class SetPasswordRequest {
|
||||
masterPasswordHint: string,
|
||||
orgIdentifier: string,
|
||||
keys: KeysRequest | null,
|
||||
kdf: KdfType,
|
||||
kdfIterations: number,
|
||||
kdfMemory?: number,
|
||||
kdfParallelism?: number,
|
||||
kdf: KdfConfig,
|
||||
) {
|
||||
this.masterPasswordHash = masterPasswordHash;
|
||||
this.key = key;
|
||||
this.masterPasswordHint = masterPasswordHint;
|
||||
this.kdf = kdf;
|
||||
this.kdfIterations = kdfIterations;
|
||||
this.kdfMemory = kdfMemory;
|
||||
this.kdfParallelism = kdfParallelism;
|
||||
this.orgIdentifier = orgIdentifier;
|
||||
this.keys = keys;
|
||||
|
||||
if (kdf.kdfType === KdfType.PBKDF2_SHA256) {
|
||||
this.kdf = KdfType.PBKDF2_SHA256;
|
||||
this.kdfIterations = kdf.iterations;
|
||||
} else if (kdf.kdfType === KdfType.Argon2id) {
|
||||
this.kdf = KdfType.Argon2id;
|
||||
this.kdfIterations = kdf.iterations;
|
||||
this.kdfMemory = kdf.memory;
|
||||
this.kdfParallelism = kdf.parallelism;
|
||||
} else {
|
||||
throw new Error(`Unsupported KDF type: ${kdf}`);
|
||||
}
|
||||
}
|
||||
|
||||
// This will eventually be changed to be an actual constructor, once all callers are updated.
|
||||
// The body of this request will be changed to carry the authentication data and unlock data.
|
||||
// https://bitwarden.atlassian.net/browse/PM-23234
|
||||
static newConstructor(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
unlockData: MasterPasswordUnlockData,
|
||||
masterPasswordHint: string,
|
||||
orgIdentifier: string,
|
||||
keys: KeysRequest | null,
|
||||
): SetPasswordRequest {
|
||||
const request = new SetPasswordRequest(
|
||||
authenticationData.masterPasswordAuthenticationHash,
|
||||
unlockData.masterKeyWrappedUserKey,
|
||||
masterPasswordHint,
|
||||
orgIdentifier,
|
||||
keys,
|
||||
unlockData.kdf,
|
||||
);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { RotateableKeySet } from "../../../../../auth/src/common/models";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
|
||||
export class WebauthnRotateCredentialRequest {
|
||||
id: string;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class DeviceVerificationResponse extends BaseResponse {
|
||||
isDeviceVerificationSectionEnabled: boolean;
|
||||
unknownDeviceVerificationEnabled: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isDeviceVerificationSectionEnabled = this.getResponseProperty(
|
||||
"IsDeviceVerificationSectionEnabled",
|
||||
);
|
||||
this.unknownDeviceVerificationEnabled = this.getResponseProperty(
|
||||
"UnknownDeviceVerificationEnabled",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { makeEncString } from "../../../../spec";
|
||||
|
||||
import { IdentityTokenResponse } from "./identity-token.response";
|
||||
|
||||
describe("IdentityTokenResponse", () => {
|
||||
const accessToken = "testAccessToken";
|
||||
const tokenType = "Bearer";
|
||||
const expiresIn = 3600;
|
||||
const refreshToken = "testRefreshToken";
|
||||
const encryptedUserKey = makeEncString("testUserKey");
|
||||
|
||||
it("should throw an error when access token is missing", () => {
|
||||
const response = {
|
||||
access_token: undefined as unknown,
|
||||
token_type: tokenType,
|
||||
};
|
||||
|
||||
expect(() => new IdentityTokenResponse(response)).toThrow(
|
||||
"Identity response does not contain a valid access token",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error when token type is missing", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: undefined as unknown,
|
||||
};
|
||||
|
||||
expect(() => new IdentityTokenResponse(response)).toThrow(
|
||||
"Identity response does not contain a valid token type",
|
||||
);
|
||||
});
|
||||
|
||||
it("should create response without optional fields", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accessToken).toEqual(accessToken);
|
||||
expect(identityTokenResponse.tokenType).toEqual(tokenType);
|
||||
expect(identityTokenResponse.expiresIn).toBeUndefined();
|
||||
expect(identityTokenResponse.refreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create response with expires_in present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
expires_in: expiresIn,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accessToken).toEqual(accessToken);
|
||||
expect(identityTokenResponse.tokenType).toEqual(tokenType);
|
||||
expect(identityTokenResponse.expiresIn).toEqual(expiresIn);
|
||||
expect(identityTokenResponse.refreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create response with refresh_token present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
expires_in: expiresIn,
|
||||
refresh_token: refreshToken,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accessToken).toEqual(accessToken);
|
||||
expect(identityTokenResponse.tokenType).toEqual(tokenType);
|
||||
expect(identityTokenResponse.expiresIn).toEqual(expiresIn);
|
||||
expect(identityTokenResponse.refreshToken).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("should create response with key is not present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
Key: undefined as unknown,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create response with key present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
Key: encryptedUserKey.encryptedString,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.key).toEqual(encryptedUserKey);
|
||||
});
|
||||
|
||||
it("should create response with user decryption options is not present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
UserDecryptionOptions: undefined as unknown,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.userDecryptionOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create response with user decryption options present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
UserDecryptionOptions: {},
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -2,41 +2,54 @@
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
|
||||
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
|
||||
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
|
||||
|
||||
export class IdentityTokenResponse extends BaseResponse {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
refreshToken: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
tokenType: string;
|
||||
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
twoFactorToken: string;
|
||||
kdf: KdfType;
|
||||
kdfIterations: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
kdfConfig: KdfConfig;
|
||||
forcePasswordReset: boolean;
|
||||
masterPasswordPolicy: MasterPasswordPolicyResponse;
|
||||
apiUseKeyConnector: boolean;
|
||||
keyConnectorUrl: string;
|
||||
|
||||
userDecryptionOptions: UserDecryptionOptionsResponse;
|
||||
userDecryptionOptions?: UserDecryptionOptionsResponse;
|
||||
|
||||
constructor(response: any) {
|
||||
constructor(response: unknown) {
|
||||
super(response);
|
||||
this.accessToken = response.access_token;
|
||||
this.expiresIn = response.expires_in;
|
||||
this.refreshToken = response.refresh_token;
|
||||
this.tokenType = response.token_type;
|
||||
|
||||
const accessToken = this.getResponseProperty("access_token");
|
||||
if (accessToken == null || typeof accessToken !== "string") {
|
||||
throw new Error("Identity response does not contain a valid access token");
|
||||
}
|
||||
const tokenType = this.getResponseProperty("token_type");
|
||||
if (tokenType == null || typeof tokenType !== "string") {
|
||||
throw new Error("Identity response does not contain a valid token type");
|
||||
}
|
||||
this.accessToken = accessToken;
|
||||
this.tokenType = tokenType;
|
||||
|
||||
const expiresIn = this.getResponseProperty("expires_in");
|
||||
if (expiresIn != null && typeof expiresIn === "number") {
|
||||
this.expiresIn = expiresIn;
|
||||
}
|
||||
const refreshToken = this.getResponseProperty("refresh_token");
|
||||
if (refreshToken != null && typeof refreshToken === "string") {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
@@ -45,10 +58,14 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
this.key = new EncString(key);
|
||||
}
|
||||
this.twoFactorToken = this.getResponseProperty("TwoFactorToken");
|
||||
this.kdf = this.getResponseProperty("Kdf");
|
||||
this.kdfIterations = this.getResponseProperty("KdfIterations");
|
||||
this.kdfMemory = this.getResponseProperty("KdfMemory");
|
||||
this.kdfParallelism = this.getResponseProperty("KdfParallelism");
|
||||
const kdf = this.getResponseProperty("Kdf");
|
||||
const kdfIterations = this.getResponseProperty("KdfIterations");
|
||||
const kdfMemory = this.getResponseProperty("KdfMemory");
|
||||
const kdfParallelism = this.getResponseProperty("KdfParallelism");
|
||||
this.kdfConfig =
|
||||
kdf == KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(kdfIterations)
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
|
||||
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
|
||||
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
|
||||
@@ -56,10 +73,9 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
this.getResponseProperty("MasterPasswordPolicy"),
|
||||
);
|
||||
|
||||
if (response.UserDecryptionOptions) {
|
||||
this.userDecryptionOptions = new UserDecryptionOptionsResponse(
|
||||
this.getResponseProperty("UserDecryptionOptions"),
|
||||
);
|
||||
const userDecryptionOptions = this.getResponseProperty("UserDecryptionOptions");
|
||||
if (userDecryptionOptions != null && typeof userDecryptionOptions === "object") {
|
||||
this.userDecryptionOptions = new UserDecryptionOptionsResponse(userDecryptionOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Jsonify } from "type-fest";
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
|
||||
import { DeviceType } from "../../../enums";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
|
||||
export class ProtectedDeviceResponse extends BaseResponse {
|
||||
constructor(response: Jsonify<ProtectedDeviceResponse>) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { EncString } from "../../../../platform/models/domain/enc-string";
|
||||
|
||||
export interface ITrustedDeviceUserDecryptionOptionServerResponse {
|
||||
HasAdminApproval: boolean;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { UserDecryptionOptionsResponse } from "./user-decryption-options.response";
|
||||
|
||||
describe("UserDecryptionOptionsResponse", () => {
|
||||
it("should create response when master password unlock is present", () => {
|
||||
const salt = "test@example.com";
|
||||
const encryptedUserKey = "testUserKey";
|
||||
|
||||
const response = new UserDecryptionOptionsResponse({
|
||||
HasMasterPassword: true,
|
||||
MasterPasswordUnlock: {
|
||||
Salt: salt,
|
||||
Kdf: {
|
||||
KdfType: KdfType.PBKDF2_SHA256,
|
||||
Iterations: 600_000,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: encryptedUserKey,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.hasMasterPassword).toBe(true);
|
||||
expect(response.masterPasswordUnlock).toBeDefined();
|
||||
expect(response.masterPasswordUnlock!.salt).toEqual(salt);
|
||||
expect(response.masterPasswordUnlock!.kdf.kdfType).toEqual(KdfType.PBKDF2_SHA256);
|
||||
expect(response.masterPasswordUnlock!.kdf.iterations).toEqual(600_000);
|
||||
expect(response.masterPasswordUnlock!.masterKeyWrappedUserKey).toEqual(encryptedUserKey);
|
||||
expect(response.trustedDeviceOption).toBeUndefined();
|
||||
expect(response.keyConnectorOption).toBeUndefined();
|
||||
expect(response.webAuthnPrfOption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create response when master password unlock is not present", () => {
|
||||
const response = new UserDecryptionOptionsResponse({
|
||||
HasMasterPassword: false,
|
||||
});
|
||||
|
||||
expect(response.hasMasterPassword).toBe(false);
|
||||
expect(response.masterPasswordUnlock).toBeUndefined();
|
||||
expect(response.trustedDeviceOption).toBeUndefined();
|
||||
expect(response.keyConnectorOption).toBeUndefined();
|
||||
expect(response.webAuthnPrfOption).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MasterPasswordUnlockResponse } from "../../../../key-management/master-password/models/response/master-password-unlock.response";
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
|
||||
export interface IUserDecryptionOptionsServerResponse {
|
||||
HasMasterPassword: boolean;
|
||||
MasterPasswordUnlock?: unknown;
|
||||
TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse;
|
||||
KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse;
|
||||
WebAuthnPrfOption?: IWebAuthnPrfDecryptionOptionServerResponse;
|
||||
@@ -22,6 +24,7 @@ export interface IUserDecryptionOptionsServerResponse {
|
||||
|
||||
export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
hasMasterPassword: boolean;
|
||||
masterPasswordUnlock?: MasterPasswordUnlockResponse;
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
|
||||
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
|
||||
@@ -31,6 +34,11 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
|
||||
this.hasMasterPassword = this.getResponseProperty("HasMasterPassword");
|
||||
|
||||
const masterPasswordUnlock = this.getResponseProperty("MasterPasswordUnlock");
|
||||
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
|
||||
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
|
||||
}
|
||||
|
||||
if (response.TrustedDeviceOption) {
|
||||
this.trustedDeviceOption = new TrustedDeviceUserDecryptionOptionResponse(
|
||||
this.getResponseProperty("TrustedDeviceOption"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { EncString } from "../../../../platform/models/domain/enc-string";
|
||||
|
||||
export interface IWebAuthnPrfDecryptionOptionServerResponse {
|
||||
EncryptedPrivateKey: string;
|
||||
|
||||
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;
|
||||
@@ -232,9 +232,11 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).update(() => {
|
||||
return setVerifyNewDeviceLogin;
|
||||
});
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN)
|
||||
.update(() => setVerifyNewDeviceLogin, {
|
||||
shouldUpdate: (previousValue) => previousValue !== setVerifyNewDeviceLogin,
|
||||
});
|
||||
}
|
||||
|
||||
async removeAccountActivity(userId: UserId): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
|
||||
|
||||
describe("AuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let actionService: MockProxy<ActionsService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let systemNotificationsService: MockProxy<SystemNotificationsService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
actionService = mock<ActionsService>();
|
||||
authService = mock<AuthService>();
|
||||
i18nService = mock<I18nService>();
|
||||
masterPasswordService = { forceSetPasswordReason$: jest.fn() };
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
systemNotificationsService = mock<SystemNotificationsService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of({
|
||||
id: userId,
|
||||
email: "user@example.com",
|
||||
emailVerified: true,
|
||||
name: "User",
|
||||
});
|
||||
accountService.accounts$ = of({
|
||||
[userId]: { email: "user@example.com", emailVerified: true, name: "User" },
|
||||
});
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
);
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
i18nService.t.mockImplementation(
|
||||
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
|
||||
);
|
||||
systemNotificationsService.create.mockResolvedValue("notif-id");
|
||||
|
||||
sut = new AuthRequestAnsweringService(
|
||||
accountService,
|
||||
actionService,
|
||||
authService,
|
||||
i18nService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
platformUtilsService,
|
||||
systemNotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked", () => {
|
||||
it("clears notification and opens popup when notification body is clicked", async () => {
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
|
||||
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing when an optional button is clicked", async () => {
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.FirstOptionalButton,
|
||||
};
|
||||
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
|
||||
expect(actionService.openPopup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("receivedPendingAuthRequest", () => {
|
||||
const authRequestId = "req-abc";
|
||||
|
||||
it("creates a system notification when popup is not open", async () => {
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
|
||||
expect(systemNotificationsService.create).toHaveBeenCalledWith({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
|
||||
title: "accountAccessRequested",
|
||||
body: "confirmAccessAttempt:user@example.com",
|
||||
buttons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => {
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
);
|
||||
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
expect(systemNotificationsService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly actionService: ActionsService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private readonly messagingService: MessagingService,
|
||||
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly systemNotificationsService: SystemNotificationsService,
|
||||
) {}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(userId),
|
||||
);
|
||||
const popupOpen = await this.platformUtilsService.isPopupOpen();
|
||||
|
||||
// Always persist the pending marker for this user to global state.
|
||||
await this.pendingAuthRequestsState.add(userId);
|
||||
|
||||
// These are the conditions we are looking for to know if the extension is in a state to show
|
||||
// the approval dialog.
|
||||
const userIsAvailableToReceiveAuthRequest =
|
||||
popupOpen &&
|
||||
authStatus === AuthenticationStatus.Unlocked &&
|
||||
activeUserId === userId &&
|
||||
forceSetPasswordReason === ForceSetPasswordReason.None;
|
||||
|
||||
if (!userIsAvailableToReceiveAuthRequest) {
|
||||
// Get the user's email to include in the system notification
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const emailForUser = accounts[userId].email;
|
||||
|
||||
await this.systemNotificationsService.create({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
|
||||
title: this.i18nService.t("accountAccessRequested"),
|
||||
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
|
||||
buttons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Popup is open and conditions are met; open dialog immediately for this request
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
|
||||
await this.systemNotificationsService.clear({
|
||||
id: `${event.id}`,
|
||||
});
|
||||
await this.actionService.openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor() {}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
AUTH_REQUEST_DISK_LOCAL,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export type PendingAuthUserMarker = {
|
||||
userId: UserId;
|
||||
receivedAtMs: number;
|
||||
};
|
||||
|
||||
export const PENDING_AUTH_REQUESTS = KeyDefinition.array<PendingAuthUserMarker>(
|
||||
AUTH_REQUEST_DISK_LOCAL,
|
||||
"pendingAuthRequests",
|
||||
{
|
||||
deserializer: (json) => json,
|
||||
},
|
||||
);
|
||||
|
||||
export class PendingAuthRequestsStateService {
|
||||
private readonly state: GlobalState<PendingAuthUserMarker[]>;
|
||||
|
||||
constructor(private readonly stateProvider: StateProvider) {
|
||||
this.state = this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS);
|
||||
}
|
||||
|
||||
getAll$(): Observable<PendingAuthUserMarker[] | null> {
|
||||
return this.state.state$;
|
||||
}
|
||||
|
||||
async add(userId: UserId): Promise<void> {
|
||||
const now = Date.now();
|
||||
await this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS).update((current) => {
|
||||
const list = (current ?? []).filter((e) => e.userId !== userId);
|
||||
return [...list, { userId, receivedAtMs: now }];
|
||||
});
|
||||
}
|
||||
|
||||
async pruneOlderThan(maxAgeMs: number): Promise<void> {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
await this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS).update((current) => {
|
||||
const list = current ?? [];
|
||||
return list.filter((e) => e.receivedAtMs >= cutoff);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS).update((current) => {
|
||||
const list = current ?? [];
|
||||
return list.filter((e) => e.userId !== userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { ActiveUserAccessor } from "../../platform/state";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
|
||||
/**
|
||||
* Implementation for Platform so they can avoid a direct dependency on AccountService. Not for general consumption.
|
||||
*/
|
||||
export class DefaultActiveUserAccessor implements ActiveUserAccessor {
|
||||
constructor(private readonly accountService: AccountService) {
|
||||
this.activeUserId$ = this.accountService.activeAccount$.pipe(
|
||||
map((a) => (a != null ? a.id : null)),
|
||||
);
|
||||
}
|
||||
|
||||
activeUserId$: Observable<UserId | null>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Observable, defer, map } from "rxjs";
|
||||
|
||||
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
|
||||
@@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
|
||||
*/
|
||||
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
constructor(
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private appIdService: AppIdService,
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets a human readable string of the device type name
|
||||
*/
|
||||
getReadableDeviceTypeName(type: DeviceType): string {
|
||||
if (type === undefined) {
|
||||
return this.i18nService.t("unknownDevice");
|
||||
}
|
||||
|
||||
const metadata = DeviceTypeMetadata[type];
|
||||
if (!metadata) {
|
||||
return this.i18nService.t("unknownDevice");
|
||||
}
|
||||
|
||||
const platform =
|
||||
metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform;
|
||||
const category = this.i18nService.t(metadata.category);
|
||||
return platform ? `${category} - ${platform}` : category;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { PasswordRequest } from "../../models/request/password.request";
|
||||
import { SetPasswordRequest } from "../../models/request/set-password.request";
|
||||
@@ -42,8 +42,7 @@ describe("MasterPasswordApiService", () => {
|
||||
publicKey: "publicKey",
|
||||
encryptedPrivateKey: "encryptedPrivateKey",
|
||||
},
|
||||
KdfType.PBKDF2_SHA256,
|
||||
600_000,
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
);
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CODE_VERIFIER,
|
||||
GLOBAL_ORGANIZATION_SSO_IDENTIFIER,
|
||||
SSO_EMAIL,
|
||||
SSO_REQUIRED_CACHE,
|
||||
SSO_STATE,
|
||||
SsoLoginService,
|
||||
USER_ORGANIZATION_SSO_IDENTIFIER,
|
||||
@@ -18,8 +22,9 @@ describe("SSOLoginService ", () => {
|
||||
let sut: SsoLoginService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let mockSingleUserStateProvider: FakeStateProvider;
|
||||
let mockStateProvider: FakeStateProvider;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let userId: UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -27,10 +32,11 @@ describe("SSOLoginService ", () => {
|
||||
|
||||
userId = Utils.newGuid() as UserId;
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
mockSingleUserStateProvider = new FakeStateProvider(accountService);
|
||||
mockStateProvider = new FakeStateProvider(accountService);
|
||||
mockLogService = mock<LogService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
|
||||
sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService);
|
||||
sut = new SsoLoginService(mockStateProvider, mockLogService, mockPolicyService);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
@@ -40,7 +46,7 @@ describe("SSOLoginService ", () => {
|
||||
it("gets and sets code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
await sut.setCodeVerifier(codeVerifier);
|
||||
mockSingleUserStateProvider.getGlobal(CODE_VERIFIER);
|
||||
mockStateProvider.getGlobal(CODE_VERIFIER);
|
||||
|
||||
const result = await sut.getCodeVerifier();
|
||||
expect(result).toBe(codeVerifier);
|
||||
@@ -49,7 +55,7 @@ describe("SSOLoginService ", () => {
|
||||
it("gets and sets SSO state", async () => {
|
||||
const ssoState = "test-sso-state";
|
||||
await sut.setSsoState(ssoState);
|
||||
mockSingleUserStateProvider.getGlobal(SSO_STATE);
|
||||
mockStateProvider.getGlobal(SSO_STATE);
|
||||
|
||||
const result = await sut.getSsoState();
|
||||
expect(result).toBe(ssoState);
|
||||
@@ -58,7 +64,7 @@ describe("SSOLoginService ", () => {
|
||||
it("gets and sets organization SSO identifier", async () => {
|
||||
const orgIdentifier = "test-org-identifier";
|
||||
await sut.setOrganizationSsoIdentifier(orgIdentifier);
|
||||
mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
|
||||
mockStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
|
||||
|
||||
const result = await sut.getOrganizationSsoIdentifier();
|
||||
expect(result).toBe(orgIdentifier);
|
||||
@@ -67,7 +73,7 @@ describe("SSOLoginService ", () => {
|
||||
it("gets and sets SSO email", async () => {
|
||||
const email = "test@example.com";
|
||||
await sut.setSsoEmail(email);
|
||||
mockSingleUserStateProvider.getGlobal(SSO_EMAIL);
|
||||
mockStateProvider.getGlobal(SSO_EMAIL);
|
||||
|
||||
const result = await sut.getSsoEmail();
|
||||
expect(result).toBe(email);
|
||||
@@ -77,7 +83,7 @@ describe("SSOLoginService ", () => {
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const orgIdentifier = "test-active-org-identifier";
|
||||
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId);
|
||||
mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
|
||||
mockStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
|
||||
|
||||
const result = await sut.getActiveUserOrganizationSsoIdentifier(userId);
|
||||
expect(result).toBe(orgIdentifier);
|
||||
@@ -91,4 +97,153 @@ describe("SSOLoginService ", () => {
|
||||
"Tried to set a user organization sso identifier with an undefined user id.",
|
||||
);
|
||||
});
|
||||
|
||||
describe("updateSsoRequiredCache()", () => {
|
||||
it("should add email to cache when SSO is required", async () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([]);
|
||||
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(email);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
await sut.updateSsoRequiredCache(email, userId);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).toHaveBeenCalledWith([email.toLowerCase()]);
|
||||
});
|
||||
|
||||
it("should add email to existing cache when SSO is required and email is not already present", async () => {
|
||||
const existingEmail = "existing@example.com";
|
||||
const newEmail = "new@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([existingEmail]);
|
||||
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(newEmail);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
await sut.updateSsoRequiredCache(newEmail, userId);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).toHaveBeenCalledWith([existingEmail, newEmail.toLowerCase()]);
|
||||
});
|
||||
|
||||
it("should not add duplicate email to cache when SSO is required", async () => {
|
||||
const duplicateEmail = "duplicate@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([duplicateEmail]);
|
||||
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(duplicateEmail);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
await sut.updateSsoRequiredCache(duplicateEmail, userId);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should initialize new cache with email when SSO is required and no cache exists", async () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next(null);
|
||||
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(email);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
await sut.updateSsoRequiredCache(email, userId);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).toHaveBeenCalledWith([email.toLowerCase()]);
|
||||
});
|
||||
|
||||
it("should remove email from cache when SSO is not required", async () => {
|
||||
const emailToRemove = "remove@example.com";
|
||||
const remainingEmail = "keep@example.com";
|
||||
|
||||
mockStateProvider.global
|
||||
.getFake(SSO_REQUIRED_CACHE)
|
||||
.stateSubject.next([emailToRemove, remainingEmail]);
|
||||
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(emailToRemove);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(false));
|
||||
|
||||
await sut.updateSsoRequiredCache(emailToRemove, userId);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).toHaveBeenCalledWith([remainingEmail]);
|
||||
});
|
||||
|
||||
it("should not update cache when SSO is not required and email is not present", async () => {
|
||||
const existingEmail = "existing@example.com";
|
||||
const nonExistentEmail = "nonexistent@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([existingEmail]);
|
||||
mockStateProvider.global.getFake(SSO_EMAIL).stateSubject.next(nonExistentEmail);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(false));
|
||||
|
||||
await sut.updateSsoRequiredCache(nonExistentEmail, userId);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check policy for correct PolicyType and userId", async () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([]);
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
await sut.updateSsoRequiredCache(email, userId);
|
||||
|
||||
expect(mockPolicyService.policyAppliesToUser$).toHaveBeenCalledWith(
|
||||
PolicyType.RequireSso,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeFromSsoRequiredCacheIfPresent()", () => {
|
||||
it("should remove email from cache when present", async () => {
|
||||
const emailToRemove = "remove@example.com";
|
||||
const remainingEmail = "keep@example.com";
|
||||
|
||||
mockStateProvider.global
|
||||
.getFake(SSO_REQUIRED_CACHE)
|
||||
.stateSubject.next([emailToRemove, remainingEmail]);
|
||||
|
||||
await sut.removeFromSsoRequiredCacheIfPresent(emailToRemove);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).toHaveBeenCalledWith([remainingEmail]);
|
||||
});
|
||||
|
||||
it("should not update cache when email is not present", async () => {
|
||||
const existingEmail = "existing@example.com";
|
||||
const nonExistentEmail = "nonexistent@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([existingEmail]);
|
||||
|
||||
await sut.removeFromSsoRequiredCacheIfPresent(nonExistentEmail);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not update cache when cache is already null", async () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next(null);
|
||||
|
||||
await sut.removeFromSsoRequiredCacheIfPresent(email);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should result in an empty array when removing last email", async () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
mockStateProvider.global.getFake(SSO_REQUIRED_CACHE).stateSubject.next([email]);
|
||||
|
||||
await sut.removeFromSsoRequiredCacheIfPresent(email);
|
||||
|
||||
const cacheState = mockStateProvider.global.getFake(SSO_REQUIRED_CACHE);
|
||||
expect(cacheState.nextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
KeyDefinition,
|
||||
SingleUserState,
|
||||
SSO_DISK,
|
||||
SSO_DISK_LOCAL,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
@@ -57,20 +60,35 @@ export const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
|
||||
deserializer: (state) => state,
|
||||
});
|
||||
|
||||
/**
|
||||
* A cache list of user emails for whom the `PolicyType.RequireSso` policy is applied (that is, a list
|
||||
* of users who are required to authenticate via SSO only). The cache lives on the current device only.
|
||||
*/
|
||||
export const SSO_REQUIRED_CACHE = new KeyDefinition<string[]>(SSO_DISK_LOCAL, "ssoRequiredCache", {
|
||||
deserializer: (ssoRequiredCache) => ssoRequiredCache,
|
||||
});
|
||||
|
||||
export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
private codeVerifierState: GlobalState<string>;
|
||||
private ssoState: GlobalState<string>;
|
||||
private orgSsoIdentifierState: GlobalState<string>;
|
||||
private ssoEmailState: GlobalState<string>;
|
||||
private ssoRequiredCacheState: GlobalState<string[]>;
|
||||
|
||||
ssoRequiredCache$: Observable<Set<string> | null>;
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
private policyService: PolicyService,
|
||||
) {
|
||||
this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER);
|
||||
this.ssoState = this.stateProvider.getGlobal(SSO_STATE);
|
||||
this.orgSsoIdentifierState = this.stateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
|
||||
this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL);
|
||||
this.ssoRequiredCacheState = this.stateProvider.getGlobal(SSO_REQUIRED_CACHE);
|
||||
|
||||
this.ssoRequiredCache$ = this.ssoRequiredCacheState.state$.pipe(map((cache) => new Set(cache)));
|
||||
}
|
||||
|
||||
getCodeVerifier(): Promise<string | null> {
|
||||
@@ -105,6 +123,10 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
await this.ssoEmailState.update((_) => email);
|
||||
}
|
||||
|
||||
async clearSsoEmail(): Promise<void> {
|
||||
await this.ssoEmailState.update((_) => null);
|
||||
}
|
||||
|
||||
getActiveUserOrganizationSsoIdentifier(userId: UserId): Promise<string | null> {
|
||||
return firstValueFrom(this.userOrgSsoIdentifierState(userId).state$);
|
||||
}
|
||||
@@ -125,4 +147,53 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
private userOrgSsoIdentifierState(userId: UserId): SingleUserState<string> {
|
||||
return this.stateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an email to the cached list of emails that must authenticate via SSO.
|
||||
*/
|
||||
private async addToSsoRequiredCache(email: string): Promise<void> {
|
||||
await this.ssoRequiredCacheState.update(
|
||||
(cache) => (cache == null ? [email] : [...cache, email]),
|
||||
{
|
||||
shouldUpdate: (cache) => {
|
||||
if (cache == null) {
|
||||
return true;
|
||||
}
|
||||
return !cache.includes(email);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async removeFromSsoRequiredCacheIfPresent(email: string): Promise<void> {
|
||||
await this.ssoRequiredCacheState.update(
|
||||
(cache) => cache?.filter((cachedEmail) => cachedEmail !== email) ?? cache,
|
||||
{
|
||||
shouldUpdate: (cache) => {
|
||||
if (cache == null) {
|
||||
return false;
|
||||
}
|
||||
return cache.includes(email);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async updateSsoRequiredCache(ssoLoginEmail: string, userId: UserId): Promise<void> {
|
||||
const ssoRequired = await firstValueFrom(
|
||||
this.policyService.policyAppliesToUser$(PolicyType.RequireSso, userId),
|
||||
);
|
||||
|
||||
if (ssoRequired) {
|
||||
await this.addToSsoRequiredCache(ssoLoginEmail.toLowerCase());
|
||||
} else {
|
||||
/**
|
||||
* If user is not required to authenticate via SSO, remove email from the cache
|
||||
* list (if it was on the list). This is necessary because the user may have been
|
||||
* required to authenticate via SSO at some point in the past, but now their org
|
||||
* no longer requires SSO authenticaiton.
|
||||
*/
|
||||
await this.removeFromSsoRequiredCacheIfPresent(ssoLoginEmail.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import { firstValueFrom } from "rxjs";
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
|
||||
import { KeyGenerationService } from "../../key-management/crypto";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutStringType,
|
||||
} from "../../key-management/vault-timeout";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
@@ -409,28 +409,8 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("getAccessToken", () => {
|
||||
it("returns null when no user id is provided and there is no active user in global state", async () => {
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no access token is found in memory, disk, or secure storage", async () => {
|
||||
// Arrange
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
test.each([
|
||||
["gets the access token from memory when a user id is provided ", userIdFromAccessToken],
|
||||
["gets the access token from memory when no user id is provided", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
it("gets the access token from memory when a user id is provided ", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -442,12 +422,10 @@ describe("TokenService", () => {
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
@@ -455,10 +433,7 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
test.each([
|
||||
["gets the access token from disk when the user id is specified", userIdFromAccessToken],
|
||||
["gets the access token from disk when no user id is specified", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
it("gets the access token from disk when the user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -469,12 +444,10 @@ describe("TokenService", () => {
|
||||
.nextState(accessTokenJwt);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
@@ -486,16 +459,7 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
it("gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -509,27 +473,17 @@ describe("TokenService", () => {
|
||||
encryptService.decryptString.mockResolvedValue("decryptedAccessToken");
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual("decryptedAccessToken");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
it("falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -540,14 +494,12 @@ describe("TokenService", () => {
|
||||
.nextState(accessTokenJwt);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
}
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// No access token key set
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
@@ -738,7 +690,7 @@ describe("TokenService", () => {
|
||||
|
||||
// Act
|
||||
// note: don't await here because we want to test the error
|
||||
const result = tokenService.getTokenExpirationDate();
|
||||
const result = tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Failed to decode access token: Mock error");
|
||||
});
|
||||
@@ -748,7 +700,7 @@ describe("TokenService", () => {
|
||||
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
@@ -763,7 +715,7 @@ describe("TokenService", () => {
|
||||
.mockResolvedValue(accessTokenDecodedWithoutExp);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
@@ -777,7 +729,7 @@ describe("TokenService", () => {
|
||||
.mockResolvedValue(accessTokenDecodedWithNonNumericExp);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
@@ -788,7 +740,7 @@ describe("TokenService", () => {
|
||||
tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getTokenExpirationDate();
|
||||
const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000));
|
||||
@@ -801,7 +753,7 @@ describe("TokenService", () => {
|
||||
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenSecondsRemaining();
|
||||
const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(0);
|
||||
@@ -823,7 +775,7 @@ describe("TokenService", () => {
|
||||
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenSecondsRemaining();
|
||||
const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expectedSecondsRemaining);
|
||||
@@ -849,7 +801,10 @@ describe("TokenService", () => {
|
||||
tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenSecondsRemaining(offsetSeconds);
|
||||
const result = await tokenService.tokenSecondsRemaining(
|
||||
userIdFromAccessToken,
|
||||
offsetSeconds,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expectedSecondsRemaining);
|
||||
@@ -866,7 +821,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh();
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
@@ -878,7 +833,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh();
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(false);
|
||||
@@ -890,7 +845,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh(2);
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 2);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
@@ -902,7 +857,7 @@ describe("TokenService", () => {
|
||||
tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.tokenNeedsRefresh(5);
|
||||
const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 5);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(false);
|
||||
@@ -1476,8 +1431,14 @@ describe("TokenService", () => {
|
||||
expect(logoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not error and fallback to disk storage when passed a null value for the refresh token", async () => {
|
||||
it("does not error and does not fallback to disk storage when passed a null value for the refresh token", async () => {
|
||||
// Arrange
|
||||
|
||||
// We must have an initial value in disk so that we can assert that it gets cleaned up
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(refreshToken);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
@@ -1559,26 +1520,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(refreshToken);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("gets the refresh token from memory when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -1597,25 +1538,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
it("gets the refresh token from disk when no user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(refreshToken);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("gets the refresh token from disk when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -1639,27 +1561,6 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("gets the refresh token from secure storage when no user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(refreshToken);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
});
|
||||
|
||||
it("gets the refresh token from secure storage when a user id is specified", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -1699,29 +1600,6 @@ describe("TokenService", () => {
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK)
|
||||
.nextState(refreshToken);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getRefreshToken();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(refreshToken);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => {
|
||||
// Arrange
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
@@ -1938,45 +1816,7 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("getClientId", () => {
|
||||
it("returns undefined when no user id is provided and there is no active user in global state", async () => {
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when no client id is found in memory or disk", async () => {
|
||||
// Arrange
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("gets the client id from memory when no user id is specified (uses global active user)", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
||||
.nextState(clientId);
|
||||
|
||||
// set disk to undefined
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(clientId);
|
||||
});
|
||||
|
||||
it("gets the client id from memory when given a user id", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -1996,25 +1836,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests", () => {
|
||||
it("gets the client id from disk when no user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK)
|
||||
.nextState(clientId);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientId();
|
||||
// Assert
|
||||
expect(result).toEqual(clientId);
|
||||
});
|
||||
|
||||
it("gets the client id from disk when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -2209,45 +2030,17 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("getClientSecret", () => {
|
||||
it("returns undefined when no user id is provided and there is no active user in global state", async () => {
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when no client secret is found in memory or disk", async () => {
|
||||
// Arrange
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
const result = await tokenService.getClientSecret(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("gets the client secret from memory when no user id is specified (uses global active user)", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
||||
.nextState(clientSecret);
|
||||
|
||||
// set disk to undefined
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
||||
.nextState(undefined);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(clientSecret);
|
||||
});
|
||||
|
||||
it("gets the client secret from memory when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
@@ -2267,25 +2060,6 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Disk storage tests", () => {
|
||||
it("gets the client secret from disk when no user id specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY)
|
||||
.nextState(undefined);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK)
|
||||
.nextState(clientSecret);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getClientSecret();
|
||||
// Assert
|
||||
expect(result).toEqual(clientSecret);
|
||||
});
|
||||
|
||||
it("gets the client secret from disk when a user id is specified", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
|
||||
@@ -7,18 +7,18 @@ import { Opaque } from "type-fest";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common";
|
||||
|
||||
import { KeyGenerationService } from "../../key-management/crypto";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString, EncryptedString } from "../../key-management/crypto/models/enc-string";
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
VaultTimeoutStringType,
|
||||
} from "../../key-management/vault-timeout";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
@@ -296,22 +296,36 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return await this.encryptService.encryptString(accessToken, accessTokenKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the access token using the provided access token key.
|
||||
*
|
||||
* @param accessTokenKey - the key used to decrypt the access token
|
||||
* @param encryptedAccessToken - the encrypted access token to decrypt
|
||||
* @returns the decrypted access token
|
||||
* @throws Error if the access token key is not provided or the decryption fails
|
||||
*/
|
||||
private async decryptAccessToken(
|
||||
accessTokenKey: AccessTokenKey,
|
||||
encryptedAccessToken: EncString,
|
||||
): Promise<string | null> {
|
||||
): Promise<string> {
|
||||
if (!accessTokenKey) {
|
||||
throw new Error(
|
||||
"decryptAccessToken: Access token key required. Cannot decrypt access token.",
|
||||
);
|
||||
}
|
||||
|
||||
const decryptedAccessToken = await this.encryptService.decryptString(
|
||||
encryptedAccessToken,
|
||||
accessTokenKey,
|
||||
);
|
||||
try {
|
||||
const decryptedAccessToken = await this.encryptService.decryptString(
|
||||
encryptedAccessToken,
|
||||
accessTokenKey,
|
||||
);
|
||||
return decryptedAccessToken;
|
||||
} catch (e) {
|
||||
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
|
||||
|
||||
return decryptedAccessToken;
|
||||
this.logService.error("[TokenService] Error decrypting access token", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,7 +362,10 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// Save the encrypted access token to disk
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => encryptedAccessToken.encryptedString);
|
||||
.update((_) => encryptedAccessToken.encryptedString, {
|
||||
shouldUpdate: (previousValue) =>
|
||||
previousValue !== encryptedAccessToken.encryptedString,
|
||||
});
|
||||
|
||||
// If we've successfully stored the encrypted access token to disk, we can return the decrypted access token
|
||||
// so that the caller can use it immediately.
|
||||
@@ -367,7 +384,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// Fall back to disk storage for unecrypted access token
|
||||
decryptedAccessToken = await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => accessToken);
|
||||
.update((_) => accessToken, {
|
||||
shouldUpdate: (previousValue) => previousValue !== accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
return decryptedAccessToken;
|
||||
@@ -376,7 +395,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// Access token stored on disk unencrypted as platform does not support secure storage
|
||||
return await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => accessToken);
|
||||
.update((_) => accessToken, {
|
||||
shouldUpdate: (previousValue) => previousValue !== accessToken,
|
||||
});
|
||||
case TokenStorageLocation.Memory:
|
||||
// Access token stored in memory due to vault timeout settings
|
||||
return await this.singleUserStateProvider
|
||||
@@ -431,13 +452,13 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
|
||||
// Platform doesn't support secure storage, so use state provider implementation
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, {
|
||||
shouldUpdate: (previousValue) => previousValue !== null,
|
||||
});
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
|
||||
}
|
||||
|
||||
async getAccessToken(userId?: UserId): Promise<string | null> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getAccessToken(userId: UserId): Promise<string | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
@@ -578,7 +599,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// TODO: PM-6408
|
||||
// 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time.
|
||||
// Remove these 2 calls to remove the refresh token from memory and disk after 3 months.
|
||||
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
|
||||
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null, {
|
||||
shouldUpdate: (previousValue) => previousValue !== null,
|
||||
});
|
||||
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
|
||||
} catch (error) {
|
||||
// This case could be hit for both Linux users who don't have secure storage configured
|
||||
@@ -591,7 +614,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// Fall back to disk storage for refresh token
|
||||
decryptedRefreshToken = await this.singleUserStateProvider
|
||||
.get(userId, REFRESH_TOKEN_DISK)
|
||||
.update((_) => refreshToken);
|
||||
.update((_) => refreshToken, {
|
||||
shouldUpdate: (previousValue) => previousValue !== refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
return decryptedRefreshToken;
|
||||
@@ -599,7 +624,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
case TokenStorageLocation.Disk:
|
||||
return await this.singleUserStateProvider
|
||||
.get(userId, REFRESH_TOKEN_DISK)
|
||||
.update((_) => refreshToken);
|
||||
.update((_) => refreshToken, {
|
||||
shouldUpdate: (previousValue) => previousValue !== refreshToken,
|
||||
});
|
||||
|
||||
case TokenStorageLocation.Memory:
|
||||
return await this.singleUserStateProvider
|
||||
@@ -608,9 +635,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getRefreshToken(userId?: UserId): Promise<string | null> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getRefreshToken(userId: UserId): Promise<string | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
@@ -679,7 +704,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
|
||||
// Platform doesn't support secure storage, so use state provider implementation
|
||||
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
|
||||
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
|
||||
await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null, {
|
||||
shouldUpdate: (previousValue) => previousValue !== null,
|
||||
});
|
||||
}
|
||||
|
||||
async setClientId(
|
||||
@@ -721,9 +748,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getClientId(userId?: UserId): Promise<string | undefined> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getClientId(userId: UserId): Promise<string | undefined> {
|
||||
if (!userId) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -797,9 +822,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getClientSecret(userId?: UserId): Promise<string | undefined> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
async getClientSecret(userId: UserId): Promise<string | undefined> {
|
||||
if (!userId) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -890,7 +913,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
if (Utils.isGuid(tokenOrUserId)) {
|
||||
token = await this.getAccessToken(tokenOrUserId as UserId);
|
||||
} else {
|
||||
token ??= await this.getAccessToken();
|
||||
token ??= await this.getAccessToken(
|
||||
await firstValueFrom(this.activeUserIdGlobalState.state$),
|
||||
);
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
@@ -903,10 +928,10 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// TODO: PM-6678- tech debt - consider consolidating the return types of all these access
|
||||
// token data retrieval methods to return null if something goes wrong instead of throwing an error.
|
||||
|
||||
async getTokenExpirationDate(): Promise<Date | null> {
|
||||
async getTokenExpirationDate(userId: UserId): Promise<Date | null> {
|
||||
let decoded: DecodedAccessToken;
|
||||
try {
|
||||
decoded = await this.decodeAccessToken();
|
||||
decoded = await this.decodeAccessToken(userId);
|
||||
} catch (error) {
|
||||
throw new Error("Failed to decode access token: " + error.message);
|
||||
}
|
||||
@@ -922,8 +947,8 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return expirationDate;
|
||||
}
|
||||
|
||||
async tokenSecondsRemaining(offsetSeconds = 0): Promise<number> {
|
||||
const date = await this.getTokenExpirationDate();
|
||||
async tokenSecondsRemaining(userId: UserId, offsetSeconds = 0): Promise<number> {
|
||||
const date = await this.getTokenExpirationDate(userId);
|
||||
if (date == null) {
|
||||
return 0;
|
||||
}
|
||||
@@ -932,8 +957,8 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return Math.round(msRemaining / 1000);
|
||||
}
|
||||
|
||||
async tokenNeedsRefresh(minutes = 5): Promise<boolean> {
|
||||
const sRemaining = await this.tokenSecondsRemaining();
|
||||
async tokenNeedsRefresh(userId: UserId, minutes = 5): Promise<boolean> {
|
||||
const sRemaining = await this.tokenSecondsRemaining(userId);
|
||||
return sRemaining < 60 * minutes;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import { of } from "rxjs";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
PinLockType,
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction";
|
||||
import { PinLockType } from "../../../key-management/pin/pin.service.implementation";
|
||||
import { VaultTimeoutSettingsService } from "../../../key-management/vault-timeout";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PinServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin.service.abstraction";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
import { UserId } from "../../../types/guid";
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("WebAuthnLoginService", () => {
|
||||
|
||||
// We must do this to make the mocked classes available for all the
|
||||
// assertCredential(...) tests.
|
||||
global.PublicKeyCredential = MockPublicKeyCredential;
|
||||
global.PublicKeyCredential = MockPublicKeyCredential as any;
|
||||
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
|
||||
|
||||
// Save the original navigator
|
||||
@@ -316,6 +316,10 @@ class MockPublicKeyCredential implements PublicKeyCredential {
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
function buildCredentialAssertionOptions(): WebAuthnLoginCredentialAssertionOptionsView {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
||||
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
|
||||
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
|
||||
import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
|
||||
import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
|
||||
import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request";
|
||||
import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request";
|
||||
import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
|
||||
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
|
||||
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
|
||||
import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
|
||||
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
|
||||
import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response";
|
||||
import {
|
||||
TwoFactorWebAuthnResponse,
|
||||
ChallengeResponse,
|
||||
} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
|
||||
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { TwoFactorApiService } from "./two-factor-api.service";
|
||||
|
||||
export class DefaultTwoFactorApiService implements TwoFactorApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
// Providers
|
||||
|
||||
async getTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>> {
|
||||
const response = await this.apiService.send("GET", "/two-factor", null, true, true);
|
||||
return new ListResponse(response, TwoFactorProviderResponse);
|
||||
}
|
||||
|
||||
async getTwoFactorOrganizationProviders(
|
||||
organizationId: string,
|
||||
): Promise<ListResponse<TwoFactorProviderResponse>> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
`/organizations/${organizationId}/two-factor`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ListResponse(response, TwoFactorProviderResponse);
|
||||
}
|
||||
|
||||
// Authenticator (TOTP)
|
||||
|
||||
async getTwoFactorAuthenticator(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorAuthenticatorResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/two-factor/get-authenticator",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorAuthenticatorResponse(response);
|
||||
}
|
||||
|
||||
async putTwoFactorAuthenticator(
|
||||
request: UpdateTwoFactorAuthenticatorRequest,
|
||||
): Promise<TwoFactorAuthenticatorResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
"/two-factor/authenticator",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorAuthenticatorResponse(response);
|
||||
}
|
||||
|
||||
async deleteTwoFactorAuthenticator(
|
||||
request: DisableTwoFactorAuthenticatorRequest,
|
||||
): Promise<TwoFactorProviderResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"DELETE",
|
||||
"/two-factor/authenticator",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorProviderResponse(response);
|
||||
}
|
||||
|
||||
// Email
|
||||
|
||||
async getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/two-factor/get-email",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorEmailResponse(response);
|
||||
}
|
||||
|
||||
async postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any> {
|
||||
return this.apiService.send("POST", "/two-factor/send-email", request, true, false);
|
||||
}
|
||||
|
||||
async postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any> {
|
||||
return this.apiService.send("POST", "/two-factor/send-email-login", request, false, false);
|
||||
}
|
||||
|
||||
async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse> {
|
||||
const response = await this.apiService.send("PUT", "/two-factor/email", request, true, true);
|
||||
return new TwoFactorEmailResponse(response);
|
||||
}
|
||||
|
||||
// Duo
|
||||
|
||||
async getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse> {
|
||||
const response = await this.apiService.send("POST", "/two-factor/get-duo", request, true, true);
|
||||
return new TwoFactorDuoResponse(response);
|
||||
}
|
||||
|
||||
async getTwoFactorOrganizationDuo(
|
||||
organizationId: string,
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorDuoResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
`/organizations/${organizationId}/two-factor/get-duo`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorDuoResponse(response);
|
||||
}
|
||||
|
||||
async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse> {
|
||||
const response = await this.apiService.send("PUT", "/two-factor/duo", request, true, true);
|
||||
return new TwoFactorDuoResponse(response);
|
||||
}
|
||||
|
||||
async putTwoFactorOrganizationDuo(
|
||||
organizationId: string,
|
||||
request: UpdateTwoFactorDuoRequest,
|
||||
): Promise<TwoFactorDuoResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
`/organizations/${organizationId}/two-factor/duo`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorDuoResponse(response);
|
||||
}
|
||||
|
||||
// YubiKey
|
||||
|
||||
async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise<TwoFactorYubiKeyResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/two-factor/get-yubikey",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorYubiKeyResponse(response);
|
||||
}
|
||||
|
||||
async putTwoFactorYubiKey(
|
||||
request: UpdateTwoFactorYubikeyOtpRequest,
|
||||
): Promise<TwoFactorYubiKeyResponse> {
|
||||
const response = await this.apiService.send("PUT", "/two-factor/yubikey", request, true, true);
|
||||
return new TwoFactorYubiKeyResponse(response);
|
||||
}
|
||||
|
||||
// WebAuthn
|
||||
|
||||
async getTwoFactorWebAuthn(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorWebAuthnResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/two-factor/get-webauthn",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorWebAuthnResponse(response);
|
||||
}
|
||||
|
||||
async getTwoFactorWebAuthnChallenge(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<ChallengeResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/two-factor/get-webauthn-challenge",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ChallengeResponse(response);
|
||||
}
|
||||
|
||||
async putTwoFactorWebAuthn(
|
||||
request: UpdateTwoFactorWebAuthnRequest,
|
||||
): Promise<TwoFactorWebAuthnResponse> {
|
||||
const deviceResponse = request.deviceResponse.response as AuthenticatorAttestationResponse;
|
||||
const body: any = Object.assign({}, request);
|
||||
|
||||
body.deviceResponse = {
|
||||
id: request.deviceResponse.id,
|
||||
rawId: btoa(request.deviceResponse.id),
|
||||
type: request.deviceResponse.type,
|
||||
extensions: request.deviceResponse.getClientExtensionResults(),
|
||||
response: {
|
||||
AttestationObject: Utils.fromBufferToB64(deviceResponse.attestationObject),
|
||||
clientDataJson: Utils.fromBufferToB64(deviceResponse.clientDataJSON),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.apiService.send("PUT", "/two-factor/webauthn", body, true, true);
|
||||
return new TwoFactorWebAuthnResponse(response);
|
||||
}
|
||||
|
||||
async deleteTwoFactorWebAuthn(
|
||||
request: UpdateTwoFactorWebAuthnDeleteRequest,
|
||||
): Promise<TwoFactorWebAuthnResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"DELETE",
|
||||
"/two-factor/webauthn",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorWebAuthnResponse(response);
|
||||
}
|
||||
|
||||
// Recovery Code
|
||||
|
||||
async getTwoFactorRecover(request: SecretVerificationRequest): Promise<TwoFactorRecoverResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/two-factor/get-recover",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorRecoverResponse(response);
|
||||
}
|
||||
|
||||
// Disable
|
||||
|
||||
async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise<TwoFactorProviderResponse> {
|
||||
const response = await this.apiService.send("PUT", "/two-factor/disable", request, true, true);
|
||||
return new TwoFactorProviderResponse(response);
|
||||
}
|
||||
|
||||
async putTwoFactorOrganizationDisable(
|
||||
organizationId: string,
|
||||
request: TwoFactorProviderRequest,
|
||||
): Promise<TwoFactorProviderResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"PUT",
|
||||
`/organizations/${organizationId}/two-factor/disable`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new TwoFactorProviderResponse(response);
|
||||
}
|
||||
}
|
||||
2
libs/common/src/auth/two-factor/index.ts
Normal file
2
libs/common/src/auth/two-factor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TwoFactorApiService } from "./two-factor-api.service";
|
||||
export { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
|
||||
697
libs/common/src/auth/two-factor/two-factor-api.service.spec.ts
Normal file
697
libs/common/src/auth/two-factor/two-factor-api.service.spec.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
||||
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
|
||||
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
|
||||
import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
|
||||
import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
|
||||
import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request";
|
||||
import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request";
|
||||
import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
|
||||
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
|
||||
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
|
||||
import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
|
||||
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
|
||||
import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response";
|
||||
import {
|
||||
TwoFactorWebAuthnResponse,
|
||||
ChallengeResponse,
|
||||
} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
|
||||
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
|
||||
|
||||
describe("TwoFactorApiService", () => {
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let twoFactorApiService: DefaultTwoFactorApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
twoFactorApiService = new DefaultTwoFactorApiService(apiService);
|
||||
});
|
||||
|
||||
describe("Two-Factor Providers", () => {
|
||||
describe("getTwoFactorProviders", () => {
|
||||
it("retrieves all enabled two-factor providers for the current user", async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{ Type: 0, Enabled: true },
|
||||
{ Type: 1, Enabled: true },
|
||||
],
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorProviders();
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith("GET", "/two-factor", null, true, true);
|
||||
expect(result).toBeInstanceOf(ListResponse);
|
||||
expect(result.data).toHaveLength(2);
|
||||
for (let i = 0; i < result.data.length; i++) {
|
||||
expect(result.data[i]).toBeInstanceOf(TwoFactorProviderResponse);
|
||||
expect(result.data[i].type).toBe(i);
|
||||
expect(result.data[i].enabled).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTwoFactorOrganizationProviders", () => {
|
||||
it("retrieves all enabled two-factor providers for a specific organization", async () => {
|
||||
const organizationId = "org-123";
|
||||
const mockResponse = {
|
||||
data: [{ Type: 6, Enabled: true }],
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorOrganizationProviders(organizationId);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/organizations/${organizationId}/two-factor`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(ListResponse);
|
||||
expect(result.data[0]).toBeInstanceOf(TwoFactorProviderResponse);
|
||||
expect(result.data[0].enabled).toBe(true);
|
||||
expect(result.data[0].type).toBe(6); // Duo
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticator (TOTP) APIs", () => {
|
||||
describe("getTwoFactorAuthenticator", () => {
|
||||
it("retrieves authenticator configuration with secret key after user verification", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: false,
|
||||
Key: "MFRGGZDFMZTWQ2LK",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorAuthenticator(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-authenticator",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorAuthenticator", () => {
|
||||
it("enables authenticator after validating the provided token", async () => {
|
||||
const request = new UpdateTwoFactorAuthenticatorRequest();
|
||||
request.token = "123456";
|
||||
request.key = "MFRGGZDFMZTWQ2LK";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Key: "MFRGGZDFMZTWQ2LK",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorAuthenticator(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/two-factor/authenticator",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.key).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTwoFactorAuthenticator", () => {
|
||||
it("disables authenticator two-factor authentication", async () => {
|
||||
const request = new DisableTwoFactorAuthenticatorRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: false,
|
||||
Type: 0,
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.deleteTwoFactorAuthenticator(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/two-factor/authenticator",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorProviderResponse);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.type).toBe(0); // Authenticator
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email APIs", () => {
|
||||
describe("getTwoFactorEmail", () => {
|
||||
it("retrieves email two-factor configuration after user verification", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Email: "user@example.com",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorEmail(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-email",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorEmailResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.email).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("postTwoFactorEmailSetup", () => {
|
||||
it("sends verification code to email address during two-factor setup", async () => {
|
||||
const request = new TwoFactorEmailRequest();
|
||||
request.email = "user@example.com";
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
|
||||
await twoFactorApiService.postTwoFactorEmailSetup(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/send-email",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postTwoFactorEmail", () => {
|
||||
it("sends two-factor authentication code during login flow", async () => {
|
||||
const request = new TwoFactorEmailRequest();
|
||||
request.email = "user@example.com";
|
||||
// Note: masterPasswordHash not required for login flow
|
||||
|
||||
await twoFactorApiService.postTwoFactorEmail(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/send-email-login",
|
||||
request,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorEmail", () => {
|
||||
it("enables email two-factor after validating the verification code", async () => {
|
||||
const request = new UpdateTwoFactorEmailRequest();
|
||||
request.email = "user@example.com";
|
||||
request.token = "verification-code";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Email: "user@example.com",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorEmail(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/two-factor/email",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorEmailResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.email).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duo APIs", () => {
|
||||
describe("getTwoFactorDuo", () => {
|
||||
it("retrieves Duo configuration for premium user after verification", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Host: "api-abc123.duosecurity.com",
|
||||
ClientId: "DI9ABC1DEFGH2JKL",
|
||||
ClientSecret: "client******",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorDuo(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-duo",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorDuoResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.host).toBeDefined();
|
||||
expect(result.clientId).toBeDefined();
|
||||
expect(result.clientSecret).toContain("******");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTwoFactorOrganizationDuo", () => {
|
||||
it("retrieves Duo configuration for organization with admin permissions", async () => {
|
||||
const organizationId = "org-123";
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Host: "api-xyz789.duosecurity.com",
|
||||
ClientId: "DI4XYZ9MNOP3QRS",
|
||||
ClientSecret: "orgcli******",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorOrganizationDuo(
|
||||
organizationId,
|
||||
request,
|
||||
);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
`/organizations/${organizationId}/two-factor/get-duo`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorDuoResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.host).toBeDefined();
|
||||
expect(result.clientId).toBeDefined();
|
||||
expect(result.clientSecret).toContain("******");
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorDuo", () => {
|
||||
it("enables Duo two-factor for premium user with valid integration details", async () => {
|
||||
const request = new UpdateTwoFactorDuoRequest();
|
||||
request.host = "api-abc123.duosecurity.com";
|
||||
request.clientId = "DI9ABC1DEFGH2JKL";
|
||||
request.clientSecret = "client-secret-value-here";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Host: "api-abc123.duosecurity.com",
|
||||
ClientId: "DI9ABC1DEFGH2JKL",
|
||||
ClientSecret: "client******",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorDuo(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith("PUT", "/two-factor/duo", request, true, true);
|
||||
expect(result).toBeInstanceOf(TwoFactorDuoResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.host).toBeDefined();
|
||||
expect(result.clientId).toBeDefined();
|
||||
expect(result.clientSecret).toContain("******");
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorOrganizationDuo", () => {
|
||||
it("enables organization-level Duo with policy management permissions", async () => {
|
||||
const organizationId = "org-123";
|
||||
const request = new UpdateTwoFactorDuoRequest();
|
||||
request.host = "api-xyz789.duosecurity.com";
|
||||
request.clientId = "DI4XYZ9MNOP3QRS";
|
||||
request.clientSecret = "orgcli-secret-value-here";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Host: "api-xyz789.duosecurity.com",
|
||||
ClientId: "DI4XYZ9MNOP3QRS",
|
||||
ClientSecret: "orgcli******",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorOrganizationDuo(
|
||||
organizationId,
|
||||
request,
|
||||
);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
`/organizations/${organizationId}/two-factor/duo`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorDuoResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.host).toBeDefined();
|
||||
expect(result.clientId).toBeDefined();
|
||||
expect(result.clientSecret).toContain("******");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("YubiKey APIs", () => {
|
||||
describe("getTwoFactorYubiKey", () => {
|
||||
it("retrieves YubiKey configuration for premium user after verification", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Key1: "cccccccccccc",
|
||||
Key2: "dddddddddddd",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorYubiKey(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-yubikey",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.key1).toBeDefined();
|
||||
expect(result.key2).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorYubiKey", () => {
|
||||
it("enables YubiKey two-factor for premium user after validating device OTPs", async () => {
|
||||
const request = new UpdateTwoFactorYubikeyOtpRequest();
|
||||
request.key1 = "ccccccccccccjkhbhbhrkcitringjkrjirfjuunlnlvcghnkrtgfj";
|
||||
request.key2 = "ddddddddddddvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Key1: "cccccccccccc",
|
||||
Key2: "dddddddddddd",
|
||||
Nfc: false,
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorYubiKey(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/two-factor/yubikey",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.key1).toBeDefined();
|
||||
expect(result.key2).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebAuthn APIs", () => {
|
||||
describe("getTwoFactorWebAuthn", () => {
|
||||
it("retrieves list of registered WebAuthn credentials after verification", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Keys: [
|
||||
{ Name: "YubiKey 5", Id: 1, Migrated: false },
|
||||
{ Name: "Security Key", Id: 2, Migrated: true },
|
||||
],
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorWebAuthn(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-webauthn",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.keys).toHaveLength(2);
|
||||
result.keys.forEach((key) => {
|
||||
expect(key).toHaveProperty("name");
|
||||
expect(key).toHaveProperty("id");
|
||||
expect(key).toHaveProperty("migrated");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTwoFactorWebAuthnChallenge", () => {
|
||||
it("obtains cryptographic challenge for WebAuthn credential registration", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
challenge: "Y2hhbGxlbmdlLXN0cmluZw",
|
||||
rp: { name: "Bitwarden" },
|
||||
user: {
|
||||
id: "dXNlci1pZA",
|
||||
name: "user@example.com",
|
||||
displayName: "User",
|
||||
},
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256
|
||||
excludeCredentials: [] as PublicKeyCredentialDescriptor[],
|
||||
timeout: 60000,
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-webauthn-challenge",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(ChallengeResponse);
|
||||
expect(result.challenge).toBeDefined();
|
||||
expect(result.rp).toHaveProperty("name", "Bitwarden");
|
||||
expect(result.user).toHaveProperty("id");
|
||||
expect(result.user).toHaveProperty("name");
|
||||
expect(result.user).toHaveProperty("displayName", "User");
|
||||
expect(result.pubKeyCredParams).toHaveLength(1);
|
||||
expect(Number(result.timeout)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorWebAuthn", () => {
|
||||
it("registers new WebAuthn credential by serializing browser credential to JSON", async () => {
|
||||
const mockAttestationResponse: Partial<AuthenticatorAttestationResponse> = {
|
||||
clientDataJSON: new Uint8Array([1, 2, 3]).buffer,
|
||||
attestationObject: new Uint8Array([4, 5, 6]).buffer,
|
||||
};
|
||||
|
||||
const mockCredential: Partial<PublicKeyCredential> = {
|
||||
id: "credential-id",
|
||||
type: "public-key",
|
||||
response: mockAttestationResponse as AuthenticatorAttestationResponse,
|
||||
getClientExtensionResults: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
const request = new UpdateTwoFactorWebAuthnRequest();
|
||||
request.deviceResponse = mockCredential as PublicKeyCredential;
|
||||
request.name = "My Security Key";
|
||||
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Keys: [{ Name: "My Security Key", Id: 1, Migrated: false }],
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorWebAuthn(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/two-factor/webauthn",
|
||||
expect.objectContaining({
|
||||
name: "My Security Key",
|
||||
deviceResponse: expect.objectContaining({
|
||||
id: "credential-id",
|
||||
rawId: expect.any(String), // base64 encoded
|
||||
type: "public-key",
|
||||
extensions: {},
|
||||
response: expect.objectContaining({
|
||||
AttestationObject: expect.any(String), // base64 encoded
|
||||
clientDataJson: expect.any(String), // base64 encoded
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.keys).toHaveLength(1);
|
||||
expect(result.keys[0].name).toBeDefined();
|
||||
expect(result.keys[0].id).toBeDefined();
|
||||
expect(result.keys[0].migrated).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves original request object without mutation during serialization", async () => {
|
||||
const mockAttestationResponse: Partial<AuthenticatorAttestationResponse> = {
|
||||
clientDataJSON: new Uint8Array([1, 2, 3]).buffer,
|
||||
attestationObject: new Uint8Array([4, 5, 6]).buffer,
|
||||
};
|
||||
|
||||
const mockCredential: Partial<PublicKeyCredential> = {
|
||||
id: "credential-id",
|
||||
type: "public-key",
|
||||
response: mockAttestationResponse as AuthenticatorAttestationResponse,
|
||||
getClientExtensionResults: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
const request = new UpdateTwoFactorWebAuthnRequest();
|
||||
request.deviceResponse = mockCredential as PublicKeyCredential;
|
||||
request.name = "My Security Key";
|
||||
|
||||
const originalDeviceResponse = request.deviceResponse;
|
||||
apiService.send.mockResolvedValue({ enabled: true, keys: [] });
|
||||
|
||||
await twoFactorApiService.putTwoFactorWebAuthn(request);
|
||||
|
||||
// Do not mutate the original request object
|
||||
expect(request.deviceResponse).toBe(originalDeviceResponse);
|
||||
expect(request.deviceResponse.response).toBe(mockAttestationResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTwoFactorWebAuthn", () => {
|
||||
it("removes specific WebAuthn credential while preserving other registered keys", async () => {
|
||||
const request = new UpdateTwoFactorWebAuthnDeleteRequest();
|
||||
request.id = 1;
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: true,
|
||||
Keys: [{ Name: "Security Key", Id: 2, Migrated: true }], // Key with id:1 removed
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.deleteTwoFactorWebAuthn(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/two-factor/webauthn",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse);
|
||||
expect(result.keys).toHaveLength(1);
|
||||
expect(result.keys[0].id).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recovery Code APIs", () => {
|
||||
describe("getTwoFactorRecover", () => {
|
||||
it("retrieves recovery code for regaining access when two-factor is unavailable", async () => {
|
||||
const request = new SecretVerificationRequest();
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Code: "ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ12",
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.getTwoFactorRecover(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/two-factor/get-recover",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorRecoverResponse);
|
||||
expect(result.code).toBeDefined();
|
||||
expect(result.code).toMatch(/^[A-Z0-9-]+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disable APIs", () => {
|
||||
describe("putTwoFactorDisable", () => {
|
||||
it("disables specified two-factor provider for current user", async () => {
|
||||
const request = new TwoFactorProviderRequest();
|
||||
request.type = 0; // Authenticator
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: false,
|
||||
Type: 0,
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorDisable(request);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
"/two-factor/disable",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorProviderResponse);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.type).toBe(0); // Authenticator
|
||||
});
|
||||
});
|
||||
|
||||
describe("putTwoFactorOrganizationDisable", () => {
|
||||
it("disables two-factor provider for organization with policy management permissions", async () => {
|
||||
const organizationId = "org-123";
|
||||
const request = new TwoFactorProviderRequest();
|
||||
request.type = 6; // Duo
|
||||
request.masterPasswordHash = "master-password-hash";
|
||||
const mockResponse = {
|
||||
Enabled: false,
|
||||
Type: 6,
|
||||
};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await twoFactorApiService.putTwoFactorOrganizationDisable(
|
||||
organizationId,
|
||||
request,
|
||||
);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
`/organizations/${organizationId}/two-factor/disable`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toBeInstanceOf(TwoFactorProviderResponse);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.type).toBe(6); // Duo
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
292
libs/common/src/auth/two-factor/two-factor-api.service.ts
Normal file
292
libs/common/src/auth/two-factor/two-factor-api.service.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
|
||||
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
|
||||
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
|
||||
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
|
||||
import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
|
||||
import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
|
||||
import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request";
|
||||
import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request";
|
||||
import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
|
||||
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
|
||||
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
|
||||
import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
|
||||
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
|
||||
import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response";
|
||||
import {
|
||||
ChallengeResponse,
|
||||
TwoFactorWebAuthnResponse,
|
||||
} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
|
||||
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
/**
|
||||
* Service abstraction for two-factor authentication API operations.
|
||||
* Provides methods for managing various two-factor authentication providers including
|
||||
* authenticator apps (TOTP), email, Duo, YubiKey, WebAuthn (FIDO2), and recovery codes.
|
||||
*
|
||||
* All methods that retrieve sensitive configuration data require user verification via
|
||||
* SecretVerificationRequest. Premium-tier providers (Duo, YubiKey) require an active
|
||||
* premium subscription. Organization-level methods require appropriate administrative permissions.
|
||||
*/
|
||||
export abstract class TwoFactorApiService {
|
||||
/**
|
||||
* Gets a list of all enabled two-factor providers for the current user.
|
||||
*
|
||||
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
|
||||
*/
|
||||
abstract getTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>>;
|
||||
|
||||
/**
|
||||
* Gets a list of all enabled two-factor providers for an organization.
|
||||
* Requires organization administrator permissions.
|
||||
*
|
||||
* @param organizationId The ID of the organization.
|
||||
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
|
||||
*/
|
||||
abstract getTwoFactorOrganizationProviders(
|
||||
organizationId: string,
|
||||
): Promise<ListResponse<TwoFactorProviderResponse>>;
|
||||
|
||||
/**
|
||||
* Gets the authenticator (TOTP) two-factor configuration for the current user.
|
||||
* Returns the shared secret key and user verification token needed for setup.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the authenticator configuration including the secret key.
|
||||
*/
|
||||
abstract getTwoFactorAuthenticator(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorAuthenticatorResponse>;
|
||||
|
||||
/**
|
||||
* Gets the email two-factor configuration for the current user.
|
||||
* Returns the configured email address and enabled status.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the email two-factor configuration.
|
||||
*/
|
||||
abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse>;
|
||||
|
||||
/**
|
||||
* Gets the Duo two-factor configuration for the current user.
|
||||
* Returns Duo integration configuration details.
|
||||
* Requires user verification and an active premium subscription.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the Duo configuration.
|
||||
*/
|
||||
abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse>;
|
||||
|
||||
/**
|
||||
* Gets the Duo two-factor configuration for an organization.
|
||||
* Returns organization-level Duo integration configuration.
|
||||
* Requires user verification and organization policy management permissions.
|
||||
*
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the organization Duo configuration.
|
||||
*/
|
||||
abstract getTwoFactorOrganizationDuo(
|
||||
organizationId: string,
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorDuoResponse>;
|
||||
|
||||
/**
|
||||
* Gets the YubiKey OTP two-factor configuration for the current user.
|
||||
* Returns configured YubiKey device identifiers (multiple keys supported).
|
||||
* Requires user verification and an active premium subscription.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the YubiKey configuration.
|
||||
*/
|
||||
abstract getTwoFactorYubiKey(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorYubiKeyResponse>;
|
||||
|
||||
/**
|
||||
* Gets the WebAuthn (FIDO2) two-factor configuration for the current user.
|
||||
* Returns a list of registered WebAuthn credentials with their names and IDs.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the WebAuthn configuration including registered credentials.
|
||||
*/
|
||||
abstract getTwoFactorWebAuthn(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorWebAuthnResponse>;
|
||||
|
||||
/**
|
||||
* Gets a WebAuthn challenge for registering a new WebAuthn credential.
|
||||
* This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge
|
||||
* required for credential creation. The challenge is used by the browser's WebAuthn API.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the credential creation options containing the challenge.
|
||||
*/
|
||||
abstract getTwoFactorWebAuthnChallenge(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<ChallengeResponse>;
|
||||
|
||||
/**
|
||||
* Gets the recovery code configuration for the current user.
|
||||
* Returns the recovery code that can be used to regain access if other two-factor methods are unavailable.
|
||||
* The recovery code should be stored securely by the user.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The secret verification request to authorize the operation.
|
||||
* @returns A promise that resolves to the recovery code configuration.
|
||||
*/
|
||||
abstract getTwoFactorRecover(
|
||||
request: SecretVerificationRequest,
|
||||
): Promise<TwoFactorRecoverResponse>;
|
||||
|
||||
/**
|
||||
* Enables or updates the authenticator (TOTP) two-factor provider.
|
||||
* Validates the provided token against the shared secret before enabling.
|
||||
* The token must be generated by an authenticator app using the secret key.
|
||||
*
|
||||
* @param request The request containing the authenticator configuration and verification token.
|
||||
* @returns A promise that resolves to the updated authenticator configuration.
|
||||
*/
|
||||
abstract putTwoFactorAuthenticator(
|
||||
request: UpdateTwoFactorAuthenticatorRequest,
|
||||
): Promise<TwoFactorAuthenticatorResponse>;
|
||||
|
||||
/**
|
||||
* Disables the authenticator (TOTP) two-factor provider for the current user.
|
||||
* Requires user verification token to confirm the operation.
|
||||
*
|
||||
* @param request The request containing verification credentials to disable the provider.
|
||||
* @returns A promise that resolves to the updated provider status.
|
||||
*/
|
||||
abstract deleteTwoFactorAuthenticator(
|
||||
request: DisableTwoFactorAuthenticatorRequest,
|
||||
): Promise<TwoFactorProviderResponse>;
|
||||
|
||||
/**
|
||||
* Enables or updates the email two-factor provider.
|
||||
* Validates the email verification token sent via postTwoFactorEmailSetup before enabling.
|
||||
* The token must match the code sent to the specified email address.
|
||||
*
|
||||
* @param request The request containing the email configuration and verification token.
|
||||
* @returns A promise that resolves to the updated email two-factor configuration.
|
||||
*/
|
||||
abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse>;
|
||||
|
||||
/**
|
||||
* Enables or updates the Duo two-factor provider for the current user.
|
||||
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
|
||||
* Requires user verification and an active premium subscription.
|
||||
*
|
||||
* @param request The request containing the Duo integration configuration.
|
||||
* @returns A promise that resolves to the updated Duo configuration.
|
||||
*/
|
||||
abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse>;
|
||||
|
||||
/**
|
||||
* Enables or updates the Duo two-factor provider for an organization.
|
||||
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
|
||||
* Requires user verification and organization policy management permissions.
|
||||
*
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param request The request containing the Duo integration configuration.
|
||||
* @returns A promise that resolves to the updated organization Duo configuration.
|
||||
*/
|
||||
abstract putTwoFactorOrganizationDuo(
|
||||
organizationId: string,
|
||||
request: UpdateTwoFactorDuoRequest,
|
||||
): Promise<TwoFactorDuoResponse>;
|
||||
|
||||
/**
|
||||
* Enables or updates the YubiKey OTP two-factor provider.
|
||||
* Validates each provided YubiKey by testing an OTP from the device.
|
||||
* Supports up to 5 YubiKey devices. Empty key slots are allowed.
|
||||
* Requires user verification and an active premium subscription.
|
||||
* Includes a 2-second delay on validation failure to prevent timing attacks.
|
||||
*
|
||||
* @param request The request containing YubiKey device identifiers and test OTPs.
|
||||
* @returns A promise that resolves to the updated YubiKey configuration.
|
||||
*/
|
||||
abstract putTwoFactorYubiKey(
|
||||
request: UpdateTwoFactorYubikeyOtpRequest,
|
||||
): Promise<TwoFactorYubiKeyResponse>;
|
||||
|
||||
/**
|
||||
* Registers a new WebAuthn (FIDO2) credential for two-factor authentication.
|
||||
* Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow.
|
||||
* The device response contains the signed challenge from the authenticator device.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The request containing the WebAuthn credential creation response from the browser.
|
||||
* @returns A promise that resolves to the updated WebAuthn configuration with the new credential.
|
||||
*/
|
||||
abstract putTwoFactorWebAuthn(
|
||||
request: UpdateTwoFactorWebAuthnRequest,
|
||||
): Promise<TwoFactorWebAuthnResponse>;
|
||||
|
||||
/**
|
||||
* Removes a specific WebAuthn (FIDO2) credential from the user's account.
|
||||
* The credential will no longer be usable for two-factor authentication.
|
||||
* Other registered WebAuthn credentials remain active.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The request containing the credential ID to remove.
|
||||
* @returns A promise that resolves to the updated WebAuthn configuration.
|
||||
*/
|
||||
abstract deleteTwoFactorWebAuthn(
|
||||
request: UpdateTwoFactorWebAuthnDeleteRequest,
|
||||
): Promise<TwoFactorWebAuthnResponse>;
|
||||
|
||||
/**
|
||||
* Disables a specific two-factor provider for the current user.
|
||||
* The provider will no longer be required or usable for authentication.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The request specifying which provider type to disable.
|
||||
* @returns A promise that resolves to the updated provider status.
|
||||
*/
|
||||
abstract putTwoFactorDisable(
|
||||
request: TwoFactorProviderRequest,
|
||||
): Promise<TwoFactorProviderResponse>;
|
||||
|
||||
/**
|
||||
* Disables a specific two-factor provider for an organization.
|
||||
* The provider will no longer be available for organization members.
|
||||
* Requires user verification and organization policy management permissions.
|
||||
*
|
||||
* @param organizationId The ID of the organization.
|
||||
* @param request The request specifying which provider type to disable.
|
||||
* @returns A promise that resolves to the updated provider status.
|
||||
*/
|
||||
abstract putTwoFactorOrganizationDisable(
|
||||
organizationId: string,
|
||||
request: TwoFactorProviderRequest,
|
||||
): Promise<TwoFactorProviderResponse>;
|
||||
|
||||
/**
|
||||
* Initiates email two-factor setup by sending a verification code to the specified email address.
|
||||
* This is the first step in enabling email two-factor authentication.
|
||||
* The verification code must be provided to putTwoFactorEmail to complete setup.
|
||||
* Only used during initial configuration, not during login flows.
|
||||
* Requires user verification via master password or OTP.
|
||||
*
|
||||
* @param request The request containing the email address for two-factor setup.
|
||||
* @returns A promise that resolves when the verification email has been sent.
|
||||
*/
|
||||
abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any>;
|
||||
|
||||
/**
|
||||
* Sends a two-factor authentication code via email during the login flow.
|
||||
* Supports multiple authentication contexts including standard login, SSO, and passwordless flows.
|
||||
* This is used to deliver codes during authentication, not during initial setup.
|
||||
* May be called without authentication for login scenarios.
|
||||
*
|
||||
* @param request The request to send the two-factor code, optionally including SSO or auth request tokens.
|
||||
* @returns A promise that resolves when the authentication email has been sent.
|
||||
*/
|
||||
abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, map, Observable, startWith, switchMap } from "rxjs";
|
||||
import { combineLatest, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
@@ -205,7 +205,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
|
||||
this.showInlineMenuCardsState = this.stateProvider.getActive(SHOW_INLINE_MENU_CARDS);
|
||||
this.showInlineMenuCards$ = combineLatest([
|
||||
this.showInlineMenuCardsState.state$.pipe(map((x) => x ?? true)),
|
||||
this.restrictedItemTypesService.restricted$.pipe(startWith([])),
|
||||
this.restrictedItemTypesService.restricted$,
|
||||
]).pipe(
|
||||
map(
|
||||
([enabled, restrictions]) =>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
@@ -10,7 +8,6 @@ import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-se
|
||||
|
||||
describe("DefaultDomainSettingsService", () => {
|
||||
let domainSettingsService: DomainSettingsService;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
|
||||
@@ -22,9 +19,7 @@ describe("DefaultDomainSettingsService", () => {
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag$.mockImplementation(() => of(false));
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService);
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
|
||||
|
||||
jest.spyOn(domainSettingsService, "getUrlEquivalentDomains");
|
||||
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user