1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-07 11:03:30 +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:
Alec Rippberger
2025-03-25 16:34:43 -05:00
committed by GitHub
parent 15b2b46b85
commit f3a2649752
8 changed files with 287 additions and 38 deletions

View File

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

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

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