mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[PM-21772] Show key connector domain for new sso users (#15381)
* Passed in userId on RemovePasswordComponent. * Added userId on other references to KeyConnectorService methods * remove password component refactor, test coverage, enabled strict * explicit user id provided to key connector service * redirect to / instead when user not logged in or not managing organization * key connector service explicit user id * key connector service no longer requires account service * key connector service missing null type * cli convert to key connector unit tests * remove unnecessary SyncService * error toast not showing on ErrorResponse * bad import due to merge conflict * bad import due to merge conflict * missing loading in remove password component for browser extension * error handling in remove password component * organization observable race condition in key-connector * usesKeyConnector always returns boolean * unit test coverage * key connector reactive * reactive key connector service * introducing convertAccountRequired$ * cli build fix * moving message sending side effect to sync * key connector service unit tests * fix unit tests * move key connector components to KM team ownership * new unit tests in wrong place * key connector domain shown in remove password component * type safety improvements * convert to key connector command localization * key connector domain in convert to key connector command * convert to key connector command unit tests with prompt assert * organization name placement change in the remove password component * unit test update * show key connector domain for new sso users * confirm key connector domain page does not require auth guard * confirm key connector domain page showing correctly * key connector url required to be provided when migrating user * missing locales * desktop styling * have to sync and navigate to vault after key connector keys exchange * logging verbosity * splitting the web client * splitting the browser client * cleanup * splitting the desktop client * cleanup * cleanup * not necessary if condition * key connector domain tests fix for sso componrnt and login strategy * confirm key connector domain base component unit tests coverage * confirm key connector domain command for cli * confirm key connector domain command for cli unit tests * design adjustments removed repeated text, vertical buttons on desktop, wrong paddings on browser extension * key connector service unit test coverage * new linting rules fixes * accept invitation to organization called twice results in error. Web vault remembers it's original route destination, which we do not want in case of accepting invitation and Key Connector, since provisioning new user through SSO and Key Connector, the user is already accepted. * moved required key connector domain confirmation into state * revert redirect from auth guard * cleanup * sso-login.strategy unit test failing * two-factor-auth.component unit test failing * two-factor-auth.component unit test coverage * cli unit test failing * removal of redundant logs * removal of un-necessary new lines * consolidated component * consolidated component css cleanup * use KdfConfig type * consolidate KDF into KdfConfig type in identity token response * moving KC requiresDomainConfirmation lower in order, after successful auth * simplification of trySetUserKeyWithMasterKey * redirect to confirm key connector route when locked but can't unlock yet --------- Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
@@ -5594,5 +5594,8 @@
|
||||
"moreBreadcrumbs": {
|
||||
"message": "More breadcrumbs",
|
||||
"description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed."
|
||||
},
|
||||
"confirmKeyConnectorDomain": {
|
||||
"message": "Confirm Key Connector domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent } from "@bitwarden/key-management-ui";
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
|
||||
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
|
||||
@@ -598,6 +598,24 @@ const routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "confirm-key-connector-domain",
|
||||
component: ExtensionAnonLayoutWrapperComponent,
|
||||
canActivate: [],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: ConfirmKeyConnectorDomainComponent,
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "confirmKeyConnectorDomain",
|
||||
},
|
||||
showBackButton: true,
|
||||
} satisfies ExtensionAnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tabs",
|
||||
component: TabsV2Component,
|
||||
|
||||
@@ -46,6 +46,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { NodeUtils } from "@bitwarden/node/node-utils";
|
||||
|
||||
import { ConfirmKeyConnectorDomainCommand } from "../../key-management/confirm-key-connector-domain.command";
|
||||
import { Response } from "../../models/response";
|
||||
import { MessageResponse } from "../../models/response/message.response";
|
||||
|
||||
@@ -332,6 +333,24 @@ export class LoginCommand {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Key Connector domain confirmation is required
|
||||
const domainConfirmation = await firstValueFrom(
|
||||
this.keyConnectorService.requiresDomainConfirmation$(response.userId),
|
||||
);
|
||||
if (domainConfirmation != null) {
|
||||
const command = new ConfirmKeyConnectorDomainCommand(
|
||||
response.userId,
|
||||
domainConfirmation.keyConnectorUrl,
|
||||
this.keyConnectorService,
|
||||
this.logoutCallback,
|
||||
this.i18nService,
|
||||
);
|
||||
const confirmResponse = await command.run();
|
||||
if (!confirmResponse.success) {
|
||||
return confirmResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Run full sync before handling success response or password reset flows (to get Master Password Policies)
|
||||
await this.syncService.fullSync(true, { skipTokenRefresh: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createPromptModule } from "inquirer";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Response } from "../models/response";
|
||||
import { MessageResponse } from "../models/response/message.response";
|
||||
import { I18nService } from "../platform/services/i18n.service";
|
||||
|
||||
import { ConfirmKeyConnectorDomainCommand } from "./confirm-key-connector-domain.command";
|
||||
|
||||
jest.mock("inquirer", () => {
|
||||
return {
|
||||
createPromptModule: jest.fn(() => jest.fn(() => Promise.resolve({ confirm: "" }))),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ConfirmKeyConnectorDomainCommand", () => {
|
||||
let command: ConfirmKeyConnectorDomainCommand;
|
||||
|
||||
const userId = "test-user-id" as UserId;
|
||||
const keyConnectorUrl = "https://keyconnector.example.com";
|
||||
|
||||
const keyConnectorService = mock<KeyConnectorService>();
|
||||
const logout = jest.fn();
|
||||
const i18nService = mock<I18nService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
command = new ConfirmKeyConnectorDomainCommand(
|
||||
userId,
|
||||
keyConnectorUrl,
|
||||
keyConnectorService,
|
||||
logout,
|
||||
i18nService,
|
||||
);
|
||||
|
||||
i18nService.t.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case "confirmKeyConnectorDomain":
|
||||
return "Please confirm the domain below with your organization administrator. Key Connector domain: https://keyconnector.example.com";
|
||||
case "confirm":
|
||||
return "Confirm";
|
||||
case "logOut":
|
||||
return "Log out";
|
||||
case "youHaveBeenLoggedOut":
|
||||
return "You have been logged out.";
|
||||
case "organizationUsingKeyConnectorConfirmLoggedOut":
|
||||
return "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("should logout and return error response if no interaction available", async () => {
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
const response = await command.run();
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.success).toEqual(false);
|
||||
expect(response).toEqual(
|
||||
Response.error(
|
||||
new MessageResponse(
|
||||
"An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out.",
|
||||
null,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should logout and return error response if interaction answer is cancel", async () => {
|
||||
process.env.BW_NOINTERACTION = "false";
|
||||
|
||||
(createPromptModule as jest.Mock).mockImplementation(() =>
|
||||
jest.fn((prompt) => {
|
||||
assertPrompt(prompt);
|
||||
return Promise.resolve({ confirm: "cancel" });
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await command.run();
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.success).toEqual(false);
|
||||
expect(response).toEqual(Response.error("You have been logged out."));
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should convert new sso user to key connector and return success response if answer is confirmed", async () => {
|
||||
process.env.BW_NOINTERACTION = "false";
|
||||
|
||||
(createPromptModule as jest.Mock).mockImplementation(() =>
|
||||
jest.fn((prompt) => {
|
||||
assertPrompt(prompt);
|
||||
return Promise.resolve({ confirm: "confirmed" });
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await command.run();
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.success).toEqual(true);
|
||||
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it("should logout and throw error if convert new sso user to key connector failed", async () => {
|
||||
process.env.BW_NOINTERACTION = "false";
|
||||
|
||||
(createPromptModule as jest.Mock).mockImplementation(() =>
|
||||
jest.fn((prompt) => {
|
||||
assertPrompt(prompt);
|
||||
return Promise.resolve({ confirm: "confirmed" });
|
||||
}),
|
||||
);
|
||||
|
||||
keyConnectorService.convertNewSsoUserToKeyConnector.mockRejectedValue(
|
||||
new Error("Migration failed"),
|
||||
);
|
||||
|
||||
await expect(command.run()).rejects.toThrow("Migration failed");
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function assertPrompt(prompt: unknown) {
|
||||
expect(typeof prompt).toEqual("object");
|
||||
expect(prompt).toHaveProperty("type");
|
||||
expect(prompt).toHaveProperty("name");
|
||||
expect(prompt).toHaveProperty("message");
|
||||
expect(prompt).toHaveProperty("choices");
|
||||
const promptObj = prompt as Record<string, unknown>;
|
||||
expect(promptObj["type"]).toEqual("list");
|
||||
expect(promptObj["name"]).toEqual("confirm");
|
||||
expect(promptObj["message"]).toEqual(
|
||||
`Please confirm the domain below with your organization administrator. Key Connector domain: ${keyConnectorUrl}`,
|
||||
);
|
||||
expect(promptObj["choices"]).toBeInstanceOf(Array);
|
||||
const choices = promptObj["choices"] as Array<Record<string, unknown>>;
|
||||
expect(choices).toHaveLength(2);
|
||||
expect(choices[0]).toEqual({
|
||||
name: "Confirm",
|
||||
value: "confirmed",
|
||||
});
|
||||
expect(choices[1]).toEqual({
|
||||
name: "Log out",
|
||||
value: "cancel",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as inquirer from "inquirer";
|
||||
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Response } from "../models/response";
|
||||
import { MessageResponse } from "../models/response/message.response";
|
||||
|
||||
export class ConfirmKeyConnectorDomainCommand {
|
||||
constructor(
|
||||
private readonly userId: UserId,
|
||||
private readonly keyConnectorUrl: string,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
private logout: () => Promise<void>,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async run(): Promise<Response> {
|
||||
// If no interaction available, alert user to use web vault
|
||||
const canInteract = process.env.BW_NOINTERACTION !== "true";
|
||||
if (!canInteract) {
|
||||
await this.logout();
|
||||
return Response.error(
|
||||
new MessageResponse(
|
||||
this.i18nService.t("organizationUsingKeyConnectorConfirmLoggedOut"),
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
|
||||
type: "list",
|
||||
name: "confirm",
|
||||
message: this.i18nService.t("confirmKeyConnectorDomain", this.keyConnectorUrl),
|
||||
choices: [
|
||||
{
|
||||
name: this.i18nService.t("confirm"),
|
||||
value: "confirmed",
|
||||
},
|
||||
{
|
||||
name: this.i18nService.t("logOut"),
|
||||
value: "cancel",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (answer.confirm === "confirmed") {
|
||||
try {
|
||||
await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId);
|
||||
} catch (e) {
|
||||
await this.logout();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return Response.success();
|
||||
} else {
|
||||
await this.logout();
|
||||
return Response.error(this.i18nService.t("youHaveBeenLoggedOut"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,5 +218,20 @@
|
||||
},
|
||||
"myItems": {
|
||||
"message": "My Items"
|
||||
},
|
||||
"organizationUsingKeyConnectorConfirmLoggedOut": {
|
||||
"message": "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out."
|
||||
},
|
||||
"confirmKeyConnectorDomain": {
|
||||
"message": "Please confirm the domain below with your organization administrator. Key Connector domain: $KEYCONNECTORDOMAIN$",
|
||||
"placeholders": {
|
||||
"keyConnectorDomain": {
|
||||
"content": "$1",
|
||||
"example": "Key Connector domain"
|
||||
}
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"message": "Confirm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
NewDeviceVerificationComponent,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { LockComponent } from "@bitwarden/key-management-ui";
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
@@ -296,6 +296,16 @@ const routes: Routes = [
|
||||
component: ChangePasswordComponent,
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: "confirm-key-connector-domain",
|
||||
component: ConfirmKeyConnectorDomainComponent,
|
||||
canActivate: [],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "confirmKeyConnectorDomain",
|
||||
},
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4083,5 +4083,11 @@
|
||||
},
|
||||
"next": {
|
||||
"message": "Next"
|
||||
},
|
||||
"confirmKeyConnectorDomain": {
|
||||
"message": "Confirm Key Connector domain"
|
||||
},
|
||||
"confirm": {
|
||||
"message": "Confirm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { ConfirmKeyConnectorDomainComponent as BaseConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||
|
||||
@Component({
|
||||
selector: "app-confirm-key-connector-domain",
|
||||
template: ` <confirm-key-connector-domain [onBeforeNavigation]="onBeforeNavigation" /> `,
|
||||
standalone: true,
|
||||
imports: [BaseConfirmKeyConnectorDomainComponent],
|
||||
})
|
||||
export class ConfirmKeyConnectorDomainComponent {
|
||||
constructor(private routerService: RouterService) {}
|
||||
|
||||
onBeforeNavigation = async () => {
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
};
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
|
||||
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
|
||||
import { RouteDataProperties } from "./core";
|
||||
import { ReportsModule } from "./dirt/reports";
|
||||
import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component";
|
||||
import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
|
||||
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "./layouts/user-layout.component";
|
||||
@@ -511,6 +512,17 @@ const routes: Routes = [
|
||||
titleId: "removeMasterPassword",
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "confirm-key-connector-domain",
|
||||
component: ConfirmKeyConnectorDomainComponent,
|
||||
canActivate: [],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "confirmKeyConnectorDomain",
|
||||
},
|
||||
titleId: "confirmKeyConnectorDomain",
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{
|
||||
path: "trial-initiation",
|
||||
canActivate: [unauthGuardFn()],
|
||||
|
||||
@@ -11166,6 +11166,9 @@
|
||||
"example": "92873837267"
|
||||
}
|
||||
}
|
||||
},
|
||||
"confirmKeyConnectorDomain": {
|
||||
"message": "Confirm Key Connector domain"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -33,29 +35,35 @@ interface SetupParams {
|
||||
}
|
||||
|
||||
describe("lockGuard", () => {
|
||||
const keyConnectorService = mock<KeyConnectorService>();
|
||||
|
||||
const setup = (setupParams: SetupParams) => {
|
||||
const authService: MockProxy<AuthService> = mock<AuthService>();
|
||||
authService.authStatusFor$.mockReturnValue(of(setupParams.authStatus));
|
||||
|
||||
const vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService> =
|
||||
mock<VaultTimeoutSettingsService>();
|
||||
vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock);
|
||||
vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock ?? true);
|
||||
|
||||
const keyService: MockProxy<KeyService> = mock<KeyService>();
|
||||
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
|
||||
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey ?? true));
|
||||
|
||||
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
|
||||
platformUtilService.getClientType.mockReturnValue(setupParams.clientType);
|
||||
platformUtilService.getClientType.mockReturnValue(setupParams.clientType ?? ClientType.Web);
|
||||
|
||||
const messagingService: MockProxy<MessagingService> = mock<MessagingService>();
|
||||
|
||||
const deviceTrustService: MockProxy<DeviceTrustServiceAbstraction> =
|
||||
mock<DeviceTrustServiceAbstraction>();
|
||||
deviceTrustService.supportsDeviceTrust$ = of(setupParams.supportsDeviceTrust);
|
||||
deviceTrustService.supportsDeviceTrust$ = of(setupParams.supportsDeviceTrust ?? false);
|
||||
|
||||
const userVerificationService: MockProxy<UserVerificationService> =
|
||||
mock<UserVerificationService>();
|
||||
userVerificationService.hasMasterPassword.mockResolvedValue(setupParams.hasMasterPassword);
|
||||
userVerificationService.hasMasterPassword.mockResolvedValue(
|
||||
setupParams.hasMasterPassword ?? true,
|
||||
);
|
||||
|
||||
keyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null));
|
||||
|
||||
const accountService: MockProxy<AccountService> = mock<AccountService>();
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
|
||||
@@ -77,6 +85,7 @@ describe("lockGuard", () => {
|
||||
{ path: "", component: EmptyComponent },
|
||||
{ path: "lock", component: EmptyComponent, canActivate: [lockGuard()] },
|
||||
{ path: "non-lock-route", component: EmptyComponent },
|
||||
{ path: "confirm-key-connector-domain", component: EmptyComponent },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
@@ -88,6 +97,7 @@ describe("lockGuard", () => {
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilService },
|
||||
{ provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService },
|
||||
{ provide: UserVerificationService, useValue: userVerificationService },
|
||||
{ provide: KeyConnectorService, useValue: keyConnectorService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -193,4 +203,19 @@ describe("lockGuard", () => {
|
||||
await router.navigate(["lock"]);
|
||||
expect(router.url).toBe("/");
|
||||
});
|
||||
|
||||
it("should redirect to the confirm-key-connector-domain route when the auth status is locked, can't lock and requires key connector domain confirmation", async () => {
|
||||
const { router } = setup({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
canLock: false,
|
||||
});
|
||||
keyConnectorService.requiresDomainConfirmation$.mockReturnValue(
|
||||
of({
|
||||
keyConnectorUrl: "https://example.com",
|
||||
} as KeyConnectorDomainConfirmation),
|
||||
);
|
||||
|
||||
await router.navigate(["lock"]);
|
||||
expect(router.url).toBe("/confirm-key-connector-domain");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -34,6 +35,7 @@ export function lockGuard(): CanActivateFn {
|
||||
const userVerificationService = inject(UserVerificationService);
|
||||
const vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
const accountService = inject(AccountService);
|
||||
const keyConnectorService = inject(KeyConnectorService);
|
||||
|
||||
const activeUser = await firstValueFrom(accountService.activeAccount$);
|
||||
|
||||
@@ -48,6 +50,12 @@ export function lockGuard(): CanActivateFn {
|
||||
return router.createUrlTree(["/"]);
|
||||
}
|
||||
|
||||
if (
|
||||
(await firstValueFrom(keyConnectorService.requiresDomainConfirmation$(activeUser.id))) != null
|
||||
) {
|
||||
return router.createUrlTree(["confirm-key-connector-domain"]);
|
||||
}
|
||||
|
||||
// if user can't lock, they can't access the lock screen
|
||||
const canLock = await vaultTimeoutSettingsService.canLock(activeUser.id);
|
||||
if (!canLock) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
|
||||
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
@@ -116,6 +117,7 @@ export class SsoComponent implements OnInit {
|
||||
private toastService: ToastService,
|
||||
private ssoComponentService: SsoComponentService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
) {
|
||||
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
|
||||
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
|
||||
@@ -444,6 +446,15 @@ export class SsoComponent implements OnInit {
|
||||
authResult.userId,
|
||||
);
|
||||
|
||||
if (
|
||||
(await firstValueFrom(
|
||||
this.keyConnectorService.requiresDomainConfirmation$(authResult.userId),
|
||||
)) != null
|
||||
) {
|
||||
await this.router.navigate(["confirm-key-connector-domain"]);
|
||||
return;
|
||||
}
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
@@ -79,6 +80,7 @@ describe("TwoFactorAuthComponent", () => {
|
||||
let mockTwoFactorAuthCompCacheService: MockProxy<TwoFactorAuthComponentCacheService>;
|
||||
let mockAuthService: MockProxy<AuthService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockKeyConnnectorService: MockProxy<KeyConnectorService>;
|
||||
|
||||
let mockUserDecryptionOpts: {
|
||||
noMasterPassword: UserDecryptionOptions;
|
||||
@@ -115,6 +117,8 @@ describe("TwoFactorAuthComponent", () => {
|
||||
mockTwoFactorAuthCompService = mock<TwoFactorAuthComponentService>();
|
||||
mockAuthService = mock<AuthService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockKeyConnnectorService = mock<KeyConnectorService>();
|
||||
mockKeyConnnectorService.requiresDomainConfirmation$.mockReturnValue(of(null));
|
||||
|
||||
mockEnvService = mock<EnvironmentService>();
|
||||
mockLoginSuccessHandlerService = mock<LoginSuccessHandlerService>();
|
||||
@@ -215,6 +219,7 @@ describe("TwoFactorAuthComponent", () => {
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: MasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
|
||||
{ provide: KeyConnectorService, useValue: mockKeyConnnectorService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -404,6 +409,24 @@ describe("TwoFactorAuthComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to /confirm-key-connector-domain when Key Connector is enabled and user has no master password", async () => {
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPasswordWithKeyConnector);
|
||||
mockKeyConnnectorService.requiresDomainConfirmation$.mockReturnValue(
|
||||
of({
|
||||
keyConnectorUrl:
|
||||
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption!
|
||||
.keyConnectorUrl,
|
||||
}),
|
||||
);
|
||||
const authResult = new AuthResult();
|
||||
authResult.userId = userId;
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
|
||||
|
||||
await component.submit(token, remember);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["confirm-key-connector-domain"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -166,6 +167,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService,
|
||||
private authService: AuthService,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -455,6 +457,15 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(await firstValueFrom(
|
||||
this.keyConnectorService.requiresDomainConfirmation$(authResult.userId),
|
||||
)) != null
|
||||
) {
|
||||
await this.router.navigate(["confirm-key-connector-domain"]);
|
||||
return;
|
||||
}
|
||||
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
@@ -33,13 +33,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
KeyService,
|
||||
Argon2KdfConfig,
|
||||
PBKDF2KdfConfig,
|
||||
KdfConfigService,
|
||||
KdfType,
|
||||
} from "@bitwarden/key-management";
|
||||
import { KeyService, KdfConfigService } from "@bitwarden/key-management";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import {
|
||||
@@ -220,16 +214,7 @@ export abstract class LoginStrategy {
|
||||
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
|
||||
);
|
||||
|
||||
await this.KdfConfigService.setKdfConfig(
|
||||
userId as UserId,
|
||||
tokenResponse.kdf === KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(tokenResponse.kdfIterations)
|
||||
: new Argon2KdfConfig(
|
||||
tokenResponse.kdfIterations,
|
||||
tokenResponse.kdfMemory,
|
||||
tokenResponse.kdfParallelism,
|
||||
),
|
||||
);
|
||||
await this.KdfConfigService.setKdfConfig(userId as UserId, tokenResponse.kdfConfig);
|
||||
|
||||
await this.billingAccountProfileStateService.setHasPremium(
|
||||
accountInformation.premium ?? false,
|
||||
|
||||
@@ -33,8 +33,8 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { DeviceKey, MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { Argon2KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
@@ -518,15 +518,19 @@ describe("SsoLoginStrategy", () => {
|
||||
});
|
||||
|
||||
it("converts new SSO user with no master password to Key Connector on first login", async () => {
|
||||
tokenResponse.key = null;
|
||||
tokenResponse.key = undefined;
|
||||
tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse,
|
||||
ssoOrgId,
|
||||
expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith(
|
||||
{
|
||||
kdfConfig: new Argon2KdfConfig(10, 64, 4),
|
||||
keyConnectorUrl: keyConnectorUrl,
|
||||
organizationId: ssoOrgId,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
@@ -574,15 +578,19 @@ describe("SsoLoginStrategy", () => {
|
||||
});
|
||||
|
||||
it("converts new SSO user with no master password to Key Connector on first login", async () => {
|
||||
tokenResponse.key = null;
|
||||
tokenResponse.key = undefined;
|
||||
tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse,
|
||||
ssoOrgId,
|
||||
expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith(
|
||||
{
|
||||
kdfConfig: new Argon2KdfConfig(10, 64, 4),
|
||||
keyConnectorUrl: keyConnectorUrl,
|
||||
organizationId: ssoOrgId,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -125,9 +125,13 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
// The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector.
|
||||
const newSsoUser = tokenResponse.key == null;
|
||||
if (newSsoUser) {
|
||||
await this.keyConnectorService.convertNewSsoUserToKeyConnector(
|
||||
tokenResponse,
|
||||
this.cache.value.orgId,
|
||||
// Store Key Connector domain confirmation data in state instead of AuthResult
|
||||
await this.keyConnectorService.setNewSsoUserKeyConnectorConversionData(
|
||||
{
|
||||
kdfConfig: tokenResponse.kdfConfig,
|
||||
keyConnectorUrl: this.getKeyConnectorUrl(tokenResponse),
|
||||
organizationId: this.cache.value.orgId,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
@@ -327,10 +331,12 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
private async trySetUserKeyWithMasterKey(userId: UserId): Promise<void> {
|
||||
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
|
||||
|
||||
// There is a scenario in which the master key is not set here. That will occur if the user
|
||||
// has a master password and is using Key Connector. In that case, we cannot set the master key
|
||||
// There are two scenarios in which the master key is not set here:
|
||||
// 1. If the user has a master password and is using Key Connector. In that case, we cannot set the master key
|
||||
// because the user hasn't entered their master password yet.
|
||||
// Instead, we'll return here and let the migration to Key Connector handle setting the master key.
|
||||
// 2. For new users with Key Connector, we will not have a master key yet, since Key Connector domain
|
||||
// has to be confirmed first.
|
||||
// In both cases, we'll return here and let the migration to Key Connector handle setting the master key.
|
||||
if (!masterKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
throw new Error("2FA not supported yet for WebAuthn Login.");
|
||||
}
|
||||
|
||||
protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) {}
|
||||
|
||||
protected override async setUserKey(idTokenResponse: IdentityTokenResponse, userId: UserId) {
|
||||
const masterKeyEncryptedUserKey = idTokenResponse.key;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType } from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
@@ -20,10 +20,7 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
twoFactorToken: string;
|
||||
kdf: KdfType;
|
||||
kdfIterations: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
kdfConfig: KdfConfig;
|
||||
forcePasswordReset: boolean;
|
||||
masterPasswordPolicy: MasterPasswordPolicyResponse;
|
||||
apiUseKeyConnector: boolean;
|
||||
@@ -45,10 +42,14 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
this.key = new EncString(key);
|
||||
}
|
||||
this.twoFactorToken = this.getResponseProperty("TwoFactorToken");
|
||||
this.kdf = this.getResponseProperty("Kdf");
|
||||
this.kdfIterations = this.getResponseProperty("KdfIterations");
|
||||
this.kdfMemory = this.getResponseProperty("KdfMemory");
|
||||
this.kdfParallelism = this.getResponseProperty("KdfParallelism");
|
||||
const kdf = this.getResponseProperty("Kdf");
|
||||
const kdfIterations = this.getResponseProperty("KdfIterations");
|
||||
const kdfMemory = this.getResponseProperty("KdfMemory");
|
||||
const kdfParallelism = this.getResponseProperty("KdfParallelism");
|
||||
this.kdfConfig =
|
||||
kdf == KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(kdfIterations)
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
|
||||
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
|
||||
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion";
|
||||
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation";
|
||||
|
||||
export abstract class KeyConnectorService {
|
||||
abstract setMasterKeyFromUrl(keyConnectorUrl: string, userId: UserId): Promise<void>;
|
||||
@@ -13,13 +15,18 @@ export abstract class KeyConnectorService {
|
||||
|
||||
abstract migrateUser(keyConnectorUrl: string, userId: UserId): Promise<void>;
|
||||
|
||||
abstract convertNewSsoUserToKeyConnector(
|
||||
tokenResponse: IdentityTokenResponse,
|
||||
orgId: string,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
abstract convertNewSsoUserToKeyConnector(userId: UserId): Promise<void>;
|
||||
|
||||
abstract setUsesKeyConnector(enabled: boolean, userId: UserId): Promise<void>;
|
||||
|
||||
abstract setNewSsoUserKeyConnectorConversionData(
|
||||
conversion: NewSsoUserKeyConnectorConversion,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
|
||||
abstract requiresDomainConfirmation$(
|
||||
userId: UserId,
|
||||
): Observable<KeyConnectorDomainConfirmation | null>;
|
||||
|
||||
abstract convertAccountRequired$: Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface KeyConnectorDomainConfirmation {
|
||||
keyConnectorUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
export interface NewSsoUserKeyConnectorConversion {
|
||||
kdfConfig: KdfConfig;
|
||||
keyConnectorUrl: string;
|
||||
organizationId: string;
|
||||
}
|
||||
@@ -3,16 +3,17 @@ import { firstValueFrom, of, timeout, TimeoutError } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-connector/models/set-key-connector-key.request";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
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";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
|
||||
import { TokenService } from "../../../auth/services/token.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
@@ -24,8 +25,13 @@ import { KeyGenerationService } from "../../crypto";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion";
|
||||
|
||||
import { USES_KEY_CONNECTOR, KeyConnectorService } from "./key-connector.service";
|
||||
import {
|
||||
USES_KEY_CONNECTOR,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
KeyConnectorService,
|
||||
} from "./key-connector.service";
|
||||
|
||||
describe("KeyConnectorService", () => {
|
||||
let keyConnectorService: KeyConnectorService;
|
||||
@@ -36,6 +42,7 @@ describe("KeyConnectorService", () => {
|
||||
const logService = mock<LogService>();
|
||||
const organizationService = mock<OrganizationService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const logoutCallback = jest.fn();
|
||||
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
@@ -51,6 +58,12 @@ describe("KeyConnectorService", () => {
|
||||
|
||||
const keyConnectorUrl = "https://key-connector-url.com";
|
||||
|
||||
const conversion: NewSsoUserKeyConnectorConversion = {
|
||||
kdfConfig: new PBKDF2KdfConfig(600_000),
|
||||
keyConnectorUrl,
|
||||
organizationId: mockOrgId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
@@ -67,7 +80,7 @@ describe("KeyConnectorService", () => {
|
||||
logService,
|
||||
organizationService,
|
||||
keyGenerationService,
|
||||
async () => {},
|
||||
logoutCallback,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
@@ -406,28 +419,21 @@ describe("KeyConnectorService", () => {
|
||||
});
|
||||
|
||||
describe("convertNewSsoUserToKeyConnector", () => {
|
||||
const tokenResponse = mock<IdentityTokenResponse>();
|
||||
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockMasterKey = getMockMasterKey();
|
||||
let mockMakeUserKeyResult: [UserKey, EncString];
|
||||
|
||||
beforeEach(() => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
|
||||
string,
|
||||
EncString,
|
||||
];
|
||||
let mockMakeUserKeyResult: [UserKey, EncString];
|
||||
|
||||
beforeEach(() => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const encString = new EncString("mockEncryptedString");
|
||||
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
|
||||
|
||||
tokenResponse.kdf = KdfType.PBKDF2_SHA256;
|
||||
tokenResponse.kdfIterations = 100000;
|
||||
tokenResponse.kdfMemory = 16;
|
||||
tokenResponse.kdfParallelism = 4;
|
||||
tokenResponse.keyConnectorUrl = keyConnectorUrl;
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue(passwordKey);
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
|
||||
@@ -435,18 +441,35 @@ describe("KeyConnectorService", () => {
|
||||
tokenService.getEmail.mockResolvedValue(mockEmail);
|
||||
});
|
||||
|
||||
it("sets up a new SSO user with key connector", async () => {
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(
|
||||
tokenResponse,
|
||||
mockOrgId,
|
||||
it.each([
|
||||
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
|
||||
[KdfType.Argon2id, 11, 65, 5],
|
||||
])(
|
||||
"sets up a new SSO user with key connector",
|
||||
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
|
||||
const expectedKdfConfig =
|
||||
kdfType == KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(kdfIterations)
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
|
||||
const conversion: NewSsoUserKeyConnectorConversion = {
|
||||
kdfConfig: expectedKdfConfig,
|
||||
keyConnectorUrl: keyConnectorUrl,
|
||||
organizationId: mockOrgId,
|
||||
};
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
expect.any(Object),
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
@@ -460,31 +483,43 @@ describe("KeyConnectorService", () => {
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse.keyConnectorUrl,
|
||||
expect.any(KeyConnectorUserKeyRequest),
|
||||
keyConnectorUrl,
|
||||
new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
|
||||
),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
|
||||
new SetKeyConnectorKeyRequest(
|
||||
mockMakeUserKeyResult[1].encryptedString!,
|
||||
expectedKdfConfig,
|
||||
mockOrgId,
|
||||
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that conversion data is cleared from conversionState
|
||||
expect(await firstValueFrom(conversionState.state$)).toBeNull();
|
||||
},
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles api error", async () => {
|
||||
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
|
||||
|
||||
try {
|
||||
await keyConnectorService.convertNewSsoUserToKeyConnector(
|
||||
tokenResponse,
|
||||
mockOrgId,
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(conversion);
|
||||
|
||||
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
|
||||
new Error("Key Connector error"),
|
||||
);
|
||||
} catch (error: any) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error?.message).toBe("Key Connector error");
|
||||
}
|
||||
|
||||
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
passwordKey.keyB64,
|
||||
mockEmail,
|
||||
expect.any(Object),
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
@@ -498,10 +533,90 @@ describe("KeyConnectorService", () => {
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse.keyConnectorUrl,
|
||||
expect.any(KeyConnectorUserKeyRequest),
|
||||
keyConnectorUrl,
|
||||
new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)),
|
||||
);
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
|
||||
|
||||
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
|
||||
});
|
||||
|
||||
it("should throw error when conversion data is null", async () => {
|
||||
const conversionState = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
conversionState.nextState(null);
|
||||
|
||||
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
|
||||
new Error("Key Connector conversion not found"),
|
||||
);
|
||||
|
||||
// Verify that no key generation or API calls were made
|
||||
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
|
||||
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNewSsoUserKeyConnectorConversionData", () => {
|
||||
it("should store Key Connector domain confirmation data in state", async () => {
|
||||
const state = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
state.nextState(null);
|
||||
|
||||
await keyConnectorService.setNewSsoUserKeyConnectorConversionData(conversion, mockUserId);
|
||||
|
||||
expect(await firstValueFrom(state.state$)).toEqual(conversion);
|
||||
});
|
||||
|
||||
it("should overwrite existing Key Connector domain confirmation data", async () => {
|
||||
const state = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
const existingConversion: NewSsoUserKeyConnectorConversion = {
|
||||
kdfConfig: new Argon2KdfConfig(3, 64, 4),
|
||||
keyConnectorUrl: "https://old.example.com",
|
||||
organizationId: "old-org-id" as OrganizationId,
|
||||
};
|
||||
state.nextState(existingConversion);
|
||||
|
||||
await keyConnectorService.setNewSsoUserKeyConnectorConversionData(conversion, mockUserId);
|
||||
|
||||
expect(await firstValueFrom(state.state$)).toEqual(conversion);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresDomainConfirmation$", () => {
|
||||
it("should return observable of key connector domain confirmation value when set", async () => {
|
||||
const state = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
state.nextState(conversion);
|
||||
|
||||
const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId);
|
||||
const data = await firstValueFrom(data$);
|
||||
|
||||
expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl });
|
||||
});
|
||||
|
||||
it("should return observable of null value when no data is set", async () => {
|
||||
const state = stateProvider.singleUser.getFake(
|
||||
mockUserId,
|
||||
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
|
||||
);
|
||||
state.nextState(null);
|
||||
|
||||
const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId);
|
||||
const data = await firstValueFrom(data$);
|
||||
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, filter, firstValueFrom, Observable, of, switchMap } from "rxjs";
|
||||
import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfig,
|
||||
PBKDF2KdfConfig,
|
||||
KeyService,
|
||||
KdfType,
|
||||
} from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } 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 { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { KeysRequest } from "../../../models/request/keys.request";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
@@ -32,6 +26,7 @@ import { MasterKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
|
||||
import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
|
||||
import { SetKeyConnectorKeyRequest } from "../models/set-key-connector-key.request";
|
||||
|
||||
@@ -45,6 +40,27 @@ export const USES_KEY_CONNECTOR = new UserKeyDefinition<boolean | null>(
|
||||
},
|
||||
);
|
||||
|
||||
export const NEW_SSO_USER_KEY_CONNECTOR_CONVERSION =
|
||||
new UserKeyDefinition<NewSsoUserKeyConnectorConversion | null>(
|
||||
KEY_CONNECTOR_DISK,
|
||||
"newSsoUserKeyConnectorConversion",
|
||||
{
|
||||
deserializer: (conversion) =>
|
||||
conversion == null
|
||||
? null
|
||||
: {
|
||||
kdfConfig:
|
||||
conversion.kdfConfig.kdfType === KdfType.PBKDF2_SHA256
|
||||
? PBKDF2KdfConfig.fromJSON(conversion.kdfConfig)
|
||||
: Argon2KdfConfig.fromJSON(conversion.kdfConfig),
|
||||
keyConnectorUrl: conversion.keyConnectorUrl,
|
||||
organizationId: conversion.organizationId,
|
||||
},
|
||||
clearOn: ["logout"],
|
||||
cleanupDelayMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
readonly convertAccountRequired$: Observable<boolean>;
|
||||
|
||||
@@ -128,25 +144,17 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
return this.findManagingOrganization(organizations);
|
||||
}
|
||||
|
||||
async convertNewSsoUserToKeyConnector(
|
||||
tokenResponse: IdentityTokenResponse,
|
||||
orgId: string,
|
||||
userId: UserId,
|
||||
) {
|
||||
// TODO: Remove after tokenResponse.keyConnectorUrl is deprecated in 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const {
|
||||
kdf,
|
||||
kdfIterations,
|
||||
kdfMemory,
|
||||
kdfParallelism,
|
||||
keyConnectorUrl: legacyKeyConnectorUrl,
|
||||
userDecryptionOptions,
|
||||
} = tokenResponse;
|
||||
async convertNewSsoUserToKeyConnector(userId: UserId) {
|
||||
const conversion = await firstValueFrom(
|
||||
this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId),
|
||||
);
|
||||
if (conversion == null) {
|
||||
throw new Error("Key Connector conversion not found");
|
||||
}
|
||||
|
||||
const { kdfConfig, keyConnectorUrl, organizationId } = conversion;
|
||||
|
||||
const password = await this.keyGenerationService.createKey(512);
|
||||
const kdfConfig: KdfConfig =
|
||||
kdf === KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(kdfIterations)
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
|
||||
const masterKey = await this.keyService.makeMasterKey(
|
||||
password.keyB64,
|
||||
@@ -165,8 +173,6 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
const [pubKey, privKey] = await this.keyService.makeKeyPair(userKey[0]);
|
||||
|
||||
try {
|
||||
const keyConnectorUrl =
|
||||
legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl;
|
||||
await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest);
|
||||
} catch (e) {
|
||||
this.handleKeyConnectorError(e);
|
||||
@@ -176,10 +182,29 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
const setPasswordRequest = new SetKeyConnectorKeyRequest(
|
||||
userKey[1].encryptedString,
|
||||
kdfConfig,
|
||||
orgId,
|
||||
organizationId,
|
||||
keys,
|
||||
);
|
||||
await this.apiService.postSetKeyConnectorKey(setPasswordRequest);
|
||||
|
||||
await this.stateProvider
|
||||
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
|
||||
.update(() => null);
|
||||
}
|
||||
|
||||
async setNewSsoUserKeyConnectorConversionData(
|
||||
conversion: NewSsoUserKeyConnectorConversion,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.stateProvider
|
||||
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
|
||||
.update(() => conversion);
|
||||
}
|
||||
|
||||
requiresDomainConfirmation$(userId: UserId): Observable<KeyConnectorDomainConfirmation | null> {
|
||||
return this.stateProvider
|
||||
.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId)
|
||||
.pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null)));
|
||||
}
|
||||
|
||||
private handleKeyConnectorError(e: any) {
|
||||
|
||||
@@ -8,3 +8,4 @@ export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust
|
||||
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
|
||||
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
|
||||
export { RemovePasswordComponent } from "./key-connector/remove-password.component";
|
||||
export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
@if (loading) {
|
||||
<div class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mb-4">
|
||||
<p class="tw-mb-1 tw-text-sm tw-font-semibold">{{ "keyConnectorDomain" | i18n }}:</p>
|
||||
<p class="tw-text-muted tw-break-all">{{ keyConnectorUrl }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<button bitButton type="button" buttonType="primary" [bitAction]="confirm" [block]="true">
|
||||
{{ "confirm" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" [bitAction]="cancel" [block]="true">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { ConfirmKeyConnectorDomainComponent } from "./confirm-key-connector-domain.component";
|
||||
|
||||
describe("ConfirmKeyConnectorDomainComponent", () => {
|
||||
let component: ConfirmKeyConnectorDomainComponent;
|
||||
|
||||
const userId = "test-user-id" as UserId;
|
||||
const confirmation: KeyConnectorDomainConfirmation = {
|
||||
keyConnectorUrl: "https://key-connector-url.com",
|
||||
};
|
||||
|
||||
const mockRouter = mock<Router>();
|
||||
const mockSyncService = mock<SyncService>();
|
||||
const mockKeyConnectorService = mock<KeyConnectorService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockMessagingService = mock<MessagingService>();
|
||||
let mockAccountService = mockAccountServiceWith(userId);
|
||||
const onBeforeNavigation = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockAccountService = mockAccountServiceWith(userId);
|
||||
|
||||
component = new ConfirmKeyConnectorDomainComponent(
|
||||
mockRouter,
|
||||
mockLogService,
|
||||
mockKeyConnectorService,
|
||||
mockMessagingService,
|
||||
mockSyncService,
|
||||
mockAccountService,
|
||||
);
|
||||
|
||||
jest.spyOn(component, "onBeforeNavigation").mockImplementation(onBeforeNavigation);
|
||||
|
||||
// Mock key connector service to return data from state
|
||||
mockKeyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(confirmation));
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should logout when no active account", async () => {
|
||||
mockAccountService.activeAccount$ = of(null);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
|
||||
expect(component.loading).toEqual(true);
|
||||
});
|
||||
|
||||
it("should logout when confirmation is null", async () => {
|
||||
mockKeyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null));
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
|
||||
expect(component.loading).toEqual(true);
|
||||
});
|
||||
|
||||
it("should set component properties correctly", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.userId).toEqual(userId);
|
||||
expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl);
|
||||
expect(component.loading).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
await component.confirm();
|
||||
|
||||
expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId);
|
||||
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
expect(mockMessagingService.send).toHaveBeenCalledWith("loggedIn");
|
||||
expect(onBeforeNavigation).toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
mockKeyConnectorService.convertNewSsoUserToKeyConnector.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(mockSyncService.fullSync.mock.invocationCallOrder[0]);
|
||||
expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockMessagingService.send.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onBeforeNavigation.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(onBeforeNavigation.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRouter.navigate.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("should logout", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
await component.cancel();
|
||||
|
||||
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
|
||||
expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BitActionDirective, ButtonModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@Component({
|
||||
selector: "confirm-key-connector-domain",
|
||||
templateUrl: "confirm-key-connector-domain.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective],
|
||||
})
|
||||
export class ConfirmKeyConnectorDomainComponent implements OnInit {
|
||||
loading = true;
|
||||
keyConnectorUrl!: string;
|
||||
userId!: UserId;
|
||||
|
||||
@Input() onBeforeNavigation: () => Promise<void> = async () => {};
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private logService: LogService,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
private messagingService: MessagingService,
|
||||
private syncService: SyncService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
} catch {
|
||||
this.logService.info("[confirm-key-connector-domain] no active account");
|
||||
this.messagingService.send("logout");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmation = await firstValueFrom(
|
||||
this.keyConnectorService.requiresDomainConfirmation$(this.userId),
|
||||
);
|
||||
if (confirmation == null) {
|
||||
this.logService.info("[confirm-key-connector-domain] missing required parameters");
|
||||
this.messagingService.send("logout");
|
||||
return;
|
||||
}
|
||||
|
||||
this.keyConnectorUrl = confirmation.keyConnectorUrl;
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
confirm = async () => {
|
||||
await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId);
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
this.messagingService.send("loggedIn");
|
||||
|
||||
await this.onBeforeNavigation();
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
};
|
||||
|
||||
cancel = async () => {
|
||||
this.messagingService.send("logout");
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user