mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
refactor(auth): [PM-18148] replace app-link-sso directive with LinkSsoService
Removes the app-link-sso directive and adds a LinkSsoService which is used to link an organization with SSO. Resolves PM-18148
This commit is contained in:
@@ -4,3 +4,4 @@ export * from "./webauthn-login";
|
||||
export * from "./set-password-jit";
|
||||
export * from "./registration";
|
||||
export * from "./two-factor-auth";
|
||||
export * from "./link-sso.service";
|
||||
|
||||
154
apps/web/src/app/auth/core/services/link-sso.service.spec.ts
Normal file
154
apps/web/src/app/auth/core/services/link-sso.service.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
PasswordGenerationServiceAbstraction,
|
||||
PasswordGeneratorOptions,
|
||||
} from "@bitwarden/generator-legacy";
|
||||
|
||||
import { LinkSsoService } from "./link-sso.service";
|
||||
|
||||
describe("LinkSsoService", () => {
|
||||
let sut: LinkSsoService;
|
||||
|
||||
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let mockEnvironmentService: MockProxy<EnvironmentService>;
|
||||
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
const mockEnvironment$ = new BehaviorSubject<any>({
|
||||
getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock implementations
|
||||
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
mockApiService = mock<ApiService>();
|
||||
mockCryptoFunctionService = mock<CryptoFunctionService>();
|
||||
mockEnvironmentService = mock<EnvironmentService>();
|
||||
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
// Set up environment service to return our mock environment
|
||||
mockEnvironmentService.environment$ = mockEnvironment$;
|
||||
|
||||
// Set up API service mocks
|
||||
const mockResponse = { Token: "mockSsoToken" };
|
||||
mockApiService.preValidateSso.mockResolvedValue(new SsoPreValidateResponse(mockResponse));
|
||||
mockApiService.getSsoUserIdentifier.mockResolvedValue("mockUserIdentifier");
|
||||
|
||||
// Set up password generation service mock
|
||||
mockPasswordGenerationService.generatePassword.mockImplementation(
|
||||
async (options: PasswordGeneratorOptions) => {
|
||||
return "mockGeneratedPassword";
|
||||
},
|
||||
);
|
||||
|
||||
// Set up crypto function service mock
|
||||
mockCryptoFunctionService.hash.mockResolvedValue(new Uint8Array([1, 2, 3, 4]));
|
||||
|
||||
// Create the service under test with mock dependencies
|
||||
sut = new LinkSsoService(
|
||||
mockSsoLoginService,
|
||||
mockApiService,
|
||||
mockCryptoFunctionService,
|
||||
mockEnvironmentService,
|
||||
mockPasswordGenerationService,
|
||||
mockPlatformUtilsService,
|
||||
);
|
||||
|
||||
// Mock Utils.fromBufferToUrlB64
|
||||
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue("mockCodeChallenge");
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
origin: "https://bitwarden.com",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("linkSso", () => {
|
||||
it("throws an error when identifier is null", async () => {
|
||||
await expect(sut.linkSso(null as unknown as string)).rejects.toThrow(
|
||||
"SSO identifier is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an error when identifier is empty", async () => {
|
||||
await expect(sut.linkSso("")).rejects.toThrow("SSO identifier is required");
|
||||
});
|
||||
|
||||
it("calls preValidateSso with the provided identifier", async () => {
|
||||
await sut.linkSso("org123");
|
||||
|
||||
expect(mockApiService.preValidateSso).toHaveBeenCalledWith("org123");
|
||||
});
|
||||
|
||||
it("generates a password for code verifier", async () => {
|
||||
await sut.linkSso("org123");
|
||||
|
||||
expect(mockPasswordGenerationService.generatePassword).toHaveBeenCalledWith({
|
||||
type: "password",
|
||||
length: 64,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets the code verifier in the ssoLoginService", async () => {
|
||||
await sut.linkSso("org123");
|
||||
|
||||
expect(mockSsoLoginService.setCodeVerifier).toHaveBeenCalledWith("mockGeneratedPassword");
|
||||
});
|
||||
|
||||
it("generates a state and sets it in the ssoLoginService", async () => {
|
||||
await sut.linkSso("org123");
|
||||
|
||||
const expectedState =
|
||||
"mockGeneratedPassword_returnUri='/settings/organizations'_identifier=org123";
|
||||
expect(mockSsoLoginService.setSsoState).toHaveBeenCalledWith(expectedState);
|
||||
});
|
||||
|
||||
it("gets the SSO user identifier from the API", async () => {
|
||||
await sut.linkSso("org123");
|
||||
|
||||
expect(mockApiService.getSsoUserIdentifier).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("launches the authorize URL with the correct parameters", async () => {
|
||||
await sut.linkSso("org123");
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://identity.bitwarden.com/connect/authorize"),
|
||||
{ sameWindow: true },
|
||||
);
|
||||
|
||||
const launchUriArg = mockPlatformUtilsService.launchUri.mock.calls[0][0];
|
||||
expect(launchUriArg).toContain("client_id=web");
|
||||
expect(launchUriArg).toContain(
|
||||
"redirect_uri=https%3A%2F%2Fbitwarden.com%2Fsso-connector.html",
|
||||
);
|
||||
expect(launchUriArg).toContain("response_type=code");
|
||||
expect(launchUriArg).toContain("code_challenge=mockCodeChallenge");
|
||||
expect(launchUriArg).toContain("ssoToken=mockSsoToken");
|
||||
expect(launchUriArg).toContain("user_identifier=mockUserIdentifier");
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/web/src/app/auth/core/services/link-sso.service.ts
Normal file
91
apps/web/src/app/auth/core/services/link-sso.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
PasswordGenerationServiceAbstraction,
|
||||
PasswordGeneratorOptions,
|
||||
} from "@bitwarden/generator-legacy";
|
||||
|
||||
/**
|
||||
* Provides a service for linking SSO.
|
||||
*/
|
||||
export class LinkSsoService {
|
||||
constructor(
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private apiService: ApiService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private environmentService: EnvironmentService,
|
||||
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Links SSO to an organization.
|
||||
* Ported from the SsoComponent
|
||||
* @param identifier The identifier of the organization to link to.
|
||||
*/
|
||||
async linkSso(identifier: string) {
|
||||
if (identifier == null || identifier === "") {
|
||||
throw new Error("SSO identifier is required");
|
||||
}
|
||||
|
||||
const redirectUri = window.location.origin + "/sso-connector.html";
|
||||
const clientId = "web";
|
||||
const returnUri = "/settings/organizations";
|
||||
|
||||
const response = await this.apiService.preValidateSso(identifier);
|
||||
|
||||
const passwordOptions: PasswordGeneratorOptions = {
|
||||
type: "password",
|
||||
length: 64,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
special: false,
|
||||
};
|
||||
|
||||
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
|
||||
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
||||
await this.ssoLoginService.setCodeVerifier(codeVerifier);
|
||||
|
||||
let state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
state += `_returnUri='${returnUri}'`;
|
||||
state += `_identifier=${identifier}`;
|
||||
|
||||
// Save state
|
||||
await this.ssoLoginService.setSsoState(state);
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
let authorizeUrl =
|
||||
env.getIdentityUrl() +
|
||||
"/connect/authorize?" +
|
||||
"client_id=" +
|
||||
clientId +
|
||||
"&redirect_uri=" +
|
||||
encodeURIComponent(redirectUri) +
|
||||
"&" +
|
||||
"response_type=code&scope=api offline_access&" +
|
||||
"state=" +
|
||||
state +
|
||||
"&code_challenge=" +
|
||||
codeChallenge +
|
||||
"&" +
|
||||
"code_challenge_method=S256&response_mode=query&" +
|
||||
"domain_hint=" +
|
||||
encodeURIComponent(identifier) +
|
||||
"&ssoToken=" +
|
||||
encodeURIComponent(response.token);
|
||||
|
||||
const userIdentifier = await this.apiService.getSsoUserIdentifier();
|
||||
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
|
||||
|
||||
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
WebLoginDecryptionOptionsService,
|
||||
WebTwoFactorAuthComponentService,
|
||||
WebTwoFactorAuthDuoComponentService,
|
||||
LinkSsoService,
|
||||
} from "../auth";
|
||||
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
|
||||
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
|
||||
@@ -345,6 +346,18 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: WebSsoComponentService,
|
||||
deps: [I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LinkSsoService,
|
||||
useClass: LinkSsoService,
|
||||
deps: [
|
||||
SsoLoginServiceAbstraction,
|
||||
ApiService,
|
||||
CryptoFunctionService,
|
||||
EnvironmentService,
|
||||
PasswordGenerationServiceAbstraction,
|
||||
PlatformUtilsService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TwoFactorAuthDuoComponentService,
|
||||
useClass: WebTwoFactorAuthDuoComponentService,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
|
||||
|
||||
import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
@Directive({
|
||||
selector: "[app-link-sso]",
|
||||
})
|
||||
export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
|
||||
@Input() organization: Organization;
|
||||
returnUri = "/settings/organizations";
|
||||
redirectUri = window.location.origin + "/sso-connector.html";
|
||||
clientId = "web";
|
||||
|
||||
@HostListener("click", ["$event"])
|
||||
async onClick($event: MouseEvent) {
|
||||
$event.preventDefault();
|
||||
await this.submit(this.returnUri, true);
|
||||
}
|
||||
|
||||
async ngAfterContentInit() {
|
||||
this.identifier = this.organization.identifier;
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,10 @@
|
||||
{{ "unlinkSso" | i18n }}
|
||||
</button>
|
||||
<ng-template #linkSso>
|
||||
<a href="#" bitMenuItem app-link-sso [organization]="organization">
|
||||
<button type="button" bitMenuItem (click)="handleLinkSso(organization)">
|
||||
<i class="bwi bwi-fw bwi-link" aria-hidden="true"></i>
|
||||
{{ "linkSso" | i18n }}
|
||||
</a>
|
||||
</button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<button *ngIf="showLeaveOrgOption" type="button" bitMenuItem (click)="leave(organization)">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
combineLatest,
|
||||
@@ -37,6 +35,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "../../../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { EnrollMasterPasswordReset } from "../../../../admin-console/organizations/users/enroll-master-password-reset.component";
|
||||
import { LinkSsoService } from "../../../../auth/core/services";
|
||||
import { OptionsInput } from "../shared/components/vault-filter-section.component";
|
||||
import { OrganizationFilter } from "../shared/models/vault-filter.type";
|
||||
|
||||
@@ -45,12 +44,12 @@ import { OrganizationFilter } from "../shared/models/vault-filter.type";
|
||||
templateUrl: "organization-options.component.html",
|
||||
})
|
||||
export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
protected actionPromise: Promise<void | boolean>;
|
||||
protected actionPromise?: Promise<void | boolean>;
|
||||
protected resetPasswordPolicy?: Policy | undefined;
|
||||
protected loaded = false;
|
||||
protected hideMenu = false;
|
||||
protected showLeaveOrgOption = false;
|
||||
protected organization: OrganizationFilter;
|
||||
protected organization!: OrganizationFilter;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -72,6 +71,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
private configService: ConfigService,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private linkSsoService: LinkSsoService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -147,6 +147,23 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
return org?.useSso && org?.identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links SSO to an organization.
|
||||
* @param organization The organization to link SSO to.
|
||||
*/
|
||||
async handleLinkSso(organization: Organization) {
|
||||
try {
|
||||
await this.linkSsoService.linkSso(organization.identifier);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unlinkSso(org: Organization) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: org.name,
|
||||
@@ -165,7 +182,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
await this.actionPromise;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("unlinkedSso"),
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -189,7 +206,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
await this.actionPromise;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("leftOrganization"),
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -215,7 +232,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
// Remove reset password
|
||||
const request = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
request.masterPasswordHash = "ignored";
|
||||
request.resetPasswordKey = null;
|
||||
request.resetPasswordKey = "";
|
||||
this.actionPromise =
|
||||
this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
|
||||
this.organization.id,
|
||||
@@ -226,7 +243,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
await this.actionPromise;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
title: "",
|
||||
message: this.i18nService.t("withdrawPasswordResetSuccess"),
|
||||
});
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
import { VaultFilterSharedModule } from "../../individual-vault/vault-filter/shared/vault-filter-shared.module";
|
||||
|
||||
import { LinkSsoDirective } from "./components/link-sso.directive";
|
||||
import { OrganizationOptionsComponent } from "./components/organization-options.component";
|
||||
import { VaultFilterComponent } from "./components/vault-filter.component";
|
||||
import { VaultFilterService as VaultFilterServiceAbstraction } from "./services/abstractions/vault-filter.service";
|
||||
@@ -12,7 +11,7 @@ import { VaultFilterService } from "./services/vault-filter.service";
|
||||
|
||||
@NgModule({
|
||||
imports: [VaultFilterSharedModule, SearchModule],
|
||||
declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoDirective],
|
||||
declarations: [VaultFilterComponent, OrganizationOptionsComponent],
|
||||
exports: [VaultFilterComponent],
|
||||
providers: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user