1
0
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:
Jared Snider
2025-10-16 10:35:50 -04:00
3074 changed files with 221988 additions and 71322 deletions

View File

@@ -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>;
}

View File

@@ -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[]>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>>;
}

View File

@@ -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;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -24,4 +24,5 @@ export abstract class PolicyApiServiceAbstraction {
type: PolicyType,
request: PolicyRequest,
) => Promise<any>;
abstract putPolicyVNext: (organizationId: string, type: PolicyType, request: any) => Promise<any>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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
}

View File

@@ -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)));

View File

@@ -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;

View File

@@ -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";

View 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);
});
});
});

View File

@@ -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() {

View File

@@ -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));
}
}

View File

@@ -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;
}
};

View File

@@ -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;
}

View File

@@ -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");
}
}

View File

@@ -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);

View File

@@ -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([

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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.
*

View File

@@ -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>;
}

View File

@@ -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
![img.png](notification-architecture.png)

View File

@@ -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

View File

@@ -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
*

View File

@@ -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>;
}

View File

@@ -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;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -0,0 +1,3 @@
export const AuthServerNotificationTags = Object.freeze({
AuthRequest: "authRequest",
});

View File

@@ -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,
}

View File

@@ -1,7 +0,0 @@
export class DeviceVerificationRequest {
unknownDeviceVerificationEnabled: boolean;
constructor(unknownDeviceVerificationEnabled: boolean) {
this.unknownDeviceVerificationEnabled = unknownDeviceVerificationEnabled;
}
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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",
);
}
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}

View File

@@ -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>) {

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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"),

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from "./send-token.service";

View File

@@ -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>;
}

View File

@@ -0,0 +1,4 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";
export * from "./types";

View File

@@ -0,0 +1 @@
export * from "./send-access-token";

View File

@@ -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);
});
});

View 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);
}
}

View File

@@ -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);
}
});

View File

@@ -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,
};
}
}
}

View File

@@ -0,0 +1 @@
export * from "./default-send-token.service";

View File

@@ -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);
},
},
);

View File

@@ -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 };

View 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";

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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 };

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendHashedPasswordB64 = Opaque<string, "SendHashedPasswordB64">;

View File

@@ -0,0 +1,3 @@
import { Opaque } from "type-fest";
export type SendOtp = Opaque<string, "SendOtp">;

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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();
});
});
});

View File

@@ -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");
}
}
}
}

View File

@@ -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> {}
}

View File

@@ -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);
});
}
}

View File

@@ -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>;
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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([]);
});
});
});

View File

@@ -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());
}
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
export { TwoFactorApiService } from "./two-factor-api.service";
export { DefaultTwoFactorApiService } from "./default-two-factor-api.service";

View 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
});
});
});
});

View 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>;
}

View File

@@ -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]) =>

View File

@@ -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