mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 13:40:06 +00:00
Merge branch 'main' into passkey-window-working
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("../shared/tsconfig.libs");
|
||||
const { compilerOptions } = require("../shared/tsconfig.spec");
|
||||
|
||||
const sharedConfig = require("../shared/jest.config.ts");
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && tsc",
|
||||
"build:watch": "npm run clean && tsc -watch"
|
||||
"build:watch": "npm run clean && tsc -watch",
|
||||
"test": "jest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { ReplaySubject, combineLatest, map } from "rxjs";
|
||||
import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
||||
import { UserId } from "../src/types/guid";
|
||||
@@ -35,6 +35,8 @@ export class FakeAccountService implements AccountService {
|
||||
activeAccountSubject = new ReplaySubject<Account | null>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
|
||||
private _activeUserId: UserId;
|
||||
get activeUserId() {
|
||||
return this._activeUserId;
|
||||
@@ -42,6 +44,7 @@ export class FakeAccountService implements AccountService {
|
||||
accounts$ = this.accountsSubject.asObservable();
|
||||
activeAccount$ = this.activeAccountSubject.asObservable();
|
||||
accountActivity$ = this.accountActivitySubject.asObservable();
|
||||
accountVerifyNewDeviceLogin$ = this.accountVerifyDevicesSubject.asObservable();
|
||||
get sortedUserIds$() {
|
||||
return this.accountActivity$.pipe(
|
||||
map((activity) => {
|
||||
@@ -52,7 +55,7 @@ export class FakeAccountService implements AccountService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
get nextUpAccount$() {
|
||||
get nextUpAccount$(): Observable<Account> {
|
||||
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
|
||||
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
|
||||
@@ -67,6 +70,11 @@ export class FakeAccountService implements AccountService {
|
||||
this.activeAccountSubject.next(null);
|
||||
this.accountActivitySubject.next(accountActivity);
|
||||
}
|
||||
|
||||
setAccountVerifyNewDeviceLogin(userId: UserId, verifyNewDeviceLogin: boolean): Promise<void> {
|
||||
return this.mock.setAccountVerifyNewDeviceLogin(userId, verifyNewDeviceLogin);
|
||||
}
|
||||
|
||||
setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
|
||||
this.accountActivitySubject.next({
|
||||
...this.accountActivitySubject["_buffer"][0],
|
||||
|
||||
@@ -225,9 +225,9 @@ export class FakeStateProvider implements StateProvider {
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]> {
|
||||
): Promise<[UserId, T | null]> {
|
||||
await this.mock.setUserState(userKeyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];
|
||||
|
||||
@@ -123,7 +123,7 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.userId, state],
|
||||
@@ -131,9 +131,9 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
@@ -198,7 +198,7 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
return this.accountService.activeUserId;
|
||||
}
|
||||
|
||||
nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.userId, state],
|
||||
@@ -206,9 +206,9 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<[UserId, T]> {
|
||||
): Promise<[UserId, T | null]> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import * as matchers from "jest-extended";
|
||||
|
||||
import { toBeFulfilled, toBeResolved, toBeRejected } from "./promise-fulfilled";
|
||||
import { toAlmostEqual } from "./to-almost-equal";
|
||||
import { toContainPartialObjects } from "./to-contain-partial-objects";
|
||||
import { toEqualBuffer } from "./to-equal-buffer";
|
||||
|
||||
export * from "./to-equal-buffer";
|
||||
export * from "./to-almost-equal";
|
||||
export * from "./promise-fulfilled";
|
||||
|
||||
// add all jest-extended matchers
|
||||
expect.extend(matchers);
|
||||
|
||||
export function addCustomMatchers() {
|
||||
expect.extend({
|
||||
toEqualBuffer: toEqualBuffer,
|
||||
@@ -18,6 +14,7 @@ export function addCustomMatchers() {
|
||||
toBeFulfilled: toBeFulfilled,
|
||||
toBeResolved: toBeResolved,
|
||||
toBeRejected: toBeRejected,
|
||||
toContainPartialObjects,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,4 +56,9 @@ export interface CustomMatchers<R = unknown> {
|
||||
* @returns CustomMatcherResult indicating whether or not the test passed
|
||||
*/
|
||||
toBeRejected(withinMs?: number): Promise<R>;
|
||||
/**
|
||||
* Matches if the received array contains all the expected objects using partial matching (expect.objectContaining).
|
||||
* @param expected An array of partial objects that should be contained in the received array.
|
||||
*/
|
||||
toContainPartialObjects<T>(expected: Array<T>): R;
|
||||
}
|
||||
|
||||
77
libs/common/spec/matchers/to-contain-partial-objects.spec.ts
Normal file
77
libs/common/spec/matchers/to-contain-partial-objects.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
describe("toContainPartialObjects", () => {
|
||||
describe("matches", () => {
|
||||
it("if the array only contains the partial objects", () => {
|
||||
const actual = [
|
||||
{
|
||||
id: 1,
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "bar",
|
||||
},
|
||||
];
|
||||
|
||||
const expected = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
expect(actual).toContainPartialObjects(expected);
|
||||
});
|
||||
|
||||
it("if the array contains the partial objects and other objects", () => {
|
||||
const actual = [
|
||||
{
|
||||
id: 1,
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "bar",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "baz",
|
||||
},
|
||||
];
|
||||
|
||||
const expected = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
expect(actual).toContainPartialObjects(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doesn't match", () => {
|
||||
it("if the array does not contain any partial objects", () => {
|
||||
const actual = [
|
||||
{
|
||||
id: 1,
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "bar",
|
||||
},
|
||||
];
|
||||
|
||||
const expected = [{ id: 1, name: "Foo" }];
|
||||
|
||||
expect(actual).not.toContainPartialObjects(expected);
|
||||
});
|
||||
|
||||
it("if the array contains some but not all partial objects", () => {
|
||||
const actual = [
|
||||
{
|
||||
id: 1,
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "bar",
|
||||
},
|
||||
];
|
||||
|
||||
const expected = [{ id: 2 }, { id: 3 }];
|
||||
|
||||
expect(actual).not.toContainPartialObjects(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
libs/common/spec/matchers/to-contain-partial-objects.ts
Normal file
31
libs/common/spec/matchers/to-contain-partial-objects.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { EOL } from "os";
|
||||
|
||||
import { diff } from "jest-diff";
|
||||
|
||||
export const toContainPartialObjects: jest.CustomMatcher = function (
|
||||
received: Array<any>,
|
||||
expected: Array<any>,
|
||||
) {
|
||||
const matched = this.equals(
|
||||
received,
|
||||
expect.arrayContaining(expected.map((e) => expect.objectContaining(e))),
|
||||
);
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
message: () =>
|
||||
"Expected the received array NOT to include partial matches for all expected objects." +
|
||||
EOL +
|
||||
diff(expected, received),
|
||||
pass: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: () =>
|
||||
"Expected the received array to contain partial matches for all expected objects." +
|
||||
EOL +
|
||||
diff(expected, received),
|
||||
pass: false,
|
||||
};
|
||||
};
|
||||
76
libs/common/spec/matrix.spec.ts
Normal file
76
libs/common/spec/matrix.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Matrix } from "./matrix";
|
||||
|
||||
class TestObject {
|
||||
value: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
increment() {
|
||||
this.value++;
|
||||
}
|
||||
}
|
||||
|
||||
describe("matrix", () => {
|
||||
it("caches entries in a matrix properly with a single argument", () => {
|
||||
const mockFunction = jest.fn<TestObject, [arg1: string]>();
|
||||
const getter = Matrix.autoMockMethod(mockFunction, () => new TestObject());
|
||||
|
||||
const obj = getter("test1");
|
||||
expect(obj.value).toBe(0);
|
||||
|
||||
// Change the state of the object
|
||||
obj.increment();
|
||||
|
||||
// Should return the same instance the second time this is called
|
||||
expect(getter("test1").value).toBe(1);
|
||||
|
||||
// Using the getter should not call the mock function
|
||||
expect(mockFunction).not.toHaveBeenCalled();
|
||||
|
||||
const mockedFunctionReturn1 = mockFunction("test1");
|
||||
expect(mockedFunctionReturn1.value).toBe(1);
|
||||
|
||||
// Totally new value
|
||||
const mockedFunctionReturn2 = mockFunction("test2");
|
||||
expect(mockedFunctionReturn2.value).toBe(0);
|
||||
|
||||
expect(mockFunction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches entries in matrix properly with multiple arguments", () => {
|
||||
const mockFunction = jest.fn<TestObject, [arg1: string, arg2: number]>();
|
||||
|
||||
const getter = Matrix.autoMockMethod(mockFunction, () => {
|
||||
return new TestObject();
|
||||
});
|
||||
|
||||
const obj = getter("test1", 4);
|
||||
expect(obj.value).toBe(0);
|
||||
|
||||
obj.increment();
|
||||
|
||||
expect(getter("test1", 4).value).toBe(1);
|
||||
|
||||
expect(mockFunction("test1", 3).value).toBe(0);
|
||||
});
|
||||
|
||||
it("should give original args in creator even if it has multiple key layers", () => {
|
||||
const mockFunction = jest.fn<TestObject, [arg1: string, arg2: number, arg3: boolean]>();
|
||||
|
||||
let invoked = false;
|
||||
|
||||
const getter = Matrix.autoMockMethod(mockFunction, (args) => {
|
||||
expect(args).toHaveLength(3);
|
||||
expect(args[0]).toBe("test");
|
||||
expect(args[1]).toBe(42);
|
||||
expect(args[2]).toBe(true);
|
||||
|
||||
invoked = true;
|
||||
|
||||
return new TestObject();
|
||||
});
|
||||
|
||||
getter("test", 42, true);
|
||||
expect(invoked).toBe(true);
|
||||
});
|
||||
});
|
||||
115
libs/common/spec/matrix.ts
Normal file
115
libs/common/spec/matrix.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
type PickFirst<Array> = Array extends [infer First, ...unknown[]] ? First : never;
|
||||
|
||||
type MatrixOrValue<Array extends unknown[], Value> = Array extends []
|
||||
? Value
|
||||
: Matrix<Array, Value>;
|
||||
|
||||
type RemoveFirst<T> = T extends [unknown, ...infer Rest] ? Rest : never;
|
||||
|
||||
/**
|
||||
* A matrix is intended to manage cached values for a set of method arguments.
|
||||
*/
|
||||
export class Matrix<TKeys extends unknown[], TValue> {
|
||||
private map: Map<PickFirst<TKeys>, MatrixOrValue<RemoveFirst<TKeys>, TValue>> = new Map();
|
||||
|
||||
/**
|
||||
* This is especially useful for methods on a service that take inputs but return Observables.
|
||||
* Generally when interacting with observables in tests, you want to use a simple SubjectLike
|
||||
* type to back it instead, so that you can easily `next` values to simulate an emission.
|
||||
*
|
||||
* @param mockFunction The function to have a Matrix based implementation added to it.
|
||||
* @param creator The function to use to create the underlying value to return for the given arguments.
|
||||
* @returns A "getter" function that allows you to retrieve the backing value that is used for the given arguments.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface MyService {
|
||||
* event$(userId: UserId) => Observable<UserEvent>
|
||||
* }
|
||||
*
|
||||
* // Test
|
||||
* const myService = mock<MyService>();
|
||||
* const eventGetter = Matrix.autoMockMethod(myService.event$, (userId) => BehaviorSubject<UserEvent>());
|
||||
*
|
||||
* eventGetter("userOne").next(new UserEvent());
|
||||
* eventGetter("userTwo").next(new UserEvent());
|
||||
* ```
|
||||
*
|
||||
* This replaces a more manual way of doing things like:
|
||||
*
|
||||
* ```ts
|
||||
* const myService = mock<MyService>();
|
||||
* const userOneSubject = new BehaviorSubject<UserEvent>();
|
||||
* const userTwoSubject = new BehaviorSubject<UserEvent>();
|
||||
* myService.event$.mockImplementation((userId) => {
|
||||
* if (userId === "userOne") {
|
||||
* return userOneSubject;
|
||||
* } else if (userId === "userTwo") {
|
||||
* return userTwoSubject;
|
||||
* }
|
||||
* return new BehaviorSubject<UserEvent>();
|
||||
* });
|
||||
*
|
||||
* userOneSubject.next(new UserEvent());
|
||||
* userTwoSubject.next(new UserEvent());
|
||||
* ```
|
||||
*/
|
||||
static autoMockMethod<TReturn, TArgs extends unknown[], TActualReturn extends TReturn>(
|
||||
mockFunction: jest.Mock<TReturn, TArgs>,
|
||||
creator: (args: TArgs) => TActualReturn,
|
||||
): (...args: TArgs) => TActualReturn {
|
||||
const matrix = new Matrix<TArgs, TActualReturn>();
|
||||
|
||||
const getter = (...args: TArgs) => {
|
||||
return matrix.getOrCreateEntry(args, creator);
|
||||
};
|
||||
|
||||
mockFunction.mockImplementation(getter);
|
||||
|
||||
return getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the ability to get or create an entry in the matrix via the given args.
|
||||
*
|
||||
* @note The args are evaulated using Javascript equality so primivites work best.
|
||||
*
|
||||
* @param args The arguments to use to evaluate if an entry in the matrix exists already,
|
||||
* or a value should be created and stored with those arguments.
|
||||
* @param creator The function to call with the arguments to build a value.
|
||||
* @returns The existing entry if one already exists or a new value created with the creator param.
|
||||
*/
|
||||
getOrCreateEntry(args: TKeys, creator: (args: TKeys) => TValue): TValue {
|
||||
if (args.length === 0) {
|
||||
throw new Error("Matrix is not for you.");
|
||||
}
|
||||
|
||||
if (args.length === 1) {
|
||||
const arg = args[0] as PickFirst<TKeys>;
|
||||
if (this.map.has(arg)) {
|
||||
// Get the cached value
|
||||
return this.map.get(arg) as TValue;
|
||||
} else {
|
||||
const value = creator(args);
|
||||
// Save the value for the next time
|
||||
this.map.set(arg, value as MatrixOrValue<RemoveFirst<TKeys>, TValue>);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// There are for sure 2 or more args
|
||||
const [first, ...rest] = args as unknown as [PickFirst<TKeys>, ...RemoveFirst<TKeys>];
|
||||
|
||||
let matrix: Matrix<RemoveFirst<TKeys>, TValue> | null = null;
|
||||
|
||||
if (this.map.has(first)) {
|
||||
// We've already created a map for this argument
|
||||
matrix = this.map.get(first) as Matrix<RemoveFirst<TKeys>, TValue>;
|
||||
} else {
|
||||
matrix = new Matrix<RemoveFirst<TKeys>, TValue>();
|
||||
this.map.set(first, matrix as MatrixOrValue<RemoveFirst<TKeys>, TValue>);
|
||||
}
|
||||
|
||||
return matrix.getOrCreateEntry(rest, () => creator(args));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, Observable, Subject, Subscription, throwError, timeout } from "rxjs";
|
||||
import {
|
||||
filter,
|
||||
firstValueFrom,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
Subject,
|
||||
Subscription,
|
||||
throwError,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
/** Test class to enable async awaiting of observable emissions */
|
||||
export class ObservableTracker<T> {
|
||||
private subscription: Subscription;
|
||||
private emissionReceived = new Subject<T>();
|
||||
emissions: T[] = [];
|
||||
constructor(observable: Observable<T>) {
|
||||
|
||||
/**
|
||||
* Creates a new ObservableTracker and instantly subscribes to the given observable.
|
||||
* @param observable The observable to track
|
||||
* @param clone Whether to clone tracked emissions or not, defaults to true.
|
||||
* Cloning can be necessary if the observable emits objects that are mutated after emission. Cloning makes it
|
||||
* harder to compare the original and the tracked emission using reference equality (e.g. `expect().toBe()`).
|
||||
*/
|
||||
constructor(
|
||||
observable: Observable<T>,
|
||||
private clone = true,
|
||||
) {
|
||||
this.emissions = this.trackEmissions(observable);
|
||||
}
|
||||
|
||||
@@ -33,6 +53,19 @@ export class ObservableTracker<T> {
|
||||
);
|
||||
}
|
||||
|
||||
async expectCompletion(msTimeout = 50): Promise<void> {
|
||||
return await lastValueFrom(
|
||||
this.emissionReceived.pipe(
|
||||
filter(() => false),
|
||||
timeout({
|
||||
first: msTimeout,
|
||||
with: () => throwError(() => new Error("Timeout exceeded waiting for completion.")),
|
||||
}),
|
||||
),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
}
|
||||
|
||||
/** Awaits until the total number of emissions observed by this tracker equals or exceeds {@link count}
|
||||
* @param count The number of emissions to wait for
|
||||
*/
|
||||
@@ -48,26 +81,31 @@ export class ObservableTracker<T> {
|
||||
this.emissionReceived.subscribe((value) => {
|
||||
emissions.push(value);
|
||||
});
|
||||
this.subscription = observable.subscribe((value) => {
|
||||
if (value == null) {
|
||||
this.emissionReceived.next(null);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
this.emissionReceived.next(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
this.emissionReceived.next(value as T);
|
||||
break;
|
||||
default: {
|
||||
this.emissionReceived.next(clone(value));
|
||||
this.subscription = observable.subscribe({
|
||||
next: (value) => {
|
||||
if (value == null) {
|
||||
this.emissionReceived.next(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
this.emissionReceived.next(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
this.emissionReceived.next(value as T);
|
||||
break;
|
||||
default: {
|
||||
this.emissionReceived.next(this.clone ? clone(value) : value);
|
||||
}
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
this.emissionReceived.complete();
|
||||
},
|
||||
});
|
||||
|
||||
return emissions;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
CollectionRequest,
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionDetailsResponse,
|
||||
CollectionRequest,
|
||||
CollectionResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
|
||||
@@ -70,6 +70,7 @@ 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 { IdentityCaptchaResponse } from "../auth/models/response/identity-captcha.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";
|
||||
@@ -95,7 +96,6 @@ 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 { TaxRateResponse } from "../billing/models/response/tax-rate.response";
|
||||
import { DeleteRecoverRequest } from "../models/request/delete-recover.request";
|
||||
import { EventRequest } from "../models/request/event.request";
|
||||
import { KdfRequest } from "../models/request/kdf.request";
|
||||
@@ -137,12 +137,12 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher
|
||||
*/
|
||||
export abstract class ApiService {
|
||||
send: (
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
path: string,
|
||||
body: any,
|
||||
authed: boolean,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
) => Promise<any>;
|
||||
|
||||
@@ -152,7 +152,12 @@ export abstract class ApiService {
|
||||
| SsoTokenRequest
|
||||
| UserApiTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
|
||||
) => Promise<
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityCaptchaResponse
|
||||
| IdentityDeviceVerificationResponse
|
||||
>;
|
||||
refreshIdentityToken: () => Promise<any>;
|
||||
|
||||
getProfile: () => Promise<ProfileResponse>;
|
||||
@@ -376,7 +381,6 @@ export abstract class ApiService {
|
||||
): Promise<OrganizationConnectionResponse<TConfig>>;
|
||||
deleteOrganizationConnection: (id: string) => Promise<void>;
|
||||
getPlans: () => Promise<ListResponse<PlanResponse>>;
|
||||
getTaxRates: () => Promise<ListResponse<TaxRateResponse>>;
|
||||
|
||||
getProviderUsers: (providerId: string) => Promise<ListResponse<ProviderUserUserDetailsResponse>>;
|
||||
getProviderUser: (providerId: string, id: string) => Promise<ProviderUserResponse>;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
export abstract class NotificationsService {
|
||||
init: () => Promise<void>;
|
||||
updateConnection: (sync?: boolean) => Promise<void>;
|
||||
reconnectFromActivity: () => Promise<void>;
|
||||
disconnectFromInactivity: () => Promise<void>;
|
||||
}
|
||||
@@ -3,16 +3,21 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SendView } from "../tools/send/models/view/send.view";
|
||||
import { IndexedEntityId } from "../types/guid";
|
||||
import { IndexedEntityId, UserId } from "../types/guid";
|
||||
import { CipherView } from "../vault/models/view/cipher.view";
|
||||
|
||||
export abstract class SearchService {
|
||||
indexedEntityId$: Observable<IndexedEntityId | null>;
|
||||
indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>;
|
||||
|
||||
clearIndex: () => Promise<void>;
|
||||
isSearchable: (query: string) => Promise<boolean>;
|
||||
indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise<void>;
|
||||
clearIndex: (userId: UserId) => Promise<void>;
|
||||
isSearchable: (userId: UserId, query: string) => Promise<boolean>;
|
||||
indexCiphers: (
|
||||
userId: UserId,
|
||||
ciphersToIndex: CipherView[],
|
||||
indexedEntityGuid?: string,
|
||||
) => Promise<void>;
|
||||
searchCiphers: (
|
||||
userId: UserId,
|
||||
query: string,
|
||||
filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[],
|
||||
ciphers?: CipherView[],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { OrganizationDomainRequest } from "../../services/organization-domain/requests/organization-domain.request";
|
||||
|
||||
import { OrganizationDomainSsoDetailsResponse } from "./responses/organization-domain-sso-details.response";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
|
||||
export class VerifiedOrganizationDomainSsoDetailsResponse extends BaseResponse {
|
||||
organizationName: string;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BillingHistoryResponse } from "@bitwarden/common/billing/models/response/billing-history.response";
|
||||
|
||||
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";
|
||||
@@ -13,6 +11,7 @@ import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models
|
||||
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";
|
||||
@@ -53,11 +52,11 @@ export class OrganizationApiServiceAbstraction {
|
||||
updatePasswordManagerSeats: (
|
||||
id: string,
|
||||
request: OrganizationSubscriptionUpdateRequest,
|
||||
) => Promise<void>;
|
||||
) => Promise<ProfileOrganizationResponse>;
|
||||
updateSecretsManagerSubscription: (
|
||||
id: string,
|
||||
request: OrganizationSmSubscriptionUpdateRequest,
|
||||
) => Promise<void>;
|
||||
) => Promise<ProfileOrganizationResponse>;
|
||||
updateSeats: (id: string, request: SeatRequest) => Promise<PaymentResponse>;
|
||||
updateStorage: (id: string, request: StorageRequest) => Promise<PaymentResponse>;
|
||||
verifyBank: (id: string, request: VerifyBankRequest) => Promise<void>;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function canAccessSettingsTab(org: Organization): boolean {
|
||||
org.canManageSso ||
|
||||
org.canManageScim ||
|
||||
org.canAccessImport ||
|
||||
org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway
|
||||
org.canAccessExport ||
|
||||
org.canManageDeviceApprovals
|
||||
);
|
||||
}
|
||||
@@ -57,14 +57,6 @@ export function getOrganizationById(id: string) {
|
||||
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
|
||||
* @deprecated Use organizationService.organizations$ with a filter instead
|
||||
*/
|
||||
export function isMember(org: Organization): boolean {
|
||||
return org.isMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an observable stream of organizations. This service is meant to
|
||||
* be used widely across Bitwarden as the primary way of fetching organizations.
|
||||
@@ -73,41 +65,23 @@ export function isMember(org: Organization): boolean {
|
||||
*/
|
||||
export abstract class OrganizationService {
|
||||
/**
|
||||
* Publishes state for all organizations under the active user.
|
||||
* Publishes state for all organizations under the specified user.
|
||||
* @returns An observable list of organizations
|
||||
*/
|
||||
organizations$: Observable<Organization[]>;
|
||||
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$: Observable<Organization[]>;
|
||||
/**
|
||||
* @deprecated This is currently only used in the CLI, and should not be
|
||||
* used in any new calls. Use get$ instead for the time being, and we'll be
|
||||
* removing this method soon. See Jira for details:
|
||||
* https://bitwarden.atlassian.net/browse/AC-2252.
|
||||
*/
|
||||
getFromState: (id: string) => Promise<Organization>;
|
||||
memberOrganizations$: (userId: UserId) => Observable<Organization[]>;
|
||||
/**
|
||||
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
|
||||
*/
|
||||
canManageSponsorships$: Observable<boolean>;
|
||||
canManageSponsorships$: (userId: UserId) => Observable<boolean>;
|
||||
/**
|
||||
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
|
||||
*/
|
||||
familySponsorshipAvailable$: Observable<boolean>;
|
||||
hasOrganizations: () => Promise<boolean>;
|
||||
get$: (id: string) => Observable<Organization | undefined>;
|
||||
get: (id: string) => Promise<Organization>;
|
||||
/**
|
||||
* @deprecated This method is only used in key connector and will be removed soon as part of https://bitwarden.atlassian.net/browse/AC-2252.
|
||||
*/
|
||||
getAll: (userId?: string) => Promise<Organization[]>;
|
||||
|
||||
/**
|
||||
* Publishes state for all organizations for the given user id or the active user.
|
||||
*/
|
||||
getAll$: (userId?: UserId) => Observable<Organization[]>;
|
||||
familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>;
|
||||
hasOrganizations: (userId: UserId) => Observable<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,20 +94,18 @@ export abstract class InternalOrganizationServiceAbstraction extends Organizatio
|
||||
/**
|
||||
* Replaces state for the provided organization, or creates it if not found.
|
||||
* @param organization The organization state being saved.
|
||||
* @param userId The userId to replace state for. Defaults to the active
|
||||
* user.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
upsert: (OrganizationData: OrganizationData) => Promise<void>;
|
||||
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Replaces state for the entire registered organization list for the active user.
|
||||
* Replaces state for the entire registered organization list for the specified user.
|
||||
* You probably don't want this unless you're calling from a full sync
|
||||
* operation or a logout. See `upsert` for creating & updating a single
|
||||
* organization in the state.
|
||||
* @param organizations A complete list of all organization state for the active
|
||||
* user.
|
||||
* @param userId The userId to replace state for. Defaults to the active
|
||||
* @param organizations A complete list of all organization state for the provided
|
||||
* user.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
replace: (organizations: { [id: string]: OrganizationData }, userId?: UserId) => Promise<void>;
|
||||
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// 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";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
export function canAccessVaultTab(org: Organization): boolean {
|
||||
return org.canViewAllCollections;
|
||||
}
|
||||
|
||||
export function canAccessSettingsTab(org: Organization): boolean {
|
||||
return (
|
||||
org.isOwner ||
|
||||
org.canManagePolicies ||
|
||||
org.canManageSso ||
|
||||
org.canManageScim ||
|
||||
org.canAccessImport ||
|
||||
org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway
|
||||
org.canManageDeviceApprovals
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessMembersTab(org: Organization): boolean {
|
||||
return org.canManageUsers || org.canManageUsersPassword;
|
||||
}
|
||||
|
||||
export function canAccessGroupsTab(org: Organization): boolean {
|
||||
return org.canManageGroups;
|
||||
}
|
||||
|
||||
export function canAccessReportingTab(org: Organization): boolean {
|
||||
return org.canAccessReports || org.canAccessEventLogs;
|
||||
}
|
||||
|
||||
export function canAccessBillingTab(org: Organization): boolean {
|
||||
return org.isOwner;
|
||||
}
|
||||
|
||||
export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
// Admin console can only be accessed by Owners for disabled organizations
|
||||
if (!org.enabled && !org.isOwner) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
canAccessMembersTab(org) ||
|
||||
canAccessGroupsTab(org) ||
|
||||
canAccessReportingTab(org) ||
|
||||
canAccessBillingTab(org) ||
|
||||
canAccessSettingsTab(org) ||
|
||||
canAccessVaultTab(org)
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrganizationById(id: string) {
|
||||
return map<Organization[], Organization | undefined>((orgs) => orgs.find((o) => o.id === id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an observable stream of organizations. This service is meant to
|
||||
* be used widely across Bitwarden as the primary way of fetching organizations.
|
||||
* Risky operations like updates are isolated to the
|
||||
* internal extension `InternalOrganizationServiceAbstraction`.
|
||||
*/
|
||||
export abstract class vNextOrganizationService {
|
||||
/**
|
||||
* Publishes state for all organizations under the specified user.
|
||||
* @returns An observable list of organizations
|
||||
*/
|
||||
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[]>;
|
||||
/**
|
||||
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
|
||||
*/
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Big scary buttons that **update** organization state. These should only be
|
||||
* called from within admin-console scoped code. Extends the base
|
||||
* `OrganizationService` for easy access to `get` calls.
|
||||
* @internal
|
||||
*/
|
||||
export abstract class vNextInternalOrganizationServiceAbstraction extends vNextOrganizationService {
|
||||
/**
|
||||
* Replaces state for the provided organization, or creates it if not found.
|
||||
* @param organization The organization state being saved.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Replaces state for the entire registered organization list for the specified user.
|
||||
* You probably don't want this unless you're calling from a full sync
|
||||
* operation or a logout. See `upsert` for creating & updating a single
|
||||
* organization in the state.
|
||||
* @param organizations A complete list of all organization state for the provided
|
||||
* user.
|
||||
* @param userId The userId to replace state for.
|
||||
*/
|
||||
replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export abstract class PolicyService {
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
*/
|
||||
getAll$: (policyType: PolicyType, userId?: UserId) => Observable<Policy[]>;
|
||||
getAll$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* All {@link Policy} objects for the specified user (from sync data).
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
export abstract class vNextPolicyService {
|
||||
/**
|
||||
* All policies for the provided user from sync data.
|
||||
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
|
||||
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
abstract policies$: (userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
* @param userId the {@link UserId} to search against
|
||||
*/
|
||||
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
|
||||
* {@link Policy} objects and then filter by Policy.data.
|
||||
*/
|
||||
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
|
||||
|
||||
// Policy specific interfaces
|
||||
|
||||
/**
|
||||
* Combines all Master Password policies that apply to the user.
|
||||
* @returns a set of options which represent the minimum Master Password settings that the user must
|
||||
* comply with in order to comply with **all** Master Password policies.
|
||||
*/
|
||||
abstract masterPasswordPolicyOptions$: (
|
||||
userId: UserId,
|
||||
policies?: Policy[],
|
||||
) => Observable<MasterPasswordPolicyOptions | undefined>;
|
||||
|
||||
/**
|
||||
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
|
||||
*/
|
||||
abstract evaluateMasterPassword: (
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
|
||||
* is enabled
|
||||
*/
|
||||
abstract getResetPasswordPolicyOptions: (
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
) => [ResetPasswordPolicyOptions, boolean];
|
||||
}
|
||||
|
||||
export abstract class vNextInternalPolicyService extends vNextPolicyService {
|
||||
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
|
||||
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// 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";
|
||||
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
|
||||
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
|
||||
@@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction {
|
||||
request: ProviderVerifyRecoverDeleteRequest,
|
||||
) => Promise<any>;
|
||||
deleteProvider: (id: string) => Promise<void>;
|
||||
getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>;
|
||||
addOrganizationToProvider: (
|
||||
providerId: string,
|
||||
request: {
|
||||
key: string;
|
||||
organizationId: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type.enum";
|
||||
|
||||
describe("PolicyType", () => {
|
||||
it("RemoveUnlockWithPin should be 14", () => {
|
||||
expect(PolicyType.RemoveUnlockWithPin).toBe(14);
|
||||
});
|
||||
});
|
||||
@@ -13,4 +13,5 @@ export enum PolicyType {
|
||||
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
|
||||
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
|
||||
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
|
||||
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ProductTierType } from "../../../billing/enums/product-tier-type.enum";
|
||||
import { OrganizationUserStatusType, OrganizationUserType } from "../../enums";
|
||||
import { ORGANIZATIONS } from "../../services/organization/organization.service";
|
||||
import { ORGANIZATIONS } from "../../services/organization/organization.state";
|
||||
|
||||
import { OrganizationData } from "./organization.data";
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
accessSecretsManager: false,
|
||||
limitCollectionCreation: false,
|
||||
limitCollectionDeletion: false,
|
||||
limitItemDeletion: false,
|
||||
allowAdminAccessToAllCollectionItems: false,
|
||||
familySponsorshipLastSyncDate: new Date(),
|
||||
userIsManagedByOrganization: false,
|
||||
|
||||
@@ -56,6 +56,7 @@ export class OrganizationData {
|
||||
accessSecretsManager: boolean;
|
||||
limitCollectionCreation: boolean;
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
@@ -117,6 +118,7 @@ export class OrganizationData {
|
||||
this.accessSecretsManager = response.accessSecretsManager;
|
||||
this.limitCollectionCreation = response.limitCollectionCreation;
|
||||
this.limitCollectionDeletion = response.limitCollectionDeletion;
|
||||
this.limitItemDeletion = response.limitItemDeletion;
|
||||
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { OrgKey, UserPrivateKey } from "../../../types/key";
|
||||
@@ -58,6 +58,9 @@ export class ProviderEncryptedOrganizationKey implements BaseEncryptedOrganizati
|
||||
new EncString(this.key),
|
||||
providerKeys[this.providerId],
|
||||
);
|
||||
if (decValue == null) {
|
||||
throw new Error("Failed to decrypt organization key");
|
||||
}
|
||||
return new SymmetricCryptoKey(decValue) as OrgKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ export class Organization {
|
||||
/**
|
||||
* Refers to the ability for an owner/admin to access all collection items, regardless of assigned collections
|
||||
*/
|
||||
limitItemDeletion: boolean;
|
||||
/**
|
||||
* Refers to the ability to limit delete permission of collection items.
|
||||
* If set to true, members can only delete items when they have a Can Manage permission over the collection.
|
||||
* If set to false, members can delete items when they have a Can Manage OR Can Edit permission over the collection.
|
||||
*/
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
/**
|
||||
* Indicates if this organization manages the user.
|
||||
@@ -138,6 +144,7 @@ export class Organization {
|
||||
this.accessSecretsManager = obj.accessSecretsManager;
|
||||
this.limitCollectionCreation = obj.limitCollectionCreation;
|
||||
this.limitCollectionDeletion = obj.limitCollectionDeletion;
|
||||
this.limitItemDeletion = obj.limitItemDeletion;
|
||||
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
@@ -182,11 +189,7 @@ export class Organization {
|
||||
);
|
||||
}
|
||||
|
||||
canAccessExport(removeProviderExport: boolean) {
|
||||
if (!removeProviderExport && this.isProviderUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
get canAccessExport() {
|
||||
return (
|
||||
this.isMember &&
|
||||
(this.type === OrganizationUserType.Owner ||
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
export class OrganizationCollectionManagementUpdateRequest {
|
||||
limitCollectionCreation: boolean;
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class AddableOrganizationResponse extends BaseResponse {
|
||||
id: string;
|
||||
plan: string;
|
||||
name: string;
|
||||
seats: number;
|
||||
disabled: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("id");
|
||||
this.plan = this.getResponseProperty("plan");
|
||||
this.name = this.getResponseProperty("name");
|
||||
this.seats = this.getResponseProperty("seats");
|
||||
this.disabled = this.getResponseProperty("disabled");
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
maxAutoscaleSmServiceAccounts?: number;
|
||||
limitCollectionCreation: boolean;
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useRiskInsights: boolean;
|
||||
|
||||
@@ -75,6 +76,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts");
|
||||
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
|
||||
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
|
||||
this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion");
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
accessSecretsManager: boolean;
|
||||
limitCollectionCreation: boolean;
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
@@ -114,6 +115,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
|
||||
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
|
||||
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
|
||||
this.limitItemDeletion = this.getResponseProperty("LimitItemDeletion");
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { OrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/organization-domain-sso-details.response";
|
||||
import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response";
|
||||
import { VerifiedOrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
|
||||
|
||||
import { OrgDomainApiService } from "./org-domain-api.service";
|
||||
import { OrgDomainService } from "./org-domain.service";
|
||||
|
||||
@@ -6,11 +6,11 @@ import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
import { DefaultvNextOrganizationService } from "./default-vnext-organization.service";
|
||||
import { ORGANIZATIONS } from "./vnext-organization.state";
|
||||
import { DefaultOrganizationService } from "./default-organization.service";
|
||||
import { ORGANIZATIONS } from "./organization.state";
|
||||
|
||||
describe("OrganizationService", () => {
|
||||
let organizationService: DefaultvNextOrganizationService;
|
||||
let organizationService: DefaultOrganizationService;
|
||||
|
||||
const fakeUserId = Utils.newGuid() as UserId;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
@@ -86,7 +86,7 @@ describe("OrganizationService", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(fakeUserId));
|
||||
organizationService = new DefaultvNextOrganizationService(fakeStateProvider);
|
||||
organizationService = new DefaultOrganizationService(fakeStateProvider);
|
||||
});
|
||||
|
||||
describe("canManageSponsorships", () => {
|
||||
@@ -4,11 +4,11 @@ import { map, Observable } from "rxjs";
|
||||
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { vNextInternalOrganizationServiceAbstraction } from "../../abstractions/organization/vnext.organization.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
import { ORGANIZATIONS } from "./vnext-organization.state";
|
||||
import { ORGANIZATIONS } from "./organization.state";
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that __do not__ offer a
|
||||
@@ -41,9 +41,7 @@ function mapToBooleanHasAnyOrganizations() {
|
||||
return map<Organization[], boolean>((orgs) => orgs.length > 0);
|
||||
}
|
||||
|
||||
export class DefaultvNextOrganizationService
|
||||
implements vNextInternalOrganizationServiceAbstraction
|
||||
{
|
||||
export class DefaultOrganizationService implements InternalOrganizationServiceAbstraction {
|
||||
memberOrganizations$(userId: UserId): Observable<Organization[]> {
|
||||
return this.organizations$(userId).pipe(mapToExcludeProviderOrganizations());
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BillingHistoryResponse } from "@bitwarden/common/billing/models/response/billing-history.response";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { OrganizationApiKeyRequest } from "../../../admin-console/models/request/organization-api-key.request";
|
||||
@@ -14,6 +13,7 @@ import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models
|
||||
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";
|
||||
@@ -161,27 +161,29 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
async updatePasswordManagerSeats(
|
||||
id: string,
|
||||
request: OrganizationSubscriptionUpdateRequest,
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
): Promise<ProfileOrganizationResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + id + "/subscription",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return new ProfileOrganizationResponse(r);
|
||||
}
|
||||
|
||||
async updateSecretsManagerSubscription(
|
||||
id: string,
|
||||
request: OrganizationSmSubscriptionUpdateRequest,
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
): Promise<ProfileOrganizationResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + id + "/sm-subscription",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return new ProfileOrganizationResponse(r);
|
||||
}
|
||||
|
||||
async updateSeats(id: string, request: SeatRequest): Promise<PaymentResponse> {
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeActiveUserState } from "../../../../spec/fake-state";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
import { OrganizationService, ORGANIZATIONS } from "./organization.service";
|
||||
|
||||
describe("OrganizationService", () => {
|
||||
let organizationService: OrganizationService;
|
||||
|
||||
const fakeUserId = Utils.newGuid() as UserId;
|
||||
let fakeAccountService: FakeAccountService;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
let fakeActiveUserState: FakeActiveUserState<Record<string, OrganizationData>>;
|
||||
|
||||
/**
|
||||
* It is easier to read arrays than records in code, but we store a record
|
||||
* in state. This helper methods lets us build organization arrays in tests
|
||||
* and easily map them to records before storing them in state.
|
||||
*/
|
||||
function arrayToRecord(input: OrganizationData[]): Record<OrganizationId, OrganizationData> {
|
||||
if (input == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(input?.map((i) => [i.id, i]));
|
||||
}
|
||||
|
||||
/**
|
||||
* There are a few assertions in this spec that check for array equality
|
||||
* but want to ignore a specific index that _should_ be different. This
|
||||
* function takes two arrays, and an index. It checks for equality of the
|
||||
* arrays, but splices out the specified index from both arrays first.
|
||||
*/
|
||||
function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) {
|
||||
// Clone the arrays to avoid modifying the reference values
|
||||
const a = [...x];
|
||||
const b = [...y];
|
||||
delete a[indexToExclude];
|
||||
delete b[indexToExclude];
|
||||
expect(a).toEqual(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simple mock `OrganizationData[]` array that can be used in tests
|
||||
* to populate state.
|
||||
* @param count The number of organizations to populate the list with. The
|
||||
* function returns undefined if this is less than 1. The default value is 1.
|
||||
* @param suffix A string to append to data fields on each organization.
|
||||
* This defaults to the index of the organization in the list.
|
||||
* @returns an `OrganizationData[]` array that can be used to populate
|
||||
* stateProvider.
|
||||
*/
|
||||
function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] {
|
||||
if (count < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMockOrganization(id: OrganizationId, name: string, identifier: string) {
|
||||
const data = new OrganizationData({} as any, {} as any);
|
||||
data.id = id;
|
||||
data.name = name;
|
||||
data.identifier = identifier;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const mockOrganizations = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const s = suffix ? suffix + i.toString() : i.toString();
|
||||
mockOrganizations.push(
|
||||
buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s),
|
||||
);
|
||||
}
|
||||
|
||||
return mockOrganizations;
|
||||
}
|
||||
|
||||
/**
|
||||
* `OrganizationService` deals with multiple accounts at times. This helper
|
||||
* function can be used to add a new non-active account to the test data.
|
||||
* This function is **not** needed to handle creation of the first account,
|
||||
* as that is handled by the `FakeAccountService` in `mockAccountServiceWith()`
|
||||
* @returns The `UserId` of the newly created state account and the mock data
|
||||
* created for them as an `Organization[]`.
|
||||
*/
|
||||
async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> {
|
||||
const nonActiveUserId = Utils.newGuid() as UserId;
|
||||
|
||||
const mockOrganizations = buildMockOrganizations(10);
|
||||
const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake(
|
||||
nonActiveUserId,
|
||||
ORGANIZATIONS,
|
||||
);
|
||||
fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations));
|
||||
|
||||
return [nonActiveUserId, mockOrganizations];
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeAccountService = mockAccountServiceWith(fakeUserId);
|
||||
fakeStateProvider = new FakeStateProvider(fakeAccountService);
|
||||
fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS);
|
||||
organizationService = new OrganizationService(fakeStateProvider);
|
||||
});
|
||||
|
||||
it("getAll", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const orgs = await organizationService.getAll();
|
||||
expect(orgs).toHaveLength(1);
|
||||
const org = orgs[0];
|
||||
expect(org).toEqual(new Organization(mockData[0]));
|
||||
});
|
||||
|
||||
describe("canManageSponsorships", () => {
|
||||
it("can because one is available", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipAvailable = true;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.canManageSponsorships$);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can because one is used", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipFriendlyName = "Something";
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.canManageSponsorships$);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can not because one isn't available or taken", async () => {
|
||||
const mockData: OrganizationData[] = buildMockOrganizations(1);
|
||||
mockData[0].familySponsorshipFriendlyName = null;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.canManageSponsorships$);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("exists", async () => {
|
||||
const mockData = buildMockOrganizations(1);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await organizationService.get(mockData[0].id);
|
||||
expect(result).toEqual(new Organization(mockData[0]));
|
||||
});
|
||||
|
||||
it("does not exist", async () => {
|
||||
const result = await organizationService.get("this-org-does-not-exist");
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizations$", () => {
|
||||
describe("null checking behavior", () => {
|
||||
it("publishes an empty array if organizations in state = undefined", async () => {
|
||||
const mockData: OrganizationData[] = undefined;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes an empty array if organizations in state = null", async () => {
|
||||
const mockData: OrganizationData[] = null;
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes an empty array if organizations in state = []", async () => {
|
||||
const mockData: OrganizationData[] = [];
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parameter handling & returns", () => {
|
||||
it("publishes all organizations for the active user by default", async () => {
|
||||
const mockData = buildMockOrganizations(10);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it("can be used to publish the organizations of a non active user if requested", async () => {
|
||||
const activeUserMockData = buildMockOrganizations(10, "activeUserState");
|
||||
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
|
||||
|
||||
const [nonActiveUserId, nonActiveUserMockOrganizations] =
|
||||
await addNonActiveAccountToStateProvider();
|
||||
// This can be updated to use
|
||||
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
|
||||
// promise based methods are removed from `OrganizationService` and the
|
||||
// main observable is refactored to accept a userId
|
||||
const result = await organizationService.getAll(nonActiveUserId);
|
||||
|
||||
expect(result).toEqual(nonActiveUserMockOrganizations);
|
||||
expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsert()", () => {
|
||||
it("can create the organization list if necassary", async () => {
|
||||
// Notice that no default state is provided in this test, so the list in
|
||||
// `stateProvider` will be null when the `upsert` method is called.
|
||||
const mockData = buildMockOrganizations();
|
||||
await organizationService.upsert(mockData[0]);
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual(mockData.map((x) => new Organization(x)));
|
||||
});
|
||||
|
||||
it("updates an organization that already exists in state, defaulting to the active user", async () => {
|
||||
const mockData = buildMockOrganizations(10);
|
||||
fakeActiveUserState.nextState(arrayToRecord(mockData));
|
||||
const indexToUpdate = 5;
|
||||
const anUpdatedOrganization = {
|
||||
...buildMockOrganizations(1, "UPDATED").pop(),
|
||||
id: mockData[indexToUpdate].id,
|
||||
};
|
||||
await organizationService.upsert(anUpdatedOrganization);
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate]));
|
||||
expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id);
|
||||
expectIsEqualExceptForIndex(
|
||||
result,
|
||||
mockData.map((x) => new Organization(x)),
|
||||
indexToUpdate,
|
||||
);
|
||||
});
|
||||
|
||||
it("can also update an organization in state for a non-active user, if requested", async () => {
|
||||
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
|
||||
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
|
||||
|
||||
const [nonActiveUserId, nonActiveUserMockOrganizations] =
|
||||
await addNonActiveAccountToStateProvider();
|
||||
const indexToUpdate = 5;
|
||||
const anUpdatedOrganization = {
|
||||
...buildMockOrganizations(1, "UPDATED").pop(),
|
||||
id: nonActiveUserMockOrganizations[indexToUpdate].id,
|
||||
};
|
||||
|
||||
await organizationService.upsert(anUpdatedOrganization, nonActiveUserId);
|
||||
// This can be updated to use
|
||||
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
|
||||
// promise based methods are removed from `OrganizationService` and the
|
||||
// main observable is refactored to accept a userId
|
||||
const result = await organizationService.getAll(nonActiveUserId);
|
||||
|
||||
expect(result[indexToUpdate]).not.toEqual(
|
||||
new Organization(nonActiveUserMockOrganizations[indexToUpdate]),
|
||||
);
|
||||
expect(result[indexToUpdate].id).toEqual(
|
||||
new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id,
|
||||
);
|
||||
expectIsEqualExceptForIndex(
|
||||
result,
|
||||
nonActiveUserMockOrganizations.map((x) => new Organization(x)),
|
||||
indexToUpdate,
|
||||
);
|
||||
|
||||
// Just to be safe, lets make sure the active user didn't get updated
|
||||
// at all
|
||||
const activeUserState = await firstValueFrom(organizationService.organizations$);
|
||||
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
|
||||
expect(activeUserState).not.toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace()", () => {
|
||||
it("replaces the entire organization list in state, defaulting to the active user", async () => {
|
||||
const originalData = buildMockOrganizations(10);
|
||||
fakeActiveUserState.nextState(arrayToRecord(originalData));
|
||||
|
||||
const newData = buildMockOrganizations(10, "newData");
|
||||
await organizationService.replace(arrayToRecord(newData));
|
||||
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
|
||||
expect(result).toEqual(newData);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
// This is more or less a test for logouts
|
||||
it("can replace state with null", async () => {
|
||||
const originalData = buildMockOrganizations(2);
|
||||
fakeActiveUserState.nextState(arrayToRecord(originalData));
|
||||
await organizationService.replace(null);
|
||||
const result = await firstValueFrom(organizationService.organizations$);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
it("can also replace state for a non-active user, if requested", async () => {
|
||||
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
|
||||
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
|
||||
|
||||
const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider();
|
||||
const newData = buildMockOrganizations(10, "newData");
|
||||
|
||||
await organizationService.replace(arrayToRecord(newData), nonActiveUserId);
|
||||
// This can be updated to use
|
||||
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
|
||||
// promise based methods are removed from `OrganizationService` and the
|
||||
// main observable is refactored to accept a userId
|
||||
const result = await organizationService.getAll(nonActiveUserId);
|
||||
expect(result).toEqual(newData);
|
||||
expect(result).not.toEqual(originalOrganizations);
|
||||
|
||||
// Just to be safe, lets make sure the active user didn't get updated
|
||||
// at all
|
||||
const activeUserState = await firstValueFrom(organizationService.organizations$);
|
||||
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
|
||||
expect(activeUserState).not.toEqual(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { map, Observable, firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
|
||||
/**
|
||||
* The `KeyDefinition` for accessing organization lists in application state.
|
||||
* @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData`
|
||||
* has some properties that contain functions. This should probably get
|
||||
* cleaned up.
|
||||
*/
|
||||
export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
|
||||
ORGANIZATIONS_DISK,
|
||||
"organizations",
|
||||
{
|
||||
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that __do not__ offer a
|
||||
* families-for-enterprise sponsorship to members.
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
|
||||
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.canManageSponsorships));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out organizations from an observable that the organization user
|
||||
* __is not__ a direct member of. This will exclude organizations only
|
||||
* accessible as a provider.
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToExcludeProviderOrganizations() {
|
||||
return map<Organization[], Organization[]>((orgs) => orgs.filter((o) => o.isMember));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an observable stream of organizations down to a boolean indicating
|
||||
* if any organizations exist (`orgs.length > 0`).
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToBooleanHasAnyOrganizations() {
|
||||
return map<Organization[], boolean>((orgs) => orgs.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an observable stream of organizations down to a single organization.
|
||||
* @param `organizationId` The ID of the organization you'd like to subscribe to
|
||||
* @returns a function that can be used in `Observable<Organization[]>` pipes,
|
||||
* like `organizationService.organizations$`
|
||||
*/
|
||||
function mapToSingleOrganization(organizationId: string) {
|
||||
return map<Organization[], Organization>((orgs) => orgs?.find((o) => o.id === organizationId));
|
||||
}
|
||||
|
||||
export class OrganizationService implements InternalOrganizationServiceAbstraction {
|
||||
organizations$: Observable<Organization[]> = this.getOrganizationsFromState$();
|
||||
memberOrganizations$: Observable<Organization[]> = this.organizations$.pipe(
|
||||
mapToExcludeProviderOrganizations(),
|
||||
);
|
||||
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
get$(id: string): Observable<Organization | undefined> {
|
||||
return this.organizations$.pipe(mapToSingleOrganization(id));
|
||||
}
|
||||
|
||||
getAll$(userId?: UserId): Observable<Organization[]> {
|
||||
return this.getOrganizationsFromState$(userId);
|
||||
}
|
||||
|
||||
async getAll(userId?: string): Promise<Organization[]> {
|
||||
return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId));
|
||||
}
|
||||
|
||||
canManageSponsorships$ = this.organizations$.pipe(
|
||||
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
|
||||
mapToBooleanHasAnyOrganizations(),
|
||||
);
|
||||
|
||||
familySponsorshipAvailable$ = this.organizations$.pipe(
|
||||
map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)),
|
||||
);
|
||||
|
||||
async hasOrganizations(): Promise<boolean> {
|
||||
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
|
||||
}
|
||||
|
||||
async upsert(organization: OrganizationData, userId?: UserId): Promise<void> {
|
||||
await this.stateFor(userId).update((existingOrganizations) => {
|
||||
const organizations = existingOrganizations ?? {};
|
||||
organizations[organization.id] = organization;
|
||||
return organizations;
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Organization> {
|
||||
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated For the CLI only
|
||||
* @param id id of the organization
|
||||
*/
|
||||
async getFromState(id: string): Promise<Organization> {
|
||||
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
|
||||
}
|
||||
|
||||
async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise<void> {
|
||||
await this.stateFor(userId).update(() => organizations);
|
||||
}
|
||||
|
||||
// Ideally this method would be renamed to organizations$() and the
|
||||
// $organizations observable as it stands would be removed. This will
|
||||
// require updates to callers, and so this method exists as a temporary
|
||||
// workaround until we have time & a plan to update callers.
|
||||
//
|
||||
// It can be thought of as "organizations$ but with a userId option".
|
||||
private getOrganizationsFromState$(userId?: UserId): Observable<Organization[] | undefined> {
|
||||
return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a record of `OrganizationData`, which is how we store the
|
||||
* organization list as a JSON object on disk, to an array of
|
||||
* `Organization`, which is how the data is published to callers of the
|
||||
* service.
|
||||
* @returns a function that can be used to pipe organization data from
|
||||
* stored state to an exposed object easily consumable by others.
|
||||
*/
|
||||
private mapOrganizationRecordToArray() {
|
||||
return map<Record<string, OrganizationData>, Organization[]>((orgs) =>
|
||||
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the organization list from on disk state for the specified user.
|
||||
* @param userId the user ID to fetch the organization list for. Defaults to
|
||||
* the currently active user.
|
||||
* @returns an observable of organization state as it is stored on disk.
|
||||
*/
|
||||
private stateFor(userId?: UserId) {
|
||||
return userId
|
||||
? this.stateProvider.getUser(userId, ORGANIZATIONS)
|
||||
: this.stateProvider.getActive(ORGANIZATIONS);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "../../../platform/state";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,590 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeSingleUserState } from "../../../../spec/fake-state";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
PolicyType,
|
||||
} from "../../../admin-console/enums";
|
||||
import { PermissionsApi } from "../../../admin-console/models/api/permissions.api";
|
||||
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../../admin-console/models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options";
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
|
||||
import { POLICIES } from "../../../admin-console/services/policy/policy.service";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
|
||||
import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
|
||||
let policyService: DefaultvNextPolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
organizationService = mock<OrganizationService>();
|
||||
singleUserState = stateProvider.singleUser.getFake(userId, POLICIES);
|
||||
|
||||
const organizations$ = of([
|
||||
// User
|
||||
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Owner
|
||||
organization(
|
||||
"org2",
|
||||
true,
|
||||
true,
|
||||
OrganizationUserStatusType.Confirmed,
|
||||
false,
|
||||
OrganizationUserType.Owner,
|
||||
),
|
||||
// Does not use policies
|
||||
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
|
||||
// Another User
|
||||
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Another User
|
||||
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Can manage policies
|
||||
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
|
||||
]);
|
||||
|
||||
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
|
||||
|
||||
policyService = new DefaultvNextPolicyService(stateProvider, organizationService);
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await policyService.upsert(
|
||||
policyData("99", "test-organization", PolicyType.DisableSend, true),
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(await firstValueFrom(policyService.policies$(userId))).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replace", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await policyService.replace(
|
||||
{
|
||||
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(await firstValueFrom(policyService.policies$(userId))).toEqual([
|
||||
{
|
||||
id: "2",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("masterPasswordPolicyOptions", () => {
|
||||
it("returns default policy options", async () => {
|
||||
const data: any = {
|
||||
minComplexity: 5,
|
||||
minLength: 20,
|
||||
requireUpper: true,
|
||||
};
|
||||
const model = [
|
||||
new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)),
|
||||
];
|
||||
jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model));
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
minComplexity: 5,
|
||||
minLength: 20,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
requireUpper: true,
|
||||
enforceOnLogin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined", async () => {
|
||||
const data: any = {};
|
||||
const model = [
|
||||
new Policy(
|
||||
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
|
||||
),
|
||||
new Policy(
|
||||
policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data),
|
||||
),
|
||||
];
|
||||
jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model));
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns specified policy options", async () => {
|
||||
const data: any = {
|
||||
minLength: 14,
|
||||
};
|
||||
const model = [
|
||||
new Policy(
|
||||
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
|
||||
),
|
||||
new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)),
|
||||
];
|
||||
jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model));
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
minComplexity: 0,
|
||||
minLength: 14,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
requireUpper: false,
|
||||
enforceOnLogin: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMasterPassword", () => {
|
||||
it("false", async () => {
|
||||
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
enforcedPolicyOptions.minLength = 14;
|
||||
const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it("true", async () => {
|
||||
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResetPasswordPolicyOptions", () => {
|
||||
it("default", async () => {
|
||||
const result = policyService.getResetPasswordPolicyOptions([], "");
|
||||
|
||||
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
|
||||
});
|
||||
|
||||
it("returns autoEnrollEnabled true", async () => {
|
||||
const data: any = {
|
||||
autoEnrollEnabled: true,
|
||||
};
|
||||
const policies = [
|
||||
new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)),
|
||||
];
|
||||
const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3");
|
||||
|
||||
expect(result).toEqual([{ autoEnrollEnabled: true }, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policiesByType$", () => {
|
||||
it("returns the specified PolicyType", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService
|
||||
.policiesByType$(PolicyType.DisablePersonalVaultExport, userId)
|
||||
.pipe(getFirstPolicy),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService
|
||||
.policiesByType$(PolicyType.DisablePersonalVaultExport, userId)
|
||||
.pipe(getFirstPolicy),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService
|
||||
.policiesByType$(PolicyType.DisablePersonalVaultExport, userId)
|
||||
.pipe(getFirstPolicy),
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["owners", "org2"],
|
||||
["administrators", "org6"],
|
||||
])("returns the password generator policy for %s", async (_, organization) => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, false),
|
||||
policyData("policy2", organization, PolicyType.PasswordGenerator, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policiesByType$(PolicyType.PasswordGenerator, userId).pipe(getFirstPolicy),
|
||||
);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policiesByType$(PolicyType.ActivateAutofill, userId).pipe(getFirstPolicy),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("policies$", () => {
|
||||
it("returns all policies when none are disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.policies$(userId));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns all policies when some are disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.policies$(userId));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.policies$(userId));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org2",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.policies$(userId));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org3",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyAppliesToUser$", () => {
|
||||
it("returns true when the policyType applies to the user", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when policyType is disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for organizations that do not use policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function policyData(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
type: PolicyType,
|
||||
enabled: boolean,
|
||||
data?: any,
|
||||
) {
|
||||
const policyData = new PolicyData({} as any);
|
||||
policyData.id = id as PolicyId;
|
||||
policyData.organizationId = organizationId;
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
function organizationData(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
usePolicies: boolean,
|
||||
status: OrganizationUserStatusType,
|
||||
managePolicies: boolean,
|
||||
type: OrganizationUserType = OrganizationUserType.User,
|
||||
) {
|
||||
const organizationData = new OrganizationData({} as any, {} as any);
|
||||
organizationData.id = id;
|
||||
organizationData.enabled = enabled;
|
||||
organizationData.usePolicies = usePolicies;
|
||||
organizationData.status = status;
|
||||
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
|
||||
organizationData.type = type;
|
||||
return organizationData;
|
||||
}
|
||||
|
||||
function organization(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
usePolicies: boolean,
|
||||
status: OrganizationUserStatusType,
|
||||
managePolicies: boolean,
|
||||
type: OrganizationUserType = OrganizationUserType.User,
|
||||
) {
|
||||
return new Organization(
|
||||
organizationData(id, enabled, usePolicies, status, managePolicies, type),
|
||||
);
|
||||
}
|
||||
|
||||
function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> {
|
||||
return Object.fromEntries(input.map((i) => [i.id, i]));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { combineLatest, map, Observable, of } from "rxjs";
|
||||
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service";
|
||||
import { OrganizationUserStatusType, PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
import { POLICIES } from "./vnext-policy-state";
|
||||
|
||||
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
|
||||
return Object.values(policiesMap || {}).map((f) => new Policy(f));
|
||||
}
|
||||
|
||||
export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => {
|
||||
return policies.at(0) ?? undefined;
|
||||
});
|
||||
|
||||
export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
private policyState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, POLICIES);
|
||||
}
|
||||
|
||||
private policyData$(userId: UserId) {
|
||||
return this.policyState(userId).state$.pipe(map((policyData) => policyData ?? {}));
|
||||
}
|
||||
|
||||
policies$(userId: UserId) {
|
||||
return this.policyData$(userId).pipe(map((policyData) => policyRecordToArray(policyData)));
|
||||
}
|
||||
|
||||
policiesByType$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.policies$(userId).pipe(
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No userId provided");
|
||||
}
|
||||
|
||||
const organizations$ = this.organizationService.organizations$(userId);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
);
|
||||
}
|
||||
|
||||
policyAppliesToUser$(policyType: PolicyType, userId: UserId) {
|
||||
return this.policiesByType$(policyType, userId).pipe(
|
||||
getFirstPolicy,
|
||||
map((policy) => !!policy),
|
||||
);
|
||||
}
|
||||
|
||||
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
|
||||
const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o]));
|
||||
return policies.filter((policy) => {
|
||||
const organization = orgDict[policy.organizationId];
|
||||
|
||||
// This shouldn't happen, i.e. the user should only have policies for orgs they are a member of
|
||||
// But if it does, err on the side of enforcing the policy
|
||||
if (!organization) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
policy.enabled &&
|
||||
organization.status >= OrganizationUserStatusType.Accepted &&
|
||||
organization.usePolicies &&
|
||||
!this.isExemptFromPolicy(policy.type, organization)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
masterPasswordPolicyOptions$(
|
||||
userId: UserId,
|
||||
policies?: Policy[],
|
||||
): Observable<MasterPasswordPolicyOptions | undefined> {
|
||||
const policies$ = policies ? of(policies) : this.policies$(userId);
|
||||
return policies$.pipe(
|
||||
map((obsPolicies) => {
|
||||
const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
const filteredPolicies =
|
||||
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
|
||||
|
||||
if (filteredPolicies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
filteredPolicies.forEach((currentPolicy) => {
|
||||
if (!currentPolicy.enabled || !currentPolicy.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minComplexity != null &&
|
||||
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
|
||||
) {
|
||||
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minLength != null &&
|
||||
currentPolicy.data.minLength > enforcedOptions.minLength
|
||||
) {
|
||||
enforcedOptions.minLength = currentPolicy.data.minLength;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireUpper) {
|
||||
enforcedOptions.requireUpper = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireLower) {
|
||||
enforcedOptions.requireLower = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireNumbers) {
|
||||
enforcedOptions.requireNumbers = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireSpecial) {
|
||||
enforcedOptions.requireSpecial = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.enforceOnLogin) {
|
||||
enforcedOptions.enforceOnLogin = true;
|
||||
}
|
||||
});
|
||||
|
||||
return enforcedOptions;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
evaluateMasterPassword(
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
|
||||
): boolean {
|
||||
if (!enforcedPolicyOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
enforcedPolicyOptions.minComplexity > 0 &&
|
||||
enforcedPolicyOptions.minComplexity > passwordStrength
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
enforcedPolicyOptions.minLength > 0 &&
|
||||
enforcedPolicyOptions.minLength > newPassword.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getResetPasswordPolicyOptions(
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
): [ResetPasswordPolicyOptions, boolean] {
|
||||
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
|
||||
|
||||
if (!policies || !orgId) {
|
||||
return [resetPasswordPolicyOptions, false];
|
||||
}
|
||||
|
||||
const policy = policies.find(
|
||||
(p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled,
|
||||
);
|
||||
resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false;
|
||||
|
||||
return [resetPasswordPolicyOptions, policy?.enabled ?? false];
|
||||
}
|
||||
|
||||
async upsert(policy: PolicyData, userId: UserId): Promise<void> {
|
||||
await this.policyState(userId).update((policies) => {
|
||||
policies ??= {};
|
||||
policies[policy.id] = policy;
|
||||
return policies;
|
||||
});
|
||||
}
|
||||
|
||||
async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> {
|
||||
await this.stateProvider.setUserState(POLICIES, policies, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an orgUser is exempt from a specific policy because of their role
|
||||
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
|
||||
*/
|
||||
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
|
||||
switch (policyType) {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
return organization.isOwner;
|
||||
case PolicyType.PasswordGenerator:
|
||||
// password generation policy applies to everyone
|
||||
return false;
|
||||
case PolicyType.PersonalOwnership:
|
||||
// individual vault policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.FreeFamiliesSponsorshipPolicy:
|
||||
// free Bitwarden families policy applies to everyone
|
||||
return false;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeActiveUserState } from "../../../../spec/fake-state";
|
||||
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
@@ -18,12 +17,14 @@ import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
|
||||
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
|
||||
let policyService: PolicyService;
|
||||
|
||||
@@ -33,6 +34,7 @@ describe("PolicyService", () => {
|
||||
organizationService = mock<OrganizationService>();
|
||||
|
||||
activeUserState = stateProvider.activeUser.getFake(POLICIES);
|
||||
singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES);
|
||||
|
||||
const organizations$ = of([
|
||||
// User
|
||||
@@ -56,9 +58,7 @@ describe("PolicyService", () => {
|
||||
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
|
||||
]);
|
||||
|
||||
organizationService.organizations$ = organizations$;
|
||||
|
||||
organizationService.getAll$.mockReturnValue(organizations$);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
policyService = new PolicyService(stateProvider, organizationService);
|
||||
});
|
||||
@@ -196,7 +196,7 @@ describe("PolicyService", () => {
|
||||
|
||||
describe("getResetPasswordPolicyOptions", () => {
|
||||
it("default", async () => {
|
||||
const result = policyService.getResetPasswordPolicyOptions(null, null);
|
||||
const result = policyService.getResetPasswordPolicyOptions([], "");
|
||||
|
||||
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
|
||||
});
|
||||
@@ -220,19 +220,34 @@ describe("PolicyService", () => {
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.get$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy1",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
});
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.DisablePersonalVaultExport)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
});
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.RemoveUnlockWithPin)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy3",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
@@ -297,7 +312,7 @@ describe("PolicyService", () => {
|
||||
|
||||
describe("getAll$", () => {
|
||||
it("returns the specified PolicyTypes", async () => {
|
||||
activeUserState.nextState(
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
@@ -307,7 +322,7 @@ describe("PolicyService", () => {
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -333,7 +348,7 @@ describe("PolicyService", () => {
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
activeUserState.nextState(
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
@@ -343,7 +358,7 @@ describe("PolicyService", () => {
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -363,7 +378,7 @@ describe("PolicyService", () => {
|
||||
});
|
||||
|
||||
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
activeUserState.nextState(
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
@@ -373,7 +388,7 @@ describe("PolicyService", () => {
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -393,7 +408,7 @@ describe("PolicyService", () => {
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
activeUserState.nextState(
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
@@ -403,7 +418,7 @@ describe("PolicyService", () => {
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport),
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, firstValueFrom, map, Observable, of } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
@@ -39,7 +39,11 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, this.organizationService.organizations$]).pipe(
|
||||
const organizations$ = this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => this.organizationService.organizations$(userId)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
map(
|
||||
([policies, organizations]) =>
|
||||
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
|
||||
@@ -47,13 +51,13 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
getAll$(policyType: PolicyType, userId?: UserId) {
|
||||
getAll$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
|
||||
map((policyData) => policyRecordToArray(policyData)),
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, this.organizationService.getAll$(userId)]).pipe(
|
||||
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
);
|
||||
}
|
||||
@@ -243,6 +247,9 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
|
||||
case PolicyType.FreeFamiliesSponsorshipPolicy:
|
||||
// free Bitwarden families policy applies to everyone
|
||||
return false;
|
||||
case PolicyType.RemoveUnlockWithPin:
|
||||
// free Remove Unlock with PIN policy applies to everyone
|
||||
return false;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { POLICIES_DISK, UserKeyDefinition } from "../../../platform/state";
|
||||
import { PolicyId } from "../../../types/guid";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
|
||||
export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
|
||||
deserializer: (policyData) => policyData,
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
|
||||
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
|
||||
@@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
|
||||
async deleteProvider(id: string): Promise<void> {
|
||||
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
|
||||
}
|
||||
|
||||
async getProviderAddableOrganizations(
|
||||
providerId: string,
|
||||
): Promise<AddableOrganizationResponse[]> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/clients/addable",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return response.map((data: any) => new AddableOrganizationResponse(data));
|
||||
}
|
||||
|
||||
addOrganizationToProvider(
|
||||
providerId: string,
|
||||
request: {
|
||||
key: string;
|
||||
organizationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
"POST",
|
||||
"/providers/" + providerId + "/clients/existing",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
|
||||
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
|
||||
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
|
||||
import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request";
|
||||
import { Verification } from "../types/verification";
|
||||
|
||||
export abstract class AccountApiService {
|
||||
@@ -18,7 +19,7 @@ export abstract class AccountApiService {
|
||||
*
|
||||
* @param request - The request object containing
|
||||
* information needed to send the verification email, such as the user's email address.
|
||||
* @returns A promise that resolves to a string tokencontaining the user's encrypted
|
||||
* @returns A promise that resolves to a string token containing the user's encrypted
|
||||
* information which must be submitted to complete registration or `null` if
|
||||
* email verification is enabled (users must get the token by clicking a
|
||||
* link in the email that will be sent to them).
|
||||
@@ -33,7 +34,7 @@ export abstract class AccountApiService {
|
||||
*
|
||||
* @param request - The request object containing the email verification token and the
|
||||
* user's email address (which is required to validate the token)
|
||||
* @returns A promise that resolves when the event is logged on the server succcessfully or a bad
|
||||
* @returns A promise that resolves when the event is logged on the server successfully or a bad
|
||||
* request if the token is invalid for any reason.
|
||||
*/
|
||||
abstract registerVerificationEmailClicked(
|
||||
@@ -50,4 +51,15 @@ export abstract class AccountApiService {
|
||||
* registration process is successfully completed.
|
||||
*/
|
||||
abstract registerFinish(request: RegisterFinishRequest): Promise<string>;
|
||||
|
||||
/**
|
||||
* Sets the [dbo].[User].[VerifyDevices] flag to true or false.
|
||||
*
|
||||
* @param request - The request object is a SecretVerificationRequest extension
|
||||
* that also contains the boolean value that the VerifyDevices property is being
|
||||
* set to.
|
||||
* @returns A promise that resolves when the process is successfully completed or
|
||||
* a bad request if secret verification fails.
|
||||
*/
|
||||
abstract setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export abstract class AccountService {
|
||||
* Observable of the last activity time for each account.
|
||||
*/
|
||||
accountActivity$: Observable<Record<UserId, Date>>;
|
||||
/** Observable of the new device login verification property for the account. */
|
||||
accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
/** Account list in order of descending recency */
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
/** Next account that is not the current active account */
|
||||
@@ -73,6 +75,15 @@ export abstract class AccountService {
|
||||
* @param emailVerified
|
||||
*/
|
||||
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
|
||||
* @param userId
|
||||
* @param VerifyNewDeviceLogin
|
||||
*/
|
||||
abstract setAccountVerifyNewDeviceLogin(
|
||||
userId: UserId,
|
||||
verifyNewDeviceLogin: boolean,
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Updates the `activeAccount$` observable with the new active account.
|
||||
* @param userId
|
||||
|
||||
@@ -9,7 +9,24 @@ import { DeviceKey, UserKey } from "../../types/key";
|
||||
import { DeviceResponse } from "./devices/responses/device.response";
|
||||
|
||||
export abstract class DeviceTrustServiceAbstraction {
|
||||
/**
|
||||
* @deprecated - use supportsDeviceTrustByUserId instead as active user state is being deprecated
|
||||
* by Platform
|
||||
* @description Checks if the device trust feature is supported for the active user.
|
||||
*/
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
|
||||
* the consuming component to display a toast informing the user the device has been trusted.
|
||||
*/
|
||||
deviceTrusted$: Observable<void>;
|
||||
|
||||
/**
|
||||
* @description Checks if the device trust feature is supported for the given user.
|
||||
*/
|
||||
supportsDeviceTrustByUserId$: (userId: UserId) => Observable<boolean>;
|
||||
|
||||
/**
|
||||
* @description Retrieves the users choice to trust the device which can only happen after decryption
|
||||
* Note: this value should only be used once and then reset
|
||||
|
||||
@@ -36,4 +36,10 @@ export abstract class DevicesApiServiceAbstraction {
|
||||
* @param deviceIdentifier - current device identifier
|
||||
*/
|
||||
postDeviceTrustLoss: (deviceIdentifier: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Deactivates a device
|
||||
* @param deviceId - The device ID
|
||||
*/
|
||||
deactivateDevice: (deviceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceResponse } from "./responses/device.response";
|
||||
import { DeviceView } from "./views/device.view";
|
||||
|
||||
export abstract class DevicesServiceAbstraction {
|
||||
getDevices$: () => Observable<Array<DeviceView>>;
|
||||
getDeviceByIdentifier$: (deviceIdentifier: string) => Observable<DeviceView>;
|
||||
isDeviceKnownForUser$: (email: string, deviceIdentifier: string) => Observable<boolean>;
|
||||
updateTrustedDeviceKeys$: (
|
||||
abstract getDevices$(): Observable<Array<DeviceView>>;
|
||||
abstract getDeviceByIdentifier$(deviceIdentifier: string): Observable<DeviceView>;
|
||||
abstract isDeviceKnownForUser$(email: string, deviceIdentifier: string): Observable<boolean>;
|
||||
abstract updateTrustedDeviceKeys$(
|
||||
deviceIdentifier: string,
|
||||
devicePublicKeyEncryptedUserKey: string,
|
||||
userKeyEncryptedDevicePublicKey: string,
|
||||
deviceKeyEncryptedDevicePrivateKey: string,
|
||||
) => Observable<DeviceView>;
|
||||
): Observable<DeviceView>;
|
||||
abstract deactivateDevice$(deviceId: string): Observable<void>;
|
||||
abstract getCurrentDevice$(): Observable<DeviceResponse>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DeviceType } from "../../../../enums";
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
|
||||
export interface DevicePendingAuthRequest {
|
||||
id: string;
|
||||
creationDate: string;
|
||||
}
|
||||
|
||||
export class DeviceResponse extends BaseResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -9,6 +14,9 @@ export class DeviceResponse extends BaseResponse {
|
||||
type: DeviceType;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
isTrusted: boolean;
|
||||
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
@@ -18,5 +26,7 @@ export class DeviceResponse extends BaseResponse {
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
this.isTrusted = this.getResponseProperty("IsTrusted");
|
||||
this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { DeviceType } from "../../../../enums";
|
||||
import { View } from "../../../../models/view/view";
|
||||
import { DeviceResponse } from "../responses/device.response";
|
||||
|
||||
export class DeviceView implements View {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
type: DeviceType;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
id: string | undefined;
|
||||
userId: string | undefined;
|
||||
name: string | undefined;
|
||||
identifier: string | undefined;
|
||||
type: DeviceType | undefined;
|
||||
creationDate: string | undefined;
|
||||
revisionDate: string | undefined;
|
||||
response: DeviceResponse | undefined;
|
||||
|
||||
constructor(deviceResponse: DeviceResponse) {
|
||||
Object.assign(this, deviceResponse);
|
||||
this.response = deviceResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export abstract class SsoLoginServiceAbstraction {
|
||||
/**
|
||||
* Gets the code verifier used for SSO.
|
||||
@@ -11,7 +11,7 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc7636
|
||||
* @returns The code verifier used for SSO.
|
||||
*/
|
||||
getCodeVerifier: () => Promise<string>;
|
||||
abstract getCodeVerifier: () => Promise<string | null>;
|
||||
/**
|
||||
* Sets the code verifier used for SSO.
|
||||
*
|
||||
@@ -21,7 +21,7 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
* and verify it matches the one sent in the request for the `authorization_code`.
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc7636
|
||||
*/
|
||||
setCodeVerifier: (codeVerifier: string) => Promise<void>;
|
||||
abstract setCodeVerifier: (codeVerifier: string) => Promise<void>;
|
||||
/**
|
||||
* Gets the value of the SSO state.
|
||||
*
|
||||
@@ -31,7 +31,7 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
|
||||
* @returns The SSO state.
|
||||
*/
|
||||
getSsoState: () => Promise<string>;
|
||||
abstract getSsoState: () => Promise<string | null>;
|
||||
/**
|
||||
* Sets the value of the SSO state.
|
||||
*
|
||||
@@ -40,7 +40,7 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
* returns the `state` in the callback and the client verifies that the value returned matches the value sent.
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
|
||||
*/
|
||||
setSsoState: (ssoState: string) => Promise<void>;
|
||||
abstract setSsoState: (ssoState: string) => Promise<void>;
|
||||
/**
|
||||
* Gets the value of the user's organization sso identifier.
|
||||
*
|
||||
@@ -48,20 +48,20 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
* Do not use this value outside of the SSO login flow.
|
||||
* @returns The user's organization identifier.
|
||||
*/
|
||||
getOrganizationSsoIdentifier: () => Promise<string>;
|
||||
abstract getOrganizationSsoIdentifier: () => Promise<string | null>;
|
||||
/**
|
||||
* Sets the value of the user's organization sso identifier.
|
||||
*
|
||||
* This should only be used during the SSO flow to identify the organization that the user is attempting to log in to.
|
||||
* Do not use this value outside of the SSO login flow.
|
||||
*/
|
||||
setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
|
||||
abstract setOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
|
||||
/**
|
||||
* Gets the user's email.
|
||||
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
|
||||
* @returns The user's email.
|
||||
*/
|
||||
getSsoEmail: () => Promise<string>;
|
||||
abstract getSsoEmail: () => Promise<string | null>;
|
||||
/**
|
||||
* Sets the user's email.
|
||||
* Note: This should only be used during the SSO flow to identify the user that is attempting to log in.
|
||||
@@ -69,17 +69,21 @@ export abstract class SsoLoginServiceAbstraction {
|
||||
* @returns A promise that resolves when the email has been set.
|
||||
*
|
||||
*/
|
||||
setSsoEmail: (email: string) => Promise<void>;
|
||||
abstract setSsoEmail: (email: string) => Promise<void>;
|
||||
/**
|
||||
* Gets the value of the active user's organization sso identifier.
|
||||
*
|
||||
* This should only be used post successful SSO login once the user is initialized.
|
||||
* @param userId The user id for retrieving the org identifier state.
|
||||
*/
|
||||
getActiveUserOrganizationSsoIdentifier: () => Promise<string>;
|
||||
abstract getActiveUserOrganizationSsoIdentifier: (userId: UserId) => Promise<string | null>;
|
||||
/**
|
||||
* Sets the value of the active user's organization sso identifier.
|
||||
*
|
||||
* This should only be used post successful SSO login once the user is initialized.
|
||||
*/
|
||||
setActiveUserOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
|
||||
abstract setActiveUserOrganizationSsoIdentifier: (
|
||||
organizationIdentifier: string,
|
||||
userId: UserId | undefined,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { VaultTimeout, VaultTimeoutAction } from "../../key-management/vault-timeout";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { VaultTimeout } from "../../types/vault-timeout.type";
|
||||
import { SetTokensResult } from "../models/domain/set-tokens-result";
|
||||
import { DecodedAccessToken } from "../services/token.service";
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
|
||||
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
|
||||
|
||||
@@ -11,15 +9,52 @@ export interface TwoFactorProviderDetails {
|
||||
sort: number;
|
||||
premium: boolean;
|
||||
}
|
||||
|
||||
export abstract class TwoFactorService {
|
||||
init: () => void;
|
||||
getSupportedProviders: (win: Window) => Promise<TwoFactorProviderDetails[]>;
|
||||
getDefaultProvider: (webAuthnSupported: boolean) => Promise<TwoFactorProviderType>;
|
||||
setSelectedProvider: (type: TwoFactorProviderType) => Promise<void>;
|
||||
clearSelectedProvider: () => Promise<void>;
|
||||
/**
|
||||
* Initializes the client-side's TwoFactorProviders const with translations.
|
||||
*/
|
||||
abstract init(): void;
|
||||
|
||||
setProviders: (response: IdentityTwoFactorResponse) => Promise<void>;
|
||||
clearProviders: () => Promise<void>;
|
||||
getProviders: () => Promise<Map<TwoFactorProviderType, { [key: string]: string }>>;
|
||||
/**
|
||||
* Gets a list of two-factor providers from state that are supported on the current client.
|
||||
* E.g., WebAuthn and Duo are not available on all clients.
|
||||
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
|
||||
*/
|
||||
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
|
||||
|
||||
/**
|
||||
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
|
||||
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
|
||||
*/
|
||||
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
|
||||
|
||||
/**
|
||||
* Sets the selected two-factor provider in state.
|
||||
* @param type - The type of two-factor provider to set as the selected provider.
|
||||
*/
|
||||
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clears the selected two-factor provider from state.
|
||||
*/
|
||||
abstract clearSelectedProvider(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the list of available two-factor providers in state.
|
||||
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
|
||||
*/
|
||||
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clears the list of available two-factor providers from state.
|
||||
*/
|
||||
abstract clearProviders(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the list of two-factor providers from state.
|
||||
* Note: no filtering is done here, so this will return all providers, including potentially
|
||||
* unsupported ones for the current client.
|
||||
* @returns A list of two-factor providers or null if none are stored in state.
|
||||
*/
|
||||
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum TwoFactorProviderType {
|
||||
Remember = 5,
|
||||
OrganizationDuo = 6,
|
||||
WebAuthn = 7,
|
||||
RecoveryCode = 8,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AuthResult {
|
||||
ssoEmail2FaSessionToken?: string;
|
||||
email: string;
|
||||
requiresEncryptionKeyMigration: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
|
||||
get requiresCaptcha() {
|
||||
return !Utils.isNullOrWhitespace(this.captchaSiteKey);
|
||||
|
||||
@@ -13,6 +13,7 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
|
||||
public captchaResponse: string,
|
||||
protected twoFactor: TokenTwoFactorRequest,
|
||||
device?: DeviceRequest,
|
||||
public newDeviceOtp?: string,
|
||||
) {
|
||||
super(twoFactor, device);
|
||||
}
|
||||
@@ -28,6 +29,10 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
|
||||
obj.captchaResponse = this.captchaResponse;
|
||||
}
|
||||
|
||||
if (this.newDeviceOtp) {
|
||||
obj.newDeviceOtp = this.newDeviceOtp;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export abstract class TokenRequest {
|
||||
this.device = device != null ? device : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
// Implemented in subclass if required
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SecretVerificationRequest } from "./secret-verification.request";
|
||||
|
||||
export class SetVerifyDevicesRequest extends SecretVerificationRequest {
|
||||
/**
|
||||
* This is the input for a user update that controls [dbo].[Users].[VerifyDevices]
|
||||
*/
|
||||
verifyDevices!: boolean;
|
||||
}
|
||||
@@ -8,8 +8,8 @@ export class UpdateDevicesTrustRequest extends SecretVerificationRequest {
|
||||
}
|
||||
|
||||
export class DeviceKeysUpdateRequest {
|
||||
encryptedPublicKey: string;
|
||||
encryptedUserKey: string;
|
||||
encryptedPublicKey: string | undefined;
|
||||
encryptedUserKey: string | undefined;
|
||||
}
|
||||
|
||||
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
// 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";
|
||||
|
||||
export class WebauthnRotateCredentialRequest {
|
||||
id: string;
|
||||
|
||||
@@ -6,8 +6,11 @@ const RequestTimeOut = 60000 * 15; //15 Minutes
|
||||
export class AuthRequestResponse extends BaseResponse {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
requestDeviceType: DeviceType;
|
||||
requestDeviceType: string;
|
||||
requestDeviceTypeValue: DeviceType;
|
||||
requestDeviceIdentifier: string;
|
||||
requestIpAddress: string;
|
||||
requestCountryName: string;
|
||||
key: string; // could be either an encrypted MasterKey or an encrypted UserKey
|
||||
masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey)
|
||||
creationDate: string;
|
||||
@@ -21,7 +24,10 @@ export class AuthRequestResponse extends BaseResponse {
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.publicKey = this.getResponseProperty("PublicKey");
|
||||
this.requestDeviceType = this.getResponseProperty("RequestDeviceType");
|
||||
this.requestDeviceTypeValue = this.getResponseProperty("RequestDeviceTypeValue");
|
||||
this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier");
|
||||
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
|
||||
this.requestCountryName = this.getResponseProperty("RequestCountryName");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class IdentityDeviceVerificationResponse extends BaseResponse {
|
||||
deviceVerified: boolean;
|
||||
captchaToken: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.deviceVerified = this.getResponseProperty("DeviceVerified") ?? false;
|
||||
|
||||
this.captchaToken = this.getResponseProperty("CaptchaBypassToken");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
|
||||
|
||||
export type IdentityResponse =
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse;
|
||||
@@ -10,6 +10,7 @@ import { UserVerificationService } from "../abstractions/user-verification/user-
|
||||
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
|
||||
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
|
||||
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
|
||||
import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request";
|
||||
import { Verification } from "../types/verification";
|
||||
|
||||
export class AccountApiServiceImplementation implements AccountApiService {
|
||||
@@ -102,4 +103,21 @@ export class AccountApiServiceImplementation implements AccountApiService {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string> {
|
||||
try {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/accounts/verify-devices",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeGlobalState } from "../../../spec/fake-state";
|
||||
import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
|
||||
import {
|
||||
FakeGlobalStateProvider,
|
||||
FakeSingleUserStateProvider,
|
||||
} from "../../../spec/fake-state-provider";
|
||||
import { trackEmissions } from "../../../spec/utils";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
ACCOUNT_ACCOUNTS,
|
||||
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||
ACCOUNT_ACTIVITY,
|
||||
ACCOUNT_VERIFY_NEW_DEVICE_LOGIN,
|
||||
AccountServiceImplementation,
|
||||
} from "./account.service";
|
||||
|
||||
@@ -66,9 +70,11 @@ describe("accountService", () => {
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
let singleUserStateProvider: FakeSingleUserStateProvider;
|
||||
let sut: AccountServiceImplementation;
|
||||
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
|
||||
let activeAccountIdState: FakeGlobalState<UserId>;
|
||||
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userInfo = { email: "email", name: "name", emailVerified: true };
|
||||
|
||||
@@ -76,11 +82,18 @@ describe("accountService", () => {
|
||||
messagingService = mock();
|
||||
logService = mock();
|
||||
globalStateProvider = new FakeGlobalStateProvider();
|
||||
singleUserStateProvider = new FakeSingleUserStateProvider();
|
||||
|
||||
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
|
||||
sut = new AccountServiceImplementation(
|
||||
messagingService,
|
||||
logService,
|
||||
globalStateProvider,
|
||||
singleUserStateProvider,
|
||||
);
|
||||
|
||||
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
|
||||
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||
accountActivityState = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -126,6 +139,22 @@ describe("accountService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountsVerifyNewDeviceLogin$", () => {
|
||||
it("returns expected value", async () => {
|
||||
// Arrange
|
||||
const expected = true;
|
||||
// we need to set this state since it is how we initialize the VerifyNewDeviceLogin$
|
||||
activeAccountIdState.stateSubject.next(userId);
|
||||
singleUserStateProvider.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).nextState(expected);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAccount", () => {
|
||||
it("should emit the new account", async () => {
|
||||
await sut.addAccount(userId, userInfo);
|
||||
@@ -224,6 +253,33 @@ describe("accountService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountVerifyNewDeviceLogin", () => {
|
||||
const initialState = true;
|
||||
beforeEach(() => {
|
||||
activeAccountIdState.stateSubject.next(userId);
|
||||
singleUserStateProvider
|
||||
.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN)
|
||||
.nextState(initialState);
|
||||
});
|
||||
|
||||
it("should update the VerifyNewDeviceLogin", async () => {
|
||||
const expected = false;
|
||||
expect(await firstValueFrom(sut.accountVerifyNewDeviceLogin$)).toEqual(initialState);
|
||||
|
||||
await sut.setAccountVerifyNewDeviceLogin(userId, expected);
|
||||
const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
|
||||
|
||||
expect(currentState).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should NOT update VerifyNewDeviceLogin when userId is null", async () => {
|
||||
await sut.setAccountVerifyNewDeviceLogin(null, false);
|
||||
const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$);
|
||||
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clean", () => {
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next({ [userId]: userInfo });
|
||||
@@ -256,6 +312,7 @@ describe("accountService", () => {
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next({ [userId]: userInfo });
|
||||
activeAccountIdState.stateSubject.next(userId);
|
||||
accountActivityState.stateSubject.next({ [userId]: new Date(1) });
|
||||
});
|
||||
|
||||
it("should emit null if no account is provided", async () => {
|
||||
@@ -269,6 +326,34 @@ describe("accountService", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
|
||||
});
|
||||
|
||||
it("should change active account when switched to the new account", async () => {
|
||||
const newUserId = Utils.newGuid() as UserId;
|
||||
accountsState.stateSubject.next({ [newUserId]: userInfo });
|
||||
|
||||
await sut.switchAccount(newUserId);
|
||||
|
||||
await expect(firstValueFrom(sut.activeAccount$)).resolves.toEqual({
|
||||
id: newUserId,
|
||||
...userInfo,
|
||||
});
|
||||
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
|
||||
[userId]: new Date(1),
|
||||
[newUserId]: expect.toAlmostEqual(new Date(), 1000),
|
||||
});
|
||||
});
|
||||
|
||||
it("should not change active account when already switched to the same account", async () => {
|
||||
await sut.switchAccount(userId);
|
||||
|
||||
await expect(firstValueFrom(sut.activeAccount$)).resolves.toEqual({
|
||||
id: userId,
|
||||
...userInfo,
|
||||
});
|
||||
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
|
||||
[userId]: new Date(1),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account activity", () => {
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
shareReplay,
|
||||
combineLatest,
|
||||
Observable,
|
||||
switchMap,
|
||||
filter,
|
||||
timeout,
|
||||
of,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
@@ -23,6 +27,8 @@ import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
SingleUserStateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
@@ -42,6 +48,15 @@ export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK,
|
||||
deserializer: (activity) => new Date(activity),
|
||||
});
|
||||
|
||||
export const ACCOUNT_VERIFY_NEW_DEVICE_LOGIN = new UserKeyDefinition<boolean>(
|
||||
ACCOUNT_DISK,
|
||||
"verifyNewDeviceLogin",
|
||||
{
|
||||
deserializer: (verifyDevices) => verifyDevices,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
const LOGGED_OUT_INFO: AccountInfo = {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
@@ -73,6 +88,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
activeAccount$: Observable<Account | null>;
|
||||
accountActivity$: Observable<Record<UserId, Date>>;
|
||||
accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
nextUpAccount$: Observable<Account>;
|
||||
|
||||
@@ -80,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
private messagingService: MessagingService,
|
||||
private logService: LogService,
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private singleUserStateProvider: SingleUserStateProvider,
|
||||
) {
|
||||
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
|
||||
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||
@@ -114,6 +131,12 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
return nextId ? { id: nextId, ...accounts[nextId] } : null;
|
||||
}),
|
||||
);
|
||||
this.accountVerifyNewDeviceLogin$ = this.activeAccountIdState.state$.pipe(
|
||||
switchMap(
|
||||
(userId) =>
|
||||
this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).state$,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
|
||||
@@ -149,21 +172,28 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
async switchAccount(userId: UserId | null): Promise<void> {
|
||||
let updateActivity = false;
|
||||
await this.activeAccountIdState.update(
|
||||
(_, accounts) => {
|
||||
if (userId == null) {
|
||||
// indicates no account is active
|
||||
return null;
|
||||
}
|
||||
|
||||
if (accounts?.[userId] == null) {
|
||||
throw new Error("Account does not exist");
|
||||
}
|
||||
(_, __) => {
|
||||
updateActivity = true;
|
||||
return userId;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.accounts$,
|
||||
shouldUpdate: (id) => {
|
||||
combineLatestWith: this.accountsState.state$.pipe(
|
||||
filter((accounts) => {
|
||||
if (userId == null) {
|
||||
// Don't worry about accounts when we are about to set active user to null
|
||||
return true;
|
||||
}
|
||||
|
||||
return accounts?.[userId] != null;
|
||||
}),
|
||||
// If we don't get the desired account with enough time, just return empty as that will result in the same error
|
||||
timeout({ first: 1000, with: () => of({} as Record<UserId, AccountInfo>) }),
|
||||
),
|
||||
shouldUpdate: (id, accounts) => {
|
||||
if (userId != null && accounts?.[userId] == null) {
|
||||
throw new Error("Account does not exist");
|
||||
}
|
||||
|
||||
// update only if userId changes
|
||||
return id !== userId;
|
||||
},
|
||||
@@ -193,6 +223,20 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
);
|
||||
}
|
||||
|
||||
async setAccountVerifyNewDeviceLogin(
|
||||
userId: UserId,
|
||||
setVerifyNewDeviceLogin: boolean,
|
||||
): Promise<void> {
|
||||
if (!Utils.isGuid(userId)) {
|
||||
// only store for valid userIds
|
||||
return;
|
||||
}
|
||||
|
||||
await this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).update(() => {
|
||||
return setVerifyNewDeviceLogin;
|
||||
});
|
||||
}
|
||||
|
||||
async removeAccountActivity(userId: UserId): Promise<void> {
|
||||
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
|
||||
(activity) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { AuthRequestServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import { NotificationType } from "../../enums";
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
makeStaticByteArray,
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { MessageSender } from "../../platform/messaging";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { firstValueFrom, map, Observable, Subject } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
|
||||
// Observable emission is used to trigger a toast in consuming components
|
||||
private deviceTrustedSubject = new Subject<void>();
|
||||
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
|
||||
|
||||
constructor(
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
@@ -79,7 +83,17 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
|
||||
map((options) => options?.trustedDeviceOption != null ?? false),
|
||||
map((options) => {
|
||||
return options?.trustedDeviceOption != null ?? false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean> {
|
||||
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
|
||||
map((options) => {
|
||||
return options?.trustedDeviceOption != null ?? false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
// store device key in local/secure storage if enc keys posted to server successfully
|
||||
await this.setDeviceKey(userId, deviceKey);
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
|
||||
// This emission will be picked up by consuming components to handle displaying a toast to the user
|
||||
this.deviceTrustedSubject.next();
|
||||
|
||||
return deviceResponse;
|
||||
}
|
||||
@@ -335,6 +350,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
);
|
||||
|
||||
return new SymmetricCryptoKey(userKey) as UserKey;
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
// If either decryption effort fails, we want to remove the device key
|
||||
this.logService.error("Failed to decrypt using device key. Removing device key.");
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { matches, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeActiveUserState } from "../../../spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { DeviceType } from "../../enums";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
@@ -70,17 +74,56 @@ describe("deviceTrustService", () => {
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
let userDecryptionOptions: UserDecryptionOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
const supportsSecureStorage = false; // default to false; tests will override as needed
|
||||
// By default all the tests will have a mocked active user in state provider.
|
||||
deviceTrustService = createDeviceTrustService(mockUserId, supportsSecureStorage);
|
||||
|
||||
userDecryptionOptions = new UserDecryptionOptions();
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(deviceTrustService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("supportsDeviceTrustByUserId$", () => {
|
||||
it("returns true when the user has a non-null trusted device decryption option", async () => {
|
||||
// Arrange
|
||||
userDecryptionOptions.trustedDeviceOption = {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: false,
|
||||
isTdeOffboarding: false,
|
||||
};
|
||||
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
new BehaviorSubject<UserDecryptionOptions>(userDecryptionOptions),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
deviceTrustService.supportsDeviceTrustByUserId$(mockUserId),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the user has a null trusted device decryption option", async () => {
|
||||
// Arrange
|
||||
userDecryptionOptions.trustedDeviceOption = null;
|
||||
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
new BehaviorSubject<UserDecryptionOptions>(userDecryptionOptions),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
deviceTrustService.supportsDeviceTrustByUserId$(mockUserId),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Trust Device Choice For Decryption", () => {
|
||||
describe("getShouldTrustDevice", () => {
|
||||
it("gets the user trust device choice for decryption", async () => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
|
||||
import { DevicesApiServiceImplementation } from "./devices-api.service.implementation";
|
||||
|
||||
describe("DevicesApiServiceImplementation", () => {
|
||||
let devicesApiService: DevicesApiServiceImplementation;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
devicesApiService = new DevicesApiServiceImplementation(apiService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getKnownDevice", () => {
|
||||
it("calls api with correct parameters", async () => {
|
||||
const email = "test@example.com";
|
||||
const deviceIdentifier = "device123";
|
||||
apiService.send.mockResolvedValue(true);
|
||||
|
||||
const result = await devicesApiService.getKnownDevice(email, deviceIdentifier);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/devices/knowndevice",
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeviceByIdentifier", () => {
|
||||
it("returns device response", async () => {
|
||||
const deviceIdentifier = "device123";
|
||||
const mockResponse = { id: "123", name: "Test Device" };
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
||||
|
||||
expect(result).toBeInstanceOf(DeviceResponse);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/devices/identifier/${deviceIdentifier}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTrustedDeviceKeys", () => {
|
||||
it("updates device keys and returns device response", async () => {
|
||||
const deviceIdentifier = "device123";
|
||||
const publicKeyEncrypted = "encryptedPublicKey";
|
||||
const userKeyEncrypted = "encryptedUserKey";
|
||||
const deviceKeyEncrypted = "encryptedDeviceKey";
|
||||
const mockResponse = { id: "123", name: "Test Device" };
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await devicesApiService.updateTrustedDeviceKeys(
|
||||
deviceIdentifier,
|
||||
publicKeyEncrypted,
|
||||
userKeyEncrypted,
|
||||
deviceKeyEncrypted,
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(DeviceResponse);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
`/devices/${deviceIdentifier}/keys`,
|
||||
{
|
||||
encryptedPrivateKey: deviceKeyEncrypted,
|
||||
encryptedPublicKey: userKeyEncrypted,
|
||||
encryptedUserKey: publicKeyEncrypted,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("propagates api errors", async () => {
|
||||
const error = new Error("API Error");
|
||||
apiService.send.mockRejectedValue(error);
|
||||
|
||||
await expect(devicesApiService.getDevices()).rejects.toThrow("API Error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -117,4 +117,8 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateDevice(deviceId: string): Promise<void> {
|
||||
await this.apiService.send("POST", `/devices/${deviceId}/deactivate`, null, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Observable, defer, map } from "rxjs";
|
||||
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
|
||||
import { DeviceResponse } from "../../abstractions/devices/responses/device.response";
|
||||
import { DeviceView } from "../../abstractions/devices/views/device.view";
|
||||
@@ -15,7 +16,10 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
|
||||
* (i.e., promsise --> observables are cold until subscribed to)
|
||||
*/
|
||||
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
constructor(private devicesApiService: DevicesApiServiceAbstraction) {}
|
||||
constructor(
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private appIdService: AppIdService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @description Gets the list of all devices.
|
||||
@@ -65,4 +69,21 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
),
|
||||
).pipe(map((deviceResponse: DeviceResponse) => new DeviceView(deviceResponse)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deactivates a device
|
||||
*/
|
||||
deactivateDevice$(deviceId: string): Observable<void> {
|
||||
return defer(() => this.devicesApiService.deactivateDevice(deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets the current device.
|
||||
*/
|
||||
getCurrentDevice$(): Observable<DeviceResponse> {
|
||||
return defer(async () => {
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
import { Organization } from "../../admin-console/models/domain/organization";
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
@@ -93,7 +95,7 @@ describe("KeyConnectorService", () => {
|
||||
organizationData(true, false, "https://key-connector-url.com", 2, false),
|
||||
organizationData(true, true, "https://other-url.com", 2, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
organizationService.organizations$.mockReturnValue(of(orgs));
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
@@ -108,7 +110,7 @@ describe("KeyConnectorService", () => {
|
||||
organizationData(true, false, "https://key-connector-url.com", 2, false),
|
||||
organizationData(false, false, "https://key-connector-url.com", 2, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
organizationService.organizations$.mockReturnValue(of(orgs));
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
@@ -123,7 +125,7 @@ describe("KeyConnectorService", () => {
|
||||
organizationData(true, true, "https://key-connector-url.com", 0, false),
|
||||
organizationData(true, true, "https://key-connector-url.com", 1, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
organizationService.organizations$.mockReturnValue(of(orgs));
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
@@ -138,7 +140,7 @@ describe("KeyConnectorService", () => {
|
||||
organizationData(true, true, "https://key-connector-url.com", 2, true),
|
||||
organizationData(false, true, "https://key-connector-url.com", 2, true),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
organizationService.organizations$.mockReturnValue(of(orgs));
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
@@ -179,7 +181,7 @@ describe("KeyConnectorService", () => {
|
||||
|
||||
// create organization object
|
||||
const data = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
organizationService.getAll.mockResolvedValue([data]);
|
||||
organizationService.organizations$.mockReturnValue(of([data]));
|
||||
|
||||
// uses KeyConnector
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
@@ -193,7 +195,7 @@ describe("KeyConnectorService", () => {
|
||||
it("should return false if the user does not need migration", async () => {
|
||||
tokenService.getIsExternal.mockResolvedValue(false);
|
||||
const data = organizationData(false, false, "https://key-connector-url.com", 2, false);
|
||||
organizationService.getAll.mockResolvedValue([data]);
|
||||
organizationService.organizations$.mockReturnValue(of([data]));
|
||||
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(true);
|
||||
@@ -273,7 +275,7 @@ describe("KeyConnectorService", () => {
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const error = new Error("Failed to post user key to key connector");
|
||||
organizationService.getAll.mockResolvedValue([organization]);
|
||||
organizationService.organizations$.mockReturnValue(of([organization]));
|
||||
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
@@ -364,6 +366,7 @@ describe("KeyConnectorService", () => {
|
||||
accessSecretsManager: false,
|
||||
limitCollectionCreation: true,
|
||||
limitCollectionDeletion: true,
|
||||
limitItemDeletion: true,
|
||||
allowAdminAccessToAllCollectionItems: true,
|
||||
flexibleCollections: false,
|
||||
object: "profileOrganization",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfig,
|
||||
@@ -12,7 +14,6 @@ import {
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserType } from "../../admin-console/enums";
|
||||
import { Organization } from "../../admin-console/models/domain/organization";
|
||||
import { KeysRequest } from "../../models/request/keys.request";
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
} from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
@@ -122,7 +122,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
}
|
||||
|
||||
async getManagingOrganization(userId?: UserId): Promise<Organization> {
|
||||
const orgs = await this.organizationService.getAll(userId);
|
||||
const orgs = await firstValueFrom(this.organizationService.organizations$(userId));
|
||||
return orgs.find(
|
||||
(o) =>
|
||||
o.keyConnectorEnabled &&
|
||||
|
||||
@@ -13,9 +13,9 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
mock = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
masterKeySubject = new ReplaySubject<MasterKey>(1);
|
||||
masterKeySubject = new ReplaySubject<MasterKey | null>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
masterKeyHashSubject = new ReplaySubject<string>(1);
|
||||
masterKeyHashSubject = new ReplaySubject<string | null>(1);
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
|
||||
forceSetPasswordReasonSubject = new ReplaySubject<ForceSetPasswordReason>(1);
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string";
|
||||
@@ -180,10 +179,18 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
let decUserKey: Uint8Array;
|
||||
|
||||
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
decUserKey = await this.encryptService.decryptToBytes(userKey, masterKey);
|
||||
decUserKey = await this.encryptService.decryptToBytes(
|
||||
userKey,
|
||||
masterKey,
|
||||
"Content: User Key; Encrypting Key: Master Key",
|
||||
);
|
||||
} else if (userKey.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const newKey = await this.keyGenerationService.stretchKey(masterKey);
|
||||
decUserKey = await this.encryptService.decryptToBytes(userKey, newKey);
|
||||
decUserKey = await this.encryptService.decryptToBytes(
|
||||
userKey,
|
||||
newKey,
|
||||
"Content: User Key; Encrypting Key: Stretched Master Key",
|
||||
);
|
||||
} else {
|
||||
throw new Error("Unsupported encryption type.");
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { UserId } from "../../../../common/src/types/guid";
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
|
||||
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordEnrollmentRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
|
||||
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserKey } from "../../types/key";
|
||||
|
||||
94
libs/common/src/auth/services/sso-login.service.spec.ts
Normal file
94
libs/common/src/auth/services/sso-login.service.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import {
|
||||
CODE_VERIFIER,
|
||||
GLOBAL_ORGANIZATION_SSO_IDENTIFIER,
|
||||
SSO_EMAIL,
|
||||
SSO_STATE,
|
||||
SsoLoginService,
|
||||
USER_ORGANIZATION_SSO_IDENTIFIER,
|
||||
} from "@bitwarden/common/auth/services/sso-login.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
|
||||
describe("SSOLoginService ", () => {
|
||||
let sut: SsoLoginService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let mockSingleUserStateProvider: FakeStateProvider;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let userId: UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
userId = Utils.newGuid() as UserId;
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
mockSingleUserStateProvider = new FakeStateProvider(accountService);
|
||||
mockLogService = mock<LogService>();
|
||||
|
||||
sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
it("gets and sets code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
await sut.setCodeVerifier(codeVerifier);
|
||||
mockSingleUserStateProvider.getGlobal(CODE_VERIFIER);
|
||||
|
||||
const result = await sut.getCodeVerifier();
|
||||
expect(result).toBe(codeVerifier);
|
||||
});
|
||||
|
||||
it("gets and sets SSO state", async () => {
|
||||
const ssoState = "test-sso-state";
|
||||
await sut.setSsoState(ssoState);
|
||||
mockSingleUserStateProvider.getGlobal(SSO_STATE);
|
||||
|
||||
const result = await sut.getSsoState();
|
||||
expect(result).toBe(ssoState);
|
||||
});
|
||||
|
||||
it("gets and sets organization SSO identifier", async () => {
|
||||
const orgIdentifier = "test-org-identifier";
|
||||
await sut.setOrganizationSsoIdentifier(orgIdentifier);
|
||||
mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
|
||||
|
||||
const result = await sut.getOrganizationSsoIdentifier();
|
||||
expect(result).toBe(orgIdentifier);
|
||||
});
|
||||
|
||||
it("gets and sets SSO email", async () => {
|
||||
const email = "test@example.com";
|
||||
await sut.setSsoEmail(email);
|
||||
mockSingleUserStateProvider.getGlobal(SSO_EMAIL);
|
||||
|
||||
const result = await sut.getSsoEmail();
|
||||
expect(result).toBe(email);
|
||||
});
|
||||
|
||||
it("gets and sets active user organization SSO identifier", async () => {
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const orgIdentifier = "test-active-org-identifier";
|
||||
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId);
|
||||
mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
|
||||
|
||||
const result = await sut.getActiveUserOrganizationSsoIdentifier(userId);
|
||||
expect(result).toBe(orgIdentifier);
|
||||
});
|
||||
|
||||
it("logs error when setting active user organization SSO identifier with undefined userId", async () => {
|
||||
const orgIdentifier = "test-active-org-identifier";
|
||||
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, undefined);
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(
|
||||
"Tried to set a user organization sso identifier with an undefined user id.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
ActiveUserState,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
SingleUserState,
|
||||
SSO_DISK,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
@@ -15,21 +16,21 @@ import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.ab
|
||||
/**
|
||||
* Uses disk storage so that the code verifier can be persisted across sso redirects.
|
||||
*/
|
||||
const CODE_VERIFIER = new KeyDefinition<string>(SSO_DISK, "ssoCodeVerifier", {
|
||||
export const CODE_VERIFIER = new KeyDefinition<string>(SSO_DISK, "ssoCodeVerifier", {
|
||||
deserializer: (codeVerifier) => codeVerifier,
|
||||
});
|
||||
|
||||
/**
|
||||
* Uses disk storage so that the sso state can be persisted across sso redirects.
|
||||
*/
|
||||
const SSO_STATE = new KeyDefinition<string>(SSO_DISK, "ssoState", {
|
||||
export const SSO_STATE = new KeyDefinition<string>(SSO_DISK, "ssoState", {
|
||||
deserializer: (state) => state,
|
||||
});
|
||||
|
||||
/**
|
||||
* Uses disk storage so that the organization sso identifier can be persisted across sso redirects.
|
||||
*/
|
||||
const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
|
||||
export const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
|
||||
SSO_DISK,
|
||||
"organizationSsoIdentifier",
|
||||
{
|
||||
@@ -41,7 +42,7 @@ const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition<string>(
|
||||
/**
|
||||
* Uses disk storage so that the organization sso identifier can be persisted across sso redirects.
|
||||
*/
|
||||
const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
|
||||
export const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
|
||||
SSO_DISK,
|
||||
"organizationSsoIdentifier",
|
||||
{
|
||||
@@ -52,7 +53,7 @@ const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition<string>(
|
||||
/**
|
||||
* Uses disk storage so that the user's email can be persisted across sso redirects.
|
||||
*/
|
||||
const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
|
||||
export const SSO_EMAIL = new KeyDefinition<string>(SSO_DISK, "ssoEmail", {
|
||||
deserializer: (state) => state,
|
||||
});
|
||||
|
||||
@@ -61,19 +62,18 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
private ssoState: GlobalState<string>;
|
||||
private orgSsoIdentifierState: GlobalState<string>;
|
||||
private ssoEmailState: GlobalState<string>;
|
||||
private activeUserOrgSsoIdentifierState: ActiveUserState<string>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
) {
|
||||
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.activeUserOrgSsoIdentifierState = this.stateProvider.getActive(
|
||||
USER_ORGANIZATION_SSO_IDENTIFIER,
|
||||
);
|
||||
}
|
||||
|
||||
getCodeVerifier(): Promise<string> {
|
||||
getCodeVerifier(): Promise<string | null> {
|
||||
return firstValueFrom(this.codeVerifierState.state$);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
await this.codeVerifierState.update((_) => codeVerifier);
|
||||
}
|
||||
|
||||
getSsoState(): Promise<string> {
|
||||
getSsoState(): Promise<string | null> {
|
||||
return firstValueFrom(this.ssoState.state$);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
await this.ssoState.update((_) => ssoState);
|
||||
}
|
||||
|
||||
getOrganizationSsoIdentifier(): Promise<string> {
|
||||
getOrganizationSsoIdentifier(): Promise<string | null> {
|
||||
return firstValueFrom(this.orgSsoIdentifierState.state$);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
await this.orgSsoIdentifierState.update((_) => organizationIdentifier);
|
||||
}
|
||||
|
||||
getSsoEmail(): Promise<string> {
|
||||
getSsoEmail(): Promise<string | null> {
|
||||
return firstValueFrom(this.ssoEmailState.state$);
|
||||
}
|
||||
|
||||
@@ -105,11 +105,24 @@ export class SsoLoginService implements SsoLoginServiceAbstraction {
|
||||
await this.ssoEmailState.update((_) => email);
|
||||
}
|
||||
|
||||
getActiveUserOrganizationSsoIdentifier(): Promise<string> {
|
||||
return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$);
|
||||
getActiveUserOrganizationSsoIdentifier(userId: UserId): Promise<string | null> {
|
||||
return firstValueFrom(this.userOrgSsoIdentifierState(userId).state$);
|
||||
}
|
||||
|
||||
async setActiveUserOrganizationSsoIdentifier(organizationIdentifier: string): Promise<void> {
|
||||
await this.activeUserOrgSsoIdentifierState.update((_) => organizationIdentifier);
|
||||
async setActiveUserOrganizationSsoIdentifier(
|
||||
organizationIdentifier: string,
|
||||
userId: UserId | undefined,
|
||||
): Promise<void> {
|
||||
if (userId === undefined) {
|
||||
this.logService.error(
|
||||
"Tried to set a user organization sso identifier with an undefined user id.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.userOrgSsoIdentifierState(userId).update((_) => organizationIdentifier);
|
||||
}
|
||||
|
||||
private userOrgSsoIdentifierState(userId: UserId): SingleUserState<string> {
|
||||
return this.stateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
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";
|
||||
@@ -14,7 +20,6 @@ import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
|
||||
import { SetTokensResult } from "../models/domain/set-tokens-result";
|
||||
|
||||
import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Opaque } from "type-fest";
|
||||
|
||||
import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common";
|
||||
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
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";
|
||||
@@ -22,7 +26,6 @@ import {
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
|
||||
import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service";
|
||||
import { SetTokensResult } from "../models/domain/set-tokens-result";
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ export class TwoFactorService implements TwoFactorServiceAbstraction {
|
||||
await this.providersState.update(() => null);
|
||||
}
|
||||
|
||||
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }>> {
|
||||
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
|
||||
return firstValueFrom(this.providers$);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ import {
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
KdfConfig,
|
||||
KeyService,
|
||||
KdfConfigService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { KdfConfigService } from "../../../../../key-management/src/abstractions/kdf-config.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
||||
import { VaultTimeoutSettingsService } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutSettingsService } from "../../../key-management/vault-timeout";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -36,10 +39,9 @@ describe("UserVerificationService", () => {
|
||||
const userVerificationApiService = mock<UserVerificationApiServiceAbstraction>();
|
||||
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
const pinService = mock<PinServiceAbstraction>();
|
||||
const logService = mock<LogService>();
|
||||
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const kdfConfigService = mock<KdfConfigService>();
|
||||
const biometricsService = mock<BiometricsService>();
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
@@ -56,10 +58,8 @@ describe("UserVerificationService", () => {
|
||||
userVerificationApiService,
|
||||
userDecryptionOptionsService,
|
||||
pinService,
|
||||
logService,
|
||||
vaultTimeoutSettingsService,
|
||||
platformUtilsService,
|
||||
kdfConfigService,
|
||||
biometricsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,26 +113,15 @@ describe("UserVerificationService", () => {
|
||||
);
|
||||
|
||||
test.each([
|
||||
[true, true, true, true],
|
||||
[true, true, true, false],
|
||||
[true, true, false, false],
|
||||
[false, true, false, true],
|
||||
[false, false, false, false],
|
||||
[false, false, true, false],
|
||||
[false, false, false, true],
|
||||
[true, BiometricsStatus.Available],
|
||||
[false, BiometricsStatus.DesktopDisconnected],
|
||||
[false, BiometricsStatus.HardwareUnavailable],
|
||||
])(
|
||||
"returns %s for biometrics availability when isBiometricLockSet is %s, hasUserKeyStored is %s, and supportsSecureStorage is %s",
|
||||
async (
|
||||
expectedReturn: boolean,
|
||||
isBiometricsLockSet: boolean,
|
||||
isBiometricsUserKeyStored: boolean,
|
||||
platformSupportSecureStorage: boolean,
|
||||
) => {
|
||||
async (expectedReturn: boolean, biometricsStatus: BiometricsStatus) => {
|
||||
setMasterPasswordAvailability(false);
|
||||
setPinAvailability("DISABLED");
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(isBiometricsLockSet);
|
||||
keyService.hasUserKeyStored.mockResolvedValue(isBiometricsUserKeyStored);
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(platformSupportSecureStorage);
|
||||
biometricsService.getBiometricsStatus.mockResolvedValue(biometricsStatus);
|
||||
|
||||
const result = await sut.getAvailableVerificationOptions("client");
|
||||
|
||||
|
||||
@@ -3,17 +3,19 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
KdfConfigService,
|
||||
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 { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { AccountService } from "../../abstractions/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
|
||||
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
|
||||
@@ -47,10 +49,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
private userVerificationApiService: UserVerificationApiServiceAbstraction,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {}
|
||||
|
||||
async getAvailableVerificationOptions(
|
||||
@@ -58,17 +58,13 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
): Promise<UserVerificationOptions> {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (verificationType === "client") {
|
||||
const [
|
||||
userHasMasterPassword,
|
||||
isPinDecryptionAvailable,
|
||||
biometricsLockSet,
|
||||
biometricsUserKeyStored,
|
||||
] = await Promise.all([
|
||||
this.hasMasterPasswordAndMasterKeyHash(userId),
|
||||
this.pinService.isPinDecryptionAvailable(userId),
|
||||
this.vaultTimeoutSettingsService.isBiometricLockSet(userId),
|
||||
this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric, userId),
|
||||
]);
|
||||
const [userHasMasterPassword, isPinDecryptionAvailable, biometricsStatus] = await Promise.all(
|
||||
[
|
||||
this.hasMasterPasswordAndMasterKeyHash(userId),
|
||||
this.pinService.isPinDecryptionAvailable(userId),
|
||||
this.biometricsService.getBiometricsStatus(),
|
||||
],
|
||||
);
|
||||
|
||||
// note: we do not need to check this.platformUtilsService.supportsBiometric() because
|
||||
// we can just use the logic below which works for both desktop & the browser extension.
|
||||
@@ -77,9 +73,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
client: {
|
||||
masterPassword: userHasMasterPassword,
|
||||
pin: isPinDecryptionAvailable,
|
||||
biometrics:
|
||||
biometricsLockSet &&
|
||||
(biometricsUserKeyStored || !this.platformUtilsService.supportsSecureStorage()),
|
||||
biometrics: biometricsStatus === BiometricsStatus.Available,
|
||||
},
|
||||
server: {
|
||||
masterPassword: false,
|
||||
@@ -169,6 +163,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
const request = new VerifyOTPRequest(verification.secret);
|
||||
try {
|
||||
await this.userVerificationApiService.postAccountVerifyOTP(request);
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
throw new Error(this.i18nService.t("invalidVerificationCode"));
|
||||
}
|
||||
@@ -227,6 +223,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
request.masterPasswordHash = serverKeyHash;
|
||||
try {
|
||||
policyOptions = await this.userVerificationApiService.postAccountVerifyPassword(request);
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
throw new Error(this.i18nService.t("invalidMasterPassword"));
|
||||
}
|
||||
@@ -253,17 +251,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
}
|
||||
|
||||
private async verifyUserByBiometrics(): Promise<boolean> {
|
||||
let userKey: UserKey;
|
||||
// Biometrics crashes and doesn't return a value if the user cancels the prompt
|
||||
try {
|
||||
userKey = await this.keyService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
|
||||
} catch (e) {
|
||||
this.logService.error(`Biometrics User Verification failed: ${e.message}`);
|
||||
// So, any failures should be treated as a failed verification
|
||||
return false;
|
||||
}
|
||||
|
||||
return userKey != null;
|
||||
return this.biometricsService.authenticateWithBiometrics();
|
||||
}
|
||||
|
||||
async requestOTP() {
|
||||
|
||||
@@ -22,5 +22,5 @@ export type ServerSideVerification = OtpVerification | MasterPasswordVerificatio
|
||||
|
||||
export type MasterPasswordVerificationResponse = {
|
||||
masterKey: MasterKey;
|
||||
policyOptions: MasterPasswordPolicyResponse;
|
||||
policyOptions: MasterPasswordPolicyResponse | null;
|
||||
};
|
||||
|
||||
@@ -25,7 +25,10 @@ export class WebAuthnIFrame {
|
||||
const params = new URLSearchParams({
|
||||
data: this.base64Encode(JSON.stringify(data)),
|
||||
parent: encodeURIComponent(this.win.document.location.href),
|
||||
btnText: encodeURIComponent(this.i18nService.t("webAuthnAuthenticate")),
|
||||
btnText: encodeURIComponent(this.i18nService.t("readSecurityKey")),
|
||||
btnAwaitingInteractionText: encodeURIComponent(
|
||||
this.i18nService.t("awaitingSecurityKeyInteraction"),
|
||||
),
|
||||
v: "1",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
@@ -8,6 +10,7 @@ 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);
|
||||
@@ -19,10 +22,13 @@ describe("DefaultDomainSettingsService", () => {
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
|
||||
configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag$.mockImplementation(() => of(false));
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService);
|
||||
|
||||
jest.spyOn(domainSettingsService, "getUrlEquivalentDomains");
|
||||
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
|
||||
domainSettingsService.blockedInteractionsUris$ = of({});
|
||||
});
|
||||
|
||||
describe("getUrlEquivalentDomains", () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { map, Observable } from "rxjs";
|
||||
import { map, Observable, switchMap, of } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "../../enums/feature-flag.enum";
|
||||
import {
|
||||
NeverDomains,
|
||||
EquivalentDomains,
|
||||
UriMatchStrategySetting,
|
||||
UriMatchStrategy,
|
||||
} from "../../models/domain/domain-service";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import {
|
||||
DOMAIN_SETTINGS_DISK,
|
||||
@@ -23,10 +25,20 @@ const SHOW_FAVICONS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "showFavicons", {
|
||||
deserializer: (value: boolean) => value ?? true,
|
||||
});
|
||||
|
||||
// Domain exclusion list for notifications
|
||||
const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", {
|
||||
deserializer: (value: NeverDomains) => value ?? null,
|
||||
});
|
||||
|
||||
// Domain exclusion list for content script injections
|
||||
const BLOCKED_INTERACTIONS_URIS = new KeyDefinition(
|
||||
DOMAIN_SETTINGS_DISK,
|
||||
"blockedInteractionsUris",
|
||||
{
|
||||
deserializer: (value: NeverDomains) => value ?? {},
|
||||
},
|
||||
);
|
||||
|
||||
const EQUIVALENT_DOMAINS = new UserKeyDefinition(DOMAIN_SETTINGS_DISK, "equivalentDomains", {
|
||||
deserializer: (value: EquivalentDomains) => value ?? null,
|
||||
clearOn: ["logout"],
|
||||
@@ -41,15 +53,45 @@ const DEFAULT_URI_MATCH_STRATEGY = new UserKeyDefinition(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The Domain Settings service; provides client settings state for "active client view" URI concerns
|
||||
*/
|
||||
export abstract class DomainSettingsService {
|
||||
/**
|
||||
* Indicates if the favicons for ciphers' URIs should be shown instead of a placeholder
|
||||
*/
|
||||
showFavicons$: Observable<boolean>;
|
||||
setShowFavicons: (newValue: boolean) => Promise<void>;
|
||||
|
||||
/**
|
||||
* User-specified URIs for which the client notifications should not appear
|
||||
*/
|
||||
neverDomains$: Observable<NeverDomains>;
|
||||
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
|
||||
|
||||
/**
|
||||
* User-specified URIs for which client content script injections should not occur, and the state
|
||||
* of banner/notice visibility for those domains within the client
|
||||
*/
|
||||
blockedInteractionsUris$: Observable<NeverDomains>;
|
||||
setBlockedInteractionsUris: (newValue: NeverDomains) => Promise<void>;
|
||||
|
||||
/**
|
||||
* URIs which should be treated as equivalent to each other for various concerns (autofill, etc)
|
||||
*/
|
||||
equivalentDomains$: Observable<EquivalentDomains>;
|
||||
setEquivalentDomains: (newValue: EquivalentDomains, userId: UserId) => Promise<void>;
|
||||
|
||||
/**
|
||||
* User-specified default for URI-matching strategies (for example, when determining relevant
|
||||
* ciphers for an active browser tab). Can be overridden by cipher-specific settings.
|
||||
*/
|
||||
defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
|
||||
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Helper function for the common resolution of a given URL against equivalent domains
|
||||
*/
|
||||
getUrlEquivalentDomains: (url: string) => Observable<Set<string>>;
|
||||
}
|
||||
|
||||
@@ -60,19 +102,37 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
|
||||
private neverDomainsState: GlobalState<NeverDomains>;
|
||||
readonly neverDomains$: Observable<NeverDomains>;
|
||||
|
||||
private blockedInteractionsUrisState: GlobalState<NeverDomains>;
|
||||
readonly blockedInteractionsUris$: Observable<NeverDomains>;
|
||||
|
||||
private equivalentDomainsState: ActiveUserState<EquivalentDomains>;
|
||||
readonly equivalentDomains$: Observable<EquivalentDomains>;
|
||||
|
||||
private defaultUriMatchStrategyState: ActiveUserState<UriMatchStrategySetting>;
|
||||
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS);
|
||||
this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true));
|
||||
|
||||
this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS);
|
||||
this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null));
|
||||
|
||||
// Needs to be global to prevent pre-login injections
|
||||
this.blockedInteractionsUrisState = this.stateProvider.getGlobal(BLOCKED_INTERACTIONS_URIS);
|
||||
|
||||
this.blockedInteractionsUris$ = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.BlockBrowserInjectionsByDomain)
|
||||
.pipe(
|
||||
switchMap((featureIsEnabled) =>
|
||||
featureIsEnabled ? this.blockedInteractionsUrisState.state$ : of({} as NeverDomains),
|
||||
),
|
||||
map((disabledUris) => (Object.keys(disabledUris).length ? disabledUris : {})),
|
||||
);
|
||||
|
||||
this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS);
|
||||
this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null));
|
||||
|
||||
@@ -90,6 +150,10 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
|
||||
await this.neverDomainsState.update(() => newValue);
|
||||
}
|
||||
|
||||
async setBlockedInteractionsUris(newValue: NeverDomains): Promise<void> {
|
||||
await this.blockedInteractionsUrisState.update(() => newValue);
|
||||
}
|
||||
|
||||
async setEquivalentDomains(newValue: EquivalentDomains, userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, EQUIVALENT_DOMAINS).update(() => newValue);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
||||
|
||||
import { CardView } from "../vault/models/view/card.view";
|
||||
|
||||
import {
|
||||
normalizeExpiryYearFormat,
|
||||
isCardExpired,
|
||||
isUrlInList,
|
||||
normalizeExpiryYearFormat,
|
||||
parseYearMonthExpiry,
|
||||
} from "@bitwarden/common/autofill/utils";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
} from "./utils";
|
||||
|
||||
function getExpiryYearValueFormats(currentCentury: string) {
|
||||
return [
|
||||
@@ -86,12 +90,14 @@ function getCardExpiryDateValues() {
|
||||
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
|
||||
const currentDateLastMonth = new Date(currentDate.setMonth(-1));
|
||||
|
||||
return [
|
||||
[null, null, false], // no month, no year
|
||||
[undefined, undefined, false], // no month, no year, invalid values
|
||||
["", "", false], // no month, no year, invalid values
|
||||
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
|
||||
["0", `${currentYear}`, true], // invalid month
|
||||
["0", `${currentYear}`, false], // invalid month
|
||||
["0", `${currentYear - 1}`, true], // invalid 0 month
|
||||
["00", `${currentYear + 1}`, false], // invalid 0 month
|
||||
[`${currentMonth}`, "0000", true], // current month, in the year 2000
|
||||
@@ -103,7 +109,7 @@ function getCardExpiryDateValues() {
|
||||
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
|
||||
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
|
||||
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
|
||||
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
|
||||
[`${currentDateLastMonth.getMonth() + 1}`, `${currentDateLastMonth.getFullYear()}`, true], // last month
|
||||
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
|
||||
];
|
||||
}
|
||||
@@ -282,3 +288,73 @@ describe("parseYearMonthExpiry", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUrlInList", () => {
|
||||
let mockUrlList: NeverDomains;
|
||||
|
||||
it("returns false if the passed URL list is empty", () => {
|
||||
const urlIsInList = isUrlInList("", mockUrlList);
|
||||
|
||||
expect(urlIsInList).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true if the URL hostname is on the passed URL list", () => {
|
||||
mockUrlList = {
|
||||
["bitwarden.com"]: { bannerIsDismissed: true },
|
||||
["duckduckgo.com"]: null,
|
||||
[".lan"]: null,
|
||||
[".net"]: null,
|
||||
["localhost"]: null,
|
||||
["extensions"]: null,
|
||||
};
|
||||
|
||||
const testPages = [
|
||||
"https://www.bitwarden.com/landing-page?some_query_string_key=1&another_one=1",
|
||||
" https://duckduckgo.com/pro ", // Note: embedded whitespacing is intentional
|
||||
"https://network-private-domain.lan/homelabs-dashboard",
|
||||
"https://jsfiddle.net/",
|
||||
"https://localhost:8443/#/login",
|
||||
"chrome://extensions/",
|
||||
];
|
||||
|
||||
for (const pageUrl of testPages) {
|
||||
const urlIsInList = isUrlInList(pageUrl, mockUrlList);
|
||||
|
||||
expect(urlIsInList).toEqual(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false if no items on the passed URL list are a full match for the page hostname", () => {
|
||||
const urlIsInList = isUrlInList("https://paypal.com/", {
|
||||
["some.packed.subdomains.sandbox.paypal.com"]: null,
|
||||
});
|
||||
|
||||
expect(urlIsInList).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns false if the URL hostname is not on the passed URL list", () => {
|
||||
const testPages = ["https://archive.org/", "bitwarden.com.some.otherdomain.com"];
|
||||
|
||||
for (const pageUrl of testPages) {
|
||||
const urlIsInList = isUrlInList(pageUrl, mockUrlList);
|
||||
|
||||
expect(urlIsInList).toEqual(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false if the passed URL is empty", () => {
|
||||
const urlIsInList = isUrlInList("", mockUrlList);
|
||||
|
||||
expect(urlIsInList).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns false if the passed URL is not a valid URL", () => {
|
||||
const testPages = ["twasbrillingandtheslithytoves", "/landing-page", undefined];
|
||||
|
||||
for (const pageUrl of testPages) {
|
||||
const urlIsInList = isUrlInList(pageUrl, mockUrlList);
|
||||
|
||||
expect(urlIsInList).toEqual(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { CardView } from "../vault/models/view/card.view";
|
||||
|
||||
import {
|
||||
DelimiterPatternExpression,
|
||||
ExpiryFullYearPattern,
|
||||
ExpiryFullYearPatternExpression,
|
||||
IrrelevantExpiryCharactersPatternExpression,
|
||||
MonthPatternExpression,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
} from "./constants";
|
||||
|
||||
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
|
||||
@@ -25,11 +27,11 @@ export function normalizeExpiryYearFormat(yearInput: string | number): Year | nu
|
||||
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
|
||||
|
||||
// Exit early if year is already formatted correctly or empty
|
||||
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
|
||||
if (yearInputIsEmpty || (expirationYear && /^[1-9]{1}\d{3}$/.test(expirationYear))) {
|
||||
return expirationYear as Year;
|
||||
}
|
||||
|
||||
expirationYear = expirationYear
|
||||
expirationYear = (expirationYear || "")
|
||||
// For safety, because even input[type="number"] will allow decimals
|
||||
.replace(/[^\d]/g, "")
|
||||
// remove any leading zero padding (leave the last leading zero if it ends the string)
|
||||
@@ -53,7 +55,7 @@ export function normalizeExpiryYearFormat(yearInput: string | number): Year | nu
|
||||
|
||||
/**
|
||||
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
|
||||
* the card is expired.
|
||||
* the card is expired. Uncertain cases return "false".
|
||||
*
|
||||
* @param {CardView} cipherCard
|
||||
* @return {*} {boolean}
|
||||
@@ -62,27 +64,38 @@ export function isCardExpired(cipherCard: CardView): boolean {
|
||||
if (cipherCard) {
|
||||
const { expMonth = null, expYear = null } = cipherCard;
|
||||
|
||||
if (!expYear) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const normalizedYear = normalizeExpiryYearFormat(expYear);
|
||||
const parsedYear = normalizedYear ? parseInt(normalizedYear, 10) : NaN;
|
||||
|
||||
// If the card year is before the current year, don't bother checking the month
|
||||
if (normalizedYear && parseInt(normalizedYear, 10) < now.getFullYear()) {
|
||||
const expiryYearIsBeforeCurrentYear = parsedYear < now.getFullYear();
|
||||
const expiryYearIsAfterCurrentYear = parsedYear > now.getFullYear();
|
||||
|
||||
// If the expiry year is before the current year, skip checking the month, since it must be expired
|
||||
if (normalizedYear && expiryYearIsBeforeCurrentYear) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the expiry year is after the current year, skip checking the month, since it cannot be expired
|
||||
if (normalizedYear && expiryYearIsAfterCurrentYear) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedYear && expMonth) {
|
||||
const parsedMonthInteger = parseInt(expMonth, 10);
|
||||
const parsedMonthIsValid = parsedMonthInteger && !isNaN(parsedMonthInteger);
|
||||
|
||||
const parsedMonth = isNaN(parsedMonthInteger)
|
||||
? 0
|
||||
: // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers
|
||||
Math.max(
|
||||
// `Date` months are zero-indexed
|
||||
parsedMonthInteger - 1,
|
||||
0,
|
||||
);
|
||||
// If the parsed month value is 0, we don't know when the expiry passes this year, so do not treat it as expired
|
||||
if (!parsedMonthIsValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedYear = parseInt(normalizedYear, 10);
|
||||
// `Date` months are zero-indexed
|
||||
const parsedMonth = parsedMonthInteger - 1;
|
||||
|
||||
// First day of the next month
|
||||
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 1);
|
||||
@@ -250,13 +263,18 @@ function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, st
|
||||
parsedMonth = dateInput.slice(-1);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
|
||||
const normalizedParsedYearAlternative = parseInt(
|
||||
normalizeExpiryYearFormat(dateInput.slice(-2)),
|
||||
10,
|
||||
);
|
||||
const normalizedYearFormat = normalizeExpiryYearFormat(parsedYear);
|
||||
const normalizedParsedYear = normalizedYearFormat && parseInt(normalizedYearFormat, 10);
|
||||
const normalizedExpiryYearFormat = normalizeExpiryYearFormat(dateInput.slice(-2));
|
||||
const normalizedParsedYearAlternative =
|
||||
normalizedExpiryYearFormat && parseInt(normalizedExpiryYearFormat, 10);
|
||||
|
||||
if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
|
||||
if (
|
||||
normalizedParsedYear &&
|
||||
normalizedParsedYear < currentYear &&
|
||||
normalizedParsedYearAlternative &&
|
||||
normalizedParsedYearAlternative >= currentYear
|
||||
) {
|
||||
parsedYear = dateInput.slice(-2);
|
||||
parsedMonth = dateInput.slice(0, 1);
|
||||
}
|
||||
@@ -288,17 +306,24 @@ export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null,
|
||||
|
||||
// If there is only one date part, no delimiter was found in the passed value
|
||||
if (dateParts.length === 1) {
|
||||
[parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
|
||||
const [parsedNonDelimitedYear, parsedNonDelimitedMonth] =
|
||||
parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
|
||||
|
||||
parsedYear = parsedNonDelimitedYear;
|
||||
parsedMonth = parsedNonDelimitedMonth;
|
||||
}
|
||||
// There are multiple date parts
|
||||
else {
|
||||
[parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([
|
||||
const [parsedDelimitedYear, parsedDelimitedMonth] = parseDelimitedYearMonthExpiry([
|
||||
sanitizedFirstPart,
|
||||
sanitizedSecondPart,
|
||||
]);
|
||||
|
||||
parsedYear = parsedDelimitedYear;
|
||||
parsedMonth = parsedDelimitedMonth;
|
||||
}
|
||||
|
||||
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
|
||||
const normalizedParsedYear = parsedYear ? normalizeExpiryYearFormat(parsedYear) : null;
|
||||
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
|
||||
|
||||
// Set "empty" values to null
|
||||
@@ -307,3 +332,29 @@ export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null,
|
||||
|
||||
return [parsedYear, parsedMonth];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL string and a NeverDomains object and determines if the passed URL's hostname is in `urlList`
|
||||
*
|
||||
* @param {string} url - representation of URL to check
|
||||
* @param {NeverDomains} urlList - object with hostname key names
|
||||
*/
|
||||
export function isUrlInList(url: string = "", urlList: NeverDomains = {}): boolean {
|
||||
const urlListKeys = urlList && Object.keys(urlList);
|
||||
|
||||
if (urlListKeys.length && url?.length) {
|
||||
let tabHostname;
|
||||
try {
|
||||
tabHostname = Utils.getHostname(url);
|
||||
} catch {
|
||||
// If the input was invalid, exit early and return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tabHostname) {
|
||||
return urlListKeys.some((blockedHostname) => tabHostname.endsWith(blockedHostname));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
BillingInvoiceResponse,
|
||||
BillingTransactionResponse,
|
||||
} from "@bitwarden/common/billing/models/response/billing.response";
|
||||
} from "../../models/response/billing.response";
|
||||
|
||||
export class AccountBillingApiServiceAbstraction {
|
||||
getBillingInvoices: (status?: string, startAfter?: string) => Promise<BillingInvoiceResponse[]>;
|
||||
|
||||
@@ -11,27 +11,32 @@ export type BillingAccountProfile = {
|
||||
|
||||
export abstract class BillingAccountProfileStateService {
|
||||
/**
|
||||
* Emits `true` when the active user's account has been granted premium from any of the
|
||||
* Emits `true` when the user's account has been granted premium from any of the
|
||||
* organizations it is a member of. Otherwise, emits `false`
|
||||
*/
|
||||
hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||
abstract hasPremiumFromAnyOrganization$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits `true` when the active user's account has an active premium subscription at the
|
||||
* Emits `true` when the user's account has an active premium subscription at the
|
||||
* individual user level
|
||||
*/
|
||||
hasPremiumPersonally$: Observable<boolean>;
|
||||
abstract hasPremiumPersonally$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true`
|
||||
*/
|
||||
hasPremiumFromAnySource$: Observable<boolean>;
|
||||
abstract hasPremiumFromAnySource$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Sets the active user's premium status fields upon every full sync, either from their personal
|
||||
* Emits `true` when the subscription menu item should be shown in navigation.
|
||||
* This is hidden for organizations that provide premium, except if the user has premium personally
|
||||
* or has a billing history.
|
||||
*/
|
||||
abstract canViewSubscription$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Sets the user's premium status fields upon every full sync, either from their personal
|
||||
* subscription to premium, or an organization they're a part of that grants them premium.
|
||||
* @param hasPremiumPersonally
|
||||
* @param hasPremiumFromAnyOrganization
|
||||
*/
|
||||
abstract setHasPremium(
|
||||
hasPremiumPersonally: boolean,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
|
||||
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
|
||||
import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response";
|
||||
import { PaymentMethodResponse } from "@bitwarden/common/billing/models/response/payment-method.response";
|
||||
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||
import { PlanResponse } from "../../billing/models/response/plan.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { PaymentMethodType } from "../enums";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request";
|
||||
import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request";
|
||||
import { InvoicesResponse } from "../models/response/invoices.response";
|
||||
import { PaymentMethodResponse } from "../models/response/payment-method.response";
|
||||
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||
|
||||
export abstract class BillingApiServiceAbstraction {
|
||||
@@ -74,4 +75,9 @@ export abstract class BillingApiServiceAbstraction {
|
||||
organizationId: string,
|
||||
request: VerifyBankAccountRequest,
|
||||
) => Promise<void>;
|
||||
|
||||
restartSubscription: (
|
||||
organizationId: string,
|
||||
request: OrganizationCreateRequest,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.response";
|
||||
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
|
||||
|
||||
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
|
||||
import { InitiationPath } from "../../models/request/reference-event.request";
|
||||
import { PaymentMethodType, PlanType } from "../enums";
|
||||
import { PaymentSourceResponse } from "../models/response/payment-source.response";
|
||||
|
||||
export type OrganizationInformation = {
|
||||
name: string;
|
||||
@@ -46,9 +45,7 @@ export type SubscriptionInformation = {
|
||||
};
|
||||
|
||||
export abstract class OrganizationBillingServiceAbstraction {
|
||||
getPaymentSource: (
|
||||
organizationId: string,
|
||||
) => Promise<BillingSourceResponse | PaymentSourceResponse>;
|
||||
getPaymentSource: (organizationId: string) => Promise<PaymentSourceResponse>;
|
||||
|
||||
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
@@ -57,4 +54,9 @@ export abstract class OrganizationBillingServiceAbstraction {
|
||||
) => Promise<OrganizationResponse>;
|
||||
|
||||
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
restartSubscription: (
|
||||
organizationId: string,
|
||||
subscription: SubscriptionInformation,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
BillingInvoiceResponse,
|
||||
BillingTransactionResponse,
|
||||
} from "@bitwarden/common/billing/models/response/billing.response";
|
||||
} from "../../models/response/billing.response";
|
||||
|
||||
export class OrganizationBillingApiServiceAbstraction {
|
||||
getBillingInvoices: (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user