diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 1e8eec759b1..11c8dd98872 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -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"; diff --git a/apps/web/src/app/auth/core/services/link-sso.service.spec.ts b/apps/web/src/app/auth/core/services/link-sso.service.spec.ts new file mode 100644 index 00000000000..70b52999875 --- /dev/null +++ b/apps/web/src/app/auth/core/services/link-sso.service.spec.ts @@ -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; + let mockApiService: MockProxy; + let mockCryptoFunctionService: MockProxy; + let mockEnvironmentService: MockProxy; + let mockPasswordGenerationService: MockProxy; + let mockPlatformUtilsService: MockProxy; + + const mockEnvironment$ = new BehaviorSubject({ + getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"), + }); + + beforeEach(() => { + // Create mock implementations + mockSsoLoginService = mock(); + mockApiService = mock(); + mockCryptoFunctionService = mock(); + mockEnvironmentService = mock(); + mockPasswordGenerationService = mock(); + mockPlatformUtilsService = mock(); + + // 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"); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/link-sso.service.ts b/apps/web/src/app/auth/core/services/link-sso.service.ts new file mode 100644 index 00000000000..3d51525add1 --- /dev/null +++ b/apps/web/src/app/auth/core/services/link-sso.service.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index cc9024490d6..9e6f88d18d6 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -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, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts deleted file mode 100644 index a1781889c49..00000000000 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html index 0b94b6e2be2..0fe243ed20a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html @@ -50,10 +50,10 @@ {{ "unlinkSso" | i18n }} - +