diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts index 4d3a7763013..ee940952852 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts @@ -1,11 +1,18 @@ import { TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; import { DefaultLoginComponentService } from "@bitwarden/auth/angular"; +import { SsoUrlService } from "@bitwarden/auth/common"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { + Environment, + 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 } from "@bitwarden/generator-legacy"; import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; @@ -18,6 +25,7 @@ jest.mock("../../../platform/flags", () => ({ })); describe("ExtensionLoginComponentService", () => { + const baseUrl = "https://webvault.bitwarden.com"; let service: ExtensionLoginComponentService; let cryptoFunctionService: MockProxy; let environmentService: MockProxy; @@ -25,13 +33,20 @@ describe("ExtensionLoginComponentService", () => { let platformUtilsService: MockProxy; let ssoLoginService: MockProxy; let extensionAnonLayoutWrapperDataService: MockProxy; + let ssoUrlService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); environmentService = mock(); passwordGenerationService = mock(); platformUtilsService = mock(); ssoLoginService = mock(); + ssoUrlService = mock(); extensionAnonLayoutWrapperDataService = mock(); + environmentService.environment$ = new BehaviorSubject({ + getWebVaultUrl: () => baseUrl, + } as Environment); + platformUtilsService.getClientType.mockReturnValue(ClientType.Browser); + TestBed.configureTestingModule({ providers: [ { @@ -44,6 +59,7 @@ describe("ExtensionLoginComponentService", () => { platformUtilsService, ssoLoginService, extensionAnonLayoutWrapperDataService, + ssoUrlService, ), }, { provide: DefaultLoginComponentService, useExisting: ExtensionLoginComponentService }, @@ -52,6 +68,11 @@ describe("ExtensionLoginComponentService", () => { { provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, + { + provide: ExtensionAnonLayoutWrapperDataService, + useValue: extensionAnonLayoutWrapperDataService, + }, + { provide: SsoUrlService, useValue: ssoUrlService }, ], }); service = TestBed.inject(ExtensionLoginComponentService); @@ -61,6 +82,26 @@ describe("ExtensionLoginComponentService", () => { expect(service).toBeTruthy(); }); + describe("redirectToSso", () => { + it("launches SSO browser window", async () => { + const email = "test@bitwarden.com"; + const state = "testState"; + const expectedState = "testState:clientId=browser"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.redirectToSsoLogin(email); + + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(expectedState); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(platformUtilsService.launchUri).toHaveBeenCalled(); + }); + }); + describe("showBackButton", () => { it("sets showBackButton in extensionAnonLayoutWrapperDataService", () => { service.showBackButton(true); diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.ts index 3de5439cf2b..9ed727ade33 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.ts @@ -1,8 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular"; +import { SsoUrlService } from "@bitwarden/auth/common"; 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"; @@ -23,6 +25,7 @@ export class ExtensionLoginComponentService platformUtilsService: PlatformUtilsService, ssoLoginService: SsoLoginServiceAbstraction, private extensionAnonLayoutWrapperDataService: ExtensionAnonLayoutWrapperDataService, + private ssoUrlService: SsoUrlService, ) { super( cryptoFunctionService, @@ -31,7 +34,35 @@ export class ExtensionLoginComponentService platformUtilsService, ssoLoginService, ); - this.clientType = this.platformUtilsService.getClientType(); + } + + /** + * On the extension, redirecting to the SSO login page is done via a new browser window, opened + * to the SSO component on the web client. + * @param email the email of the user trying to log in, used to look up the org SSO identifier + * @param state the state that will be used to verify the SSO login, which needs to be passed to the IdP + * @param codeChallenge the challenge that will be verified after the code is returned from the IdP, which needs to be passed to the IdP + */ + protected override async redirectToSso( + email: string, + state: string, + codeChallenge: string, + ): Promise { + const env = await firstValueFrom(this.environmentService.environment$); + const webVaultUrl = env.getWebVaultUrl(); + + const redirectUri = webVaultUrl + "/sso-connector.html"; + + const webAppSsoUrl = this.ssoUrlService.buildSsoUrl( + webVaultUrl, + this.clientType, + redirectUri, + state, + codeChallenge, + email, + ); + + this.platformUtilsService.launchUri(webAppSsoUrl); } showBackButton(showBackButton: boolean): void { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 210a05d9947..eeceb1d4c47 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -27,7 +27,12 @@ import { LoginDecryptionOptionsService, SsoComponentService, } from "@bitwarden/auth/angular"; -import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common"; +import { + LockService, + LoginEmailService, + PinServiceAbstraction, + SsoUrlService, +} from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; @@ -550,6 +555,11 @@ const safeProviders: SafeProvider[] = [ useExisting: ExtensionAnonLayoutWrapperDataService, deps: [], }), + safeProvider({ + provide: SsoUrlService, + useClass: SsoUrlService, + deps: [], + }), safeProvider({ provide: LoginComponentService, useClass: ExtensionLoginComponentService, @@ -560,6 +570,7 @@ const safeProviders: SafeProvider[] = [ PlatformUtilsService, SsoLoginServiceAbstraction, ExtensionAnonLayoutWrapperDataService, + SsoUrlService, ], }), safeProvider({ diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 359ed08ca99..14cd93f370f 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -11,6 +11,7 @@ import { LoginStrategyServiceAbstraction, PasswordLoginCredentials, SsoLoginCredentials, + SsoUrlService, UserApiLoginCredentials, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -28,6 +29,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; +import { ClientType } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -71,6 +73,7 @@ export class LoginCommand { protected orgService: OrganizationService, protected logoutCallback: () => Promise, protected kdfConfigService: KdfConfigService, + protected ssoUrlService: SsoUrlService, ) {} async run(email: string, password: string, options: OptionValues) { @@ -738,17 +741,14 @@ export class LoginCommand { try { this.ssoRedirectUri = "http://localhost:" + port; callbackServer.listen(port, () => { - this.platformUtilsService.launchUri( - webUrl + - "/#/sso?clientId=" + - "cli" + - "&redirectUri=" + - encodeURIComponent(this.ssoRedirectUri) + - "&state=" + - state + - "&codeChallenge=" + - codeChallenge, + const webAppSsoUrl = this.ssoUrlService.buildSsoUrl( + webUrl, + ClientType.Cli, + this.ssoRedirectUri, + state, + codeChallenge, ); + this.platformUtilsService.launchUri(webAppSsoUrl); }); foundPort = true; break; diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 01844986834..a118985bf0d 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -170,6 +170,7 @@ export class Program extends BaseProgram { this.serviceContainer.organizationService, async () => await this.serviceContainer.logout(), this.serviceContainer.kdfConfigService, + this.serviceContainer.ssoUrlService, ); const response = await command.run(email, password, options); this.processResponse(response, true); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index f7dad133f94..98926f7ae65 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -19,6 +19,7 @@ import { PinService, PinServiceAbstraction, UserDecryptionOptionsService, + SsoUrlService, } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; @@ -274,6 +275,7 @@ export class ServiceContainer { sdkService: SdkService; sdkLoadService: SdkLoadService; cipherAuthorizationService: CipherAuthorizationService; + ssoUrlService: SsoUrlService; constructor() { let p = null; @@ -457,6 +459,7 @@ export class ServiceContainer { this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); + this.ssoUrlService = new SsoUrlService(); this.organizationService = new DefaultOrganizationService(this.stateProvider); this.policyService = new PolicyService(this.stateProvider, this.organizationService); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 61938bcaca6..f19f508d4ba 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -17,7 +17,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; -import { LogoutReason } from "@bitwarden/auth/common"; +import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -299,7 +299,7 @@ export class AppComponent implements OnInit, OnDestroy { const queryParams = { code: message.code, state: message.state, - redirectUri: message.redirectUri ?? "bitwarden://sso-callback", + redirectUri: message.redirectUri ?? DESKTOP_SSO_CALLBACK, }; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -812,7 +812,7 @@ export class AppComponent implements OnInit, OnDestroy { if (urlString.indexOf("bitwarden://import-callback-lp") === 0) { message = "importCallbackLastPass"; - } else if (urlString.indexOf("bitwarden://sso-callback") === 0) { + } else if (urlString.indexOf(DESKTOP_SSO_CALLBACK) === 0) { message = "ssoCallback"; } diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index c23d9d62ee8..7453fc453cf 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -32,6 +32,7 @@ import { LoginApprovalComponentServiceAbstraction, LoginEmailService, PinServiceAbstraction, + SsoUrlService, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -378,6 +379,11 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: SsoUrlService, + useClass: SsoUrlService, + deps: [], + }), safeProvider({ provide: LoginComponentService, useClass: DesktopLoginComponentService, @@ -389,6 +395,7 @@ const safeProviders: SafeProvider[] = [ SsoLoginServiceAbstraction, I18nServiceAbstraction, ToastService, + SsoUrlService, ], }), safeProvider({ diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts index 6edde35733f..914dc73bfd2 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts @@ -3,7 +3,9 @@ import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; import { DefaultLoginComponentService } from "@bitwarden/auth/angular"; +import { SsoUrlService } from "@bitwarden/auth/common"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { Environment, @@ -41,8 +43,7 @@ describe("DesktopLoginComponentService", () => { let ssoLoginService: MockProxy; let i18nService: MockProxy; let toastService: MockProxy; - - let superLaunchSsoBrowserWindowSpy: jest.SpyInstance; + let ssoUrlService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -60,6 +61,8 @@ describe("DesktopLoginComponentService", () => { ssoLoginService = mock(); i18nService = mock(); toastService = mock(); + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + ssoUrlService = mock(); TestBed.configureTestingModule({ providers: [ @@ -74,6 +77,7 @@ describe("DesktopLoginComponentService", () => { ssoLoginService, i18nService, toastService, + ssoUrlService, ), }, { provide: DefaultLoginComponentService, useExisting: DesktopLoginComponentService }, @@ -84,15 +88,11 @@ describe("DesktopLoginComponentService", () => { { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, { provide: I18nService, useValue: i18nService }, { provide: ToastService, useValue: toastService }, + { provide: SsoUrlService, useValue: ssoUrlService }, ], }); service = TestBed.inject(DesktopLoginComponentService); - - superLaunchSsoBrowserWindowSpy = jest.spyOn( - DefaultLoginComponentService.prototype, - "launchSsoBrowserWindow", - ); }); afterEach(() => { @@ -106,7 +106,7 @@ describe("DesktopLoginComponentService", () => { expect(service).toBeTruthy(); }); - describe("launchSsoBrowserWindow", () => { + describe("redirectToSso", () => { // Array of all permutations of isAppImage, isSnapStore, and isDev const permutations = [ [true, false, false], // Case 1: isAppImage true @@ -125,36 +125,27 @@ describe("DesktopLoginComponentService", () => { (global as any).ipc.platform.isSnapStore = isSnapStore; (global as any).ipc.platform.isDev = isDev; - const email = "user@example.com"; - const clientId = "desktop"; - const codeChallenge = "testCodeChallenge"; - const codeVerifier = "testCodeVerifier"; + const email = "test@bitwarden.com"; const state = "testState"; - const codeVerifierHash = new Uint8Array(64); + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; passwordGenerationService.generatePassword.mockResolvedValueOnce(state); passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); - cryptoFunctionService.hash.mockResolvedValueOnce(codeVerifierHash); jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); - await service.launchSsoBrowserWindow(email, clientId); + await service.redirectToSsoLogin(email); if (isAppImage || isSnapStore || isDev) { - expect(superLaunchSsoBrowserWindowSpy).not.toHaveBeenCalled(); - - // Assert that the standard logic is executed - expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email); - expect(passwordGenerationService.generatePassword).toHaveBeenCalledTimes(2); - expect(cryptoFunctionService.hash).toHaveBeenCalledWith(codeVerifier, "sha256"); - expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); - expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith( codeChallenge, state, + email, ); } else { - // If all values are false, expect the super method to be called - expect(superLaunchSsoBrowserWindowSpy).toHaveBeenCalledWith(email, clientId); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(platformUtilsService.launchUri).toHaveBeenCalled(); } }); }); diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.ts b/apps/desktop/src/auth/login/desktop-login-component.service.ts index dbf689e801c..89407b0d4bc 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -1,14 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular"; +import { DESKTOP_SSO_CALLBACK, SsoUrlService } from "@bitwarden/auth/common"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; @@ -25,6 +26,7 @@ export class DesktopLoginComponentService protected ssoLoginService: SsoLoginServiceAbstraction, protected i18nService: I18nService, protected toastService: ToastService, + protected ssoUrlService: SsoUrlService, ) { super( cryptoFunctionService, @@ -33,38 +35,50 @@ export class DesktopLoginComponentService platformUtilsService, ssoLoginService, ); - this.clientType = this.platformUtilsService.getClientType(); } - override async launchSsoBrowserWindow(email: string, clientId: "desktop"): Promise { - if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) { - return super.launchSsoBrowserWindow(email, clientId); - } - - // Save email for SSO - await this.ssoLoginService.setSsoEmail(email); + /** + * On the desktop, redirecting to the SSO login page is done via a new browser window, opened + * to the SSO component on the web client. + * @param email the email of the user trying to log in, used to look up the org SSO identifier + * @param state the state that will be used to verify the SSO login, which needs to be passed to the IdP + * @param codeChallenge the challenge that will be verified after the code is returned from the IdP, which needs to be passed to the IdP + */ + protected override async redirectToSso( + email: string, + state: string, + codeChallenge: string, + ): Promise { + // For platforms that cannot support a protocol-based (e.g. bitwarden://) callback, we use a localhost callback + // Otherwise, we launch the SSO component in a browser window and wait for the callback + if (ipc.platform.isAppImage || ipc.platform.isSnapStore || ipc.platform.isDev) { + await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge); + } else { + const env = await firstValueFrom(this.environmentService.environment$); + const webVaultUrl = env.getWebVaultUrl(); - // Generate SSO params - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; + const redirectUri = DESKTOP_SSO_CALLBACK; - const state = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + const ssoWebAppUrl = this.ssoUrlService.buildSsoUrl( + webVaultUrl, + this.clientType, + redirectUri, + state, + codeChallenge, + email, + ); - // Save SSO params - await this.ssoLoginService.setSsoState(state); - await this.ssoLoginService.setCodeVerifier(codeVerifier); + this.platformUtilsService.launchUri(ssoWebAppUrl); + } + } + private async initiateSsoThroughLocalhostCallback( + email: string, + state: string, + challenge: string, + ): Promise { try { - await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state); + await ipc.platform.localhostCallbackService.openSsoPrompt(challenge, state, email); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { diff --git a/apps/desktop/src/auth/login/login-v1.component.ts b/apps/desktop/src/auth/login/login-v1.component.ts index f78bee7323d..5d1a1d818d5 100644 --- a/apps/desktop/src/auth/login/login-v1.component.ts +++ b/apps/desktop/src/auth/login/login-v1.component.ts @@ -220,9 +220,10 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) { return super.launchSsoBrowser(clientId, ssoRedirectUri); } + const email = this.formGroup.controls.email.value; // Save off email for SSO - await this.ssoLoginService.setSsoEmail(this.formGroup.controls.email.value); + await this.ssoLoginService.setSsoEmail(email); // Generate necessary sso params const passwordOptions: any = { @@ -243,7 +244,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier); try { - await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state); + await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state, email); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 72e884d286f..c6e074ead91 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,6 +5,7 @@ import * as path from "path"; import { app } from "electron"; import { Subject, firstValueFrom } from "rxjs"; +import { SsoUrlService } from "@bitwarden/auth/common"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -66,6 +67,7 @@ export class Main { desktopSettingsService: DesktopSettingsService; mainCryptoFunctionService: MainCryptoFunctionService; migrationRunner: MigrationRunner; + ssoUrlService: SsoUrlService; windowMain: WindowMain; messagingMain: MessagingMain; @@ -261,7 +263,13 @@ export class Main { this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService); new EphemeralValueStorageService(); - new SSOLocalhostCallbackService(this.environmentService, this.messagingService); + + this.ssoUrlService = new SsoUrlService(); + new SSOLocalhostCallbackService( + this.environmentService, + this.messagingService, + this.ssoUrlService, + ); this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain); void this.nativeAutofillMain.init(); diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 24360ecbbb5..05dcd484def 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -127,8 +127,8 @@ const ephemeralStore = { }; const localhostCallbackService = { - openSsoPrompt: (codeChallenge: string, state: string): Promise => { - return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state }); + openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise => { + return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email }); }, }; diff --git a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts index 2baba6275bc..14baee51b90 100644 --- a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -5,6 +5,8 @@ import * as http from "http"; import { ipcMain } from "electron"; import { firstValueFrom } from "rxjs"; +import { SsoUrlService } from "@bitwarden/auth/common"; +import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { MessageSender } from "@bitwarden/common/platform/messaging"; @@ -18,9 +20,10 @@ export class SSOLocalhostCallbackService { constructor( private environmentService: EnvironmentService, private messagingService: MessageSender, + private ssoUrlService: SsoUrlService, ) { - ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state }) => { - const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state); + ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { + const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email); this.messagingService.send("ssoCallback", { code: ssoCode, state: recvState, @@ -32,6 +35,7 @@ export class SSOLocalhostCallbackService { private async openSsoPrompt( codeChallenge: string, state: string, + email: string, ): Promise<{ ssoCode: string; recvState: string }> { const env = await firstValueFrom(this.environmentService.environment$); @@ -78,18 +82,17 @@ export class SSOLocalhostCallbackService { for (let port = 8065; port <= 8070; port++) { try { this.ssoRedirectUri = "http://localhost:" + port; + const ssoUrl = this.ssoUrlService.buildSsoUrl( + webUrl, + ClientType.Desktop, + this.ssoRedirectUri, + state, + codeChallenge, + email, + ); callbackServer.listen(port, () => { this.messagingService.send("launchUri", { - url: - webUrl + - "/#/sso?clientId=" + - "desktop" + - "&redirectUri=" + - encodeURIComponent(this.ssoRedirectUri) + - "&state=" + - state + - "&codeChallenge=" + - codeChallenge, + url: ssoUrl, }); }); foundPort = true; @@ -112,15 +115,6 @@ export class SSOLocalhostCallbackService { }); } - private getOrgIdentifierFromState(state: string): string { - if (state === null || state === undefined) { - return null; - } - - const stateSplit = state.split("_identifier="); - return stateSplit.length > 1 ? stateSplit[1] : null; - } - private checkState(state: string, checkState: string): boolean { if (state === null || state === undefined) { return false; diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index aa0c204750f..29f2f237ec1 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { @@ -37,6 +38,7 @@ export class WebLoginComponentService passwordGenerationService: PasswordGenerationServiceAbstraction, platformUtilsService: PlatformUtilsService, ssoLoginService: SsoLoginServiceAbstraction, + private router: Router, ) { super( cryptoFunctionService, @@ -45,7 +47,20 @@ export class WebLoginComponentService platformUtilsService, ssoLoginService, ); - this.clientType = this.platformUtilsService.getClientType(); + } + + /** + * For the web client, redirecting to the SSO component is done via the router. + * We do not need to provide email, state, or code challenge since those are set in state + * or generated on the SSO component. + */ + protected override async redirectToSso( + email: string, + state: string, + codeChallenge: string, + ): Promise { + await this.router.navigate(["/sso"]); + return; } async getOrgPoliciesFromOrgInvite(): Promise { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index be42a9ba34e..305928bfc24 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -295,6 +295,7 @@ const safeProviders: SafeProvider[] = [ PasswordGenerationServiceAbstraction, PlatformUtilsService, SsoLoginServiceAbstraction, + Router, ], }), safeProvider({ diff --git a/apps/web/src/connectors/sso.ts b/apps/web/src/connectors/sso.ts index b48c2b49d72..4fdab71be3b 100644 --- a/apps/web/src/connectors/sso.ts +++ b/apps/web/src/connectors/sso.ts @@ -16,17 +16,21 @@ window.addEventListener("load", () => { } else if (state != null && state.includes(":clientId=browser")) { initiateBrowserSso(code, state, false); } else { - window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state; - // Match any characters between "_returnUri='" and the next "'" - const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')"); - if (returnUri) { - window.location.href = window.location.origin + `/#${returnUri}`; - } else { - window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state; - } + initiateWebAppSso(code, state); } }); +function initiateWebAppSso(code: string, state: string) { + // If we've initiated SSO from somewhere other than the SSO component on the web app, the SSO component will add + // a _returnUri to the state variable. Here we're extracting that URI and sending the user there instead of to the SSO component. + const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')"); + if (returnUri) { + window.location.href = window.location.origin + `/#${returnUri}`; + } else { + window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state; + } +} + function initiateBrowserSso(code: string, state: string, lastpass: boolean) { window.postMessage({ command: "authResult", code: code, state: state, lastpass: lastpass }, "*"); const handOffMessage = ("; " + document.cookie) diff --git a/libs/angular/src/auth/components/login-v1.component.ts b/libs/angular/src/auth/components/login-v1.component.ts index 3416901da97..26903716edf 100644 --- a/libs/angular/src/auth/components/login-v1.component.ts +++ b/libs/angular/src/auth/components/login-v1.component.ts @@ -339,6 +339,9 @@ export class LoginComponentV1 extends CaptchaProtectedComponent implements OnIni } protected async saveEmailSettings() { + // Save off email for SSO + await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); + this.loginEmailService.setLoginEmail(this.formGroup.value.email); this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail); await this.loginEmailService.saveEmailSettings(); diff --git a/libs/auth/src/angular/login/default-login-component.service.spec.ts b/libs/auth/src/angular/login/default-login-component.service.spec.ts index 446ab44b4ee..28e0e3db479 100644 --- a/libs/auth/src/angular/login/default-login-component.service.spec.ts +++ b/libs/auth/src/angular/login/default-login-component.service.spec.ts @@ -68,50 +68,21 @@ describe("DefaultLoginComponentService", () => { }); }); - describe("launchSsoBrowserWindow", () => { - const email = "test@bitwarden.com"; - let state = "testState"; - const codeVerifier = "testCodeVerifier"; - const codeChallenge = "testCodeChallenge"; - const baseUrl = "https://webvault.bitwarden.com/#/sso"; - - beforeEach(() => { - state = "testState"; + describe("redirectToSsoLogin", () => { + it("sets the pre-SSO state", async () => { + const email = "test@bitwarden.com"; + const state = "testState"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; passwordGenerationService.generatePassword.mockResolvedValueOnce(state); passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); - }); - - it.each([ - { - clientType: ClientType.Browser, - clientId: "browser", - expectedRedirectUri: "https://webvault.bitwarden.com/sso-connector.html", - }, - { - clientType: ClientType.Desktop, - clientId: "desktop", - expectedRedirectUri: "bitwarden://sso-callback", - }, - ])( - "launches SSO browser window with correct URL for $clientId client", - async ({ clientType, clientId, expectedRedirectUri }) => { - service["clientType"] = clientType; - - await service.launchSsoBrowserWindow(email, clientId as "browser" | "desktop"); - if (clientType === ClientType.Browser) { - state += ":clientId=browser"; - } - - const expectedUrl = `${baseUrl}?clientId=${clientId}&redirectUri=${encodeURIComponent(expectedRedirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`; - - expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email); - expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); - expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); - expect(platformUtilsService.launchUri).toHaveBeenCalledWith(expectedUrl); - }, - ); + await service.redirectToSsoLogin(email); + expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + }); }); }); diff --git a/libs/auth/src/angular/login/default-login-component.service.ts b/libs/auth/src/angular/login/default-login-component.service.ts index 41b761ce1d9..c70be33f9d4 100644 --- a/libs/auth/src/angular/login/default-login-component.service.ts +++ b/libs/auth/src/angular/login/default-login-component.service.ts @@ -1,7 +1,5 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; - import { LoginComponentService } from "@bitwarden/auth/angular"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; @@ -21,19 +19,55 @@ export class DefaultLoginComponentService implements LoginComponentService { protected passwordGenerationService: PasswordGenerationServiceAbstraction, protected platformUtilsService: PlatformUtilsService, protected ssoLoginService: SsoLoginServiceAbstraction, - ) {} + ) { + this.clientType = this.platformUtilsService.getClientType(); + } isLoginWithPasskeySupported(): boolean { return this.clientType === ClientType.Web; } - async launchSsoBrowserWindow( - email: string, - clientId: "browser" | "desktop", - ): Promise { - // Save email for SSO + /** + * Redirects the user to the SSO login page, either via route or in a new browser window. + * @param email The email address of the user attempting to log in + */ + async redirectToSsoLogin(email: string): Promise { + // Set the state that we'll need to verify the SSO login when we get the code back + const [state, codeChallenge] = await this.setSsoPreLoginState(); + + // Set the email address in state. This is used in 2 places: + // 1. On the web client, on the SSO component we need the email address to look up + // the org SSO identifier. The email address is passed via query param for the other clients. + // 2. On all clients, after authentication on the originating client the SSO component + // will need to look up 2FA Remember token by email. await this.ssoLoginService.setSsoEmail(email); + // Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service. + await this.redirectToSso(email, state, codeChallenge); + } + + /** + * No-op implementation of redirectToSso + */ + protected async redirectToSso( + email: string, + state: string, + codeChallenge: string, + ): Promise { + return; + } + + /** + * No-op implementation of showBackButton + */ + showBackButton(showBackButton: boolean): void { + return; + } + + /** + * Sets the state required for verifying SSO login after completion + */ + private async setSsoPreLoginState(): Promise<[string, string]> { // Generate SSO params const passwordOptions: any = { type: "password", @@ -46,8 +80,8 @@ export class DefaultLoginComponentService implements LoginComponentService { let state = await this.passwordGenerationService.generatePassword(passwordOptions); - if (clientId === "browser") { - // Need to persist the clientId in the state for the extension + // For the browser extension, we persist the clientId on state so that it will be included after SSO in the callback + if (this.clientType === ClientType.Browser) { state += ":clientId=browser"; } @@ -59,35 +93,6 @@ export class DefaultLoginComponentService implements LoginComponentService { await this.ssoLoginService.setSsoState(state); await this.ssoLoginService.setCodeVerifier(codeVerifier); - // Build URL - const env = await firstValueFrom(this.environmentService.environment$); - const webVaultUrl = env.getWebVaultUrl(); - - const redirectUri = - clientId === "browser" - ? webVaultUrl + "/sso-connector.html" // Browser - : "bitwarden://sso-callback"; // Desktop - - // Launch browser window with URL - this.platformUtilsService.launchUri( - webVaultUrl + - "/#/sso?clientId=" + - clientId + - "&redirectUri=" + - encodeURIComponent(redirectUri) + - "&state=" + - state + - "&codeChallenge=" + - codeChallenge + - "&email=" + - encodeURIComponent(email), - ); - } - - /** - * No-op implementation of showBackButton - */ - showBackButton(showBackButton: boolean): void { - return; + return [state, codeChallenge]; } } diff --git a/libs/auth/src/angular/login/login-component.service.ts b/libs/auth/src/angular/login/login-component.service.ts index 1147c5d8644..796a01c71c3 100644 --- a/libs/auth/src/angular/login/login-component.service.ts +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -31,10 +31,9 @@ export abstract class LoginComponentService { isLoginWithPasskeySupported: () => boolean; /** - * Launches the SSO flow in a new browser window. - * - Used by: Browser, Desktop + * Redirects the user to the SSO login page, either via route or in a new browser window. */ - launchSsoBrowserWindow: (email: string, clientId: "browser" | "desktop") => Promise; + redirectToSsoLogin: (email: string) => Promise; /** * Shows the back button. diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index f31e02fdb1f..47109b00bbb 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -318,15 +318,6 @@ export class LoginComponent implements OnInit, OnDestroy { } } - protected async launchSsoBrowserWindow(clientId: "browser" | "desktop"): Promise { - const email = this.emailFormControl.value; - if (!email) { - this.logService.error("Email is required for SSO login"); - return; - } - await this.loginComponentService.launchSsoBrowserWindow(email, clientId); - } - /** * Checks if the master password meets the enforced policy requirements * and if the user is required to change their password. @@ -636,26 +627,25 @@ export class LoginComponent implements OnInit, OnDestroy { /** * Handle the SSO button click. - * @param event - The event object. */ async handleSsoClick() { - const isEmailValid = await this.validateEmail(); + // Make sure the email is not empty, for type safety + const email = this.formGroup.value.email; + if (!email) { + this.logService.error("Email is required for SSO"); + return; + } + // Make sure the email is valid + const isEmailValid = await this.validateEmail(); if (!isEmailValid) { return; } + // Save the email configuration for the login component await this.saveEmailSettings(); - if (this.clientType === ClientType.Web) { - await this.router.navigate(["/sso"], { - queryParams: { email: this.formGroup.value.email }, - }); - return; - } - - await this.launchSsoBrowserWindow( - this.clientType === ClientType.Browser ? "browser" : "desktop", - ); + // Send the user to SSO, either through routing or through redirecting to the web app + await this.loginComponentService.redirectToSsoLogin(email); } } diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index b3f0d7d6a66..9f81ab2a748 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -89,6 +89,7 @@ export class SsoComponent implements OnInit { protected state: string | undefined; protected codeChallenge: string | undefined; protected clientId: SsoClientType | undefined; + protected email: string | null | undefined; formPromise: Promise | undefined; initiateSsoFormPromise: Promise | undefined; @@ -129,38 +130,58 @@ export class SsoComponent implements OnInit { } } + /** + * Like several components in our app (e.g. our invite acceptance components), the SSO component is engaged both + * before and after the user authenticates. + * Flow 1: Initialize SSO state and redirect to IdP + * - We can get here several ways: + * - The user is on the web client and is routed here + * - The user is on a different client and is redirected by opening a new browser window, passing query params + * - A customer integration has been set up to direct users to the `/sso` route to initiate SSO with an identifier + * Flow 2: Handle callback from IdP and verify the state that was set pre-authentication + */ async ngOnInit() { const qParams: QueryParams = await firstValueFrom(this.route.queryParams); - // This if statement will pass on the second portion of the SSO flow + // SSO on web uses a service to provide the email via state that's set on login, + // but because we have clients that delegate SSO to web we have to accept the email in the query params as well. + // We also can't require the email, because it isn't provided in the CLI SSO flow. + this.email = qParams.email ?? (await this.ssoLoginService.getSsoEmail()); + + // Detect if we are on the second portion of the SSO flow, // where the user has already authenticated with the identity provider - if (this.hasCodeOrStateParams(qParams)) { - await this.handleCodeAndStateParams(qParams); + if (this.userCompletedSsoAuthentication(qParams)) { + await this.handleTokenRequestForAuthenticatedUser(qParams); return; } - // This if statement will pass on the first portion of the SSO flow - if (this.hasRequiredSsoParams(qParams)) { - this.setRequiredSsoVariables(qParams); + // Detect if we are on the first portion of the SSO flow + // and have been sent here from another client with the info in query params + if (this.hasParametersFromOtherClientRedirect(qParams)) { + this.initializeFromRedirectFromOtherClient(qParams); return; } + // Detect if we have landed here but only have an SSO identifier in the URL. + // This is used by integrations that want to "short-circuit" the login to send users + // directly to their IdP to simulate IdP-initiated SSO, so we submit automatically. if (qParams.identifier != null) { - // SSO Org Identifier in query params takes precedence over claimed domains this.identifierFormControl.setValue(qParams.identifier); this.loggingIn = true; await this.submit(); return; } - await this.initializeIdentifierFromEmailOrStorage(qParams); + // If we're routed here with no additional parameters, we'll try to determine the + // identifier using claimed domain or local state saved from their last attempt. + await this.initializeIdentifierFromEmailOrStorage(); } /** * Sets the required SSO variables from the query params * @param qParams - The query params */ - private setRequiredSsoVariables(qParams: QueryParams): void { + private initializeFromRedirectFromOtherClient(qParams: QueryParams): void { this.redirectUri = qParams.redirectUri ?? ""; this.state = qParams.state ?? ""; this.codeChallenge = qParams.codeChallenge ?? ""; @@ -182,11 +203,16 @@ export class SsoComponent implements OnInit { } /** - * Checks if the query params have the required SSO params + * Checks if the query params have the required SSO params to initiate SSO + * * The query params presented here are: + * - clientId: The client type (e.g. web, browser, desktop) + * - redirectUri: The URI to redirect to after authentication + * - state: The state to verify on the client after authentication + * - codeChallenge: The PKCE code challenge that is sent up when authenticating with the IdP * @param qParams - The query params * @returns True if the query params have the required SSO params, false otherwise */ - private hasRequiredSsoParams(qParams: QueryParams): boolean { + private hasParametersFromOtherClientRedirect(qParams: QueryParams): boolean { return ( qParams.clientId != null && qParams.redirectUri != null && @@ -196,12 +222,18 @@ export class SsoComponent implements OnInit { } /** - * Handles the code and state params + * Handles the case in which the user has completed SSO authentication, has a code + * and has been redirected back to the SSO component to exchange the code for a token. + * This will be on the client originating the SSO request, not always the web client, as that + * is where the state and verifier are stored. * @param qParams - The query params */ - private async handleCodeAndStateParams(qParams: QueryParams): Promise { + private async handleTokenRequestForAuthenticatedUser(qParams: QueryParams): Promise { + // We set these in state prior to starting SSO, so we can retrieve them here const codeVerifier = await this.ssoLoginService.getCodeVerifier(); - const state = await this.ssoLoginService.getSsoState(); + const stateFromPrelogin = await this.ssoLoginService.getSsoState(); + + // Reset the code verifier and state so we don't accidentally use them again await this.ssoLoginService.setCodeVerifier(""); await this.ssoLoginService.setSsoState(""); @@ -209,11 +241,13 @@ export class SsoComponent implements OnInit { this.redirectUri = qParams.redirectUri; } + // Verify that the state matches the state we set prior to starting SSO. + // If it does, we can proceed with exchanging the code for a token. if ( qParams.code != null && codeVerifier != null && - state != null && - this.checkState(state, qParams.state ?? "") + stateFromPrelogin != null && + this.verifyStateMatches(stateFromPrelogin, qParams.state ?? "") ) { const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state ?? ""); await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier); @@ -221,11 +255,12 @@ export class SsoComponent implements OnInit { } /** - * Checks if the query params have a code or state + * Checks if the query params have a code and state, indicating that we've completed SSO authentication + * and have been redirected back to the SSO component on the originating client to complete login. * @param qParams - The query params - * @returns True if the query params have a code or state, false otherwise + * @returns True if the query params have a code and state, false otherwise */ - private hasCodeOrStateParams(qParams: QueryParams): boolean { + private userCompletedSsoAuthentication(qParams: QueryParams): boolean { return qParams.code != null && qParams.state != null; } @@ -265,6 +300,11 @@ export class SsoComponent implements OnInit { } }; + /** + * Redirects the user to `/connect/authorize` on IdentityServer to begin SSO. + * @param returnUri - The URI to redirect to after authentication (used to link user to SSO) + * @param includeUserIdentifier - Whether to include the user identifier in the request (used to link user to SSO) + */ private async submitSso(returnUri?: string, includeUserIdentifier?: boolean) { if (this.identifier == null || this.identifier === "") { this.toastService.showToast({ @@ -307,6 +347,9 @@ export class SsoComponent implements OnInit { special: false, }; + // Initialize the challenge and state if they aren't passed in. If we're performing SSO initiated on a + // different client, they'll be passed in, as they will need to be verified on that client and not the web. + // If they're not passed in, then we need to set them here on the web client to be verified here after SSO. if (codeChallenge == null) { const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); @@ -316,15 +359,20 @@ export class SsoComponent implements OnInit { if (state == null) { state = await this.passwordGenerationService.generatePassword(passwordOptions); - if (returnUri) { - state += `_returnUri='${returnUri}'`; - } + } + + // If we have a returnUri, add it to the state parameter. This will be used after SSO + // is complete, on the sso-connector, in order to route the user somewhere other than the SSO component. + if (returnUri) { + state += `_returnUri='${returnUri}'`; } // Add Organization Identifier to state state += `_identifier=${this.identifier}`; - // Save state (regardless of new or existing) + // Save the pre-SSO state. + // We need to do this here as even if it was generated on the intiating client (e.g. browser, desktop), + // we need it on the web client to verify after the user authenticates with the identity provider and is redirected back. await this.ssoLoginService.setSsoState(state); const env = await firstValueFrom(this.environmentService.environment$); @@ -349,6 +397,8 @@ export class SsoComponent implements OnInit { "&ssoToken=" + encodeURIComponent(token ?? ""); + // If we're linking a user to SSO, we need to provide a user identifier that will be passed + // on to the SSO provider so that after SSO we can link the user to the SSO identity. if (includeUserIdentifier) { const userIdentifier = await this.apiService.getSsoUserIdentifier(); authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`; @@ -357,17 +407,23 @@ export class SsoComponent implements OnInit { return authorizeUrl; } + /** + * We are using the Auth Code + PKCE flow. + * We have received the code from IdentityServer, which we will now present with the code verifier to get a token. + */ private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise { this.loggingIn = true; try { - const email = await this.ssoLoginService.getSsoEmail(); + // The code verifier is used to ensure that the client presenting the code is the same one that initiated the authentication request. + // The redirect URI is also supplied on the request to the token endpoint, so the server can ensure it matches the original request + // for the code and prevent authorization code injection attacks. const redirectUri = this.redirectUri ?? ""; const credentials = new SsoLoginCredentials( code, codeVerifier, redirectUri, orgSsoIdentifier, - email ?? undefined, + this.email ?? undefined, ); this.formPromise = this.loginStrategyService.logIn(credentials); const authResult = await this.formPromise; @@ -524,16 +580,22 @@ export class SsoComponent implements OnInit { return stateSplit.length > 1 ? stateSplit[1] : ""; } - private checkState(state: string, checkState: string): boolean { - if (state === null || state === undefined) { + /** + * Checks if the state matches the checkState + * @param originalStateValue - The state to check + * @param stateValueToCheck - The state to check against + * @returns True if the state matches the checkState, false otherwise + */ + private verifyStateMatches(originalStateValue: string, stateValueToCheck: string): boolean { + if (originalStateValue === null || originalStateValue === undefined) { return false; } - if (checkState === null || checkState === undefined) { + if (stateValueToCheck === null || stateValueToCheck === undefined) { return false; } - const stateSplit = state.split("_identifier="); - const checkStateSplit = checkState.split("_identifier="); + const stateSplit = originalStateValue.split("_identifier="); + const checkStateSplit = stateValueToCheck.split("_identifier="); return stateSplit[0] === checkStateSplit[0]; } @@ -541,17 +603,16 @@ export class SsoComponent implements OnInit { * Attempts to initialize the SSO identifier from email or storage. * Note: this flow is written for web but both browser and desktop * redirect here on SSO button click. - * @param qParams - The query params */ - private async initializeIdentifierFromEmailOrStorage(qParams: QueryParams): Promise { - // Check if email matches any claimed domains - if (qParams.email) { + private async initializeIdentifierFromEmailOrStorage(): Promise { + if (this.email) { // show loading spinner this.loggingIn = true; try { + // Check if email matches any claimed domains if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) { const response: ListResponse = - await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email); + await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(this.email); if (response.data.length > 0) { this.identifierFormControl.setValue(response.data[0].organizationIdentifier); @@ -560,7 +621,7 @@ export class SsoComponent implements OnInit { } } else { const response: OrganizationDomainSsoDetailsResponse = - await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email); + await this.orgDomainApiService.getClaimedOrgDomainByEmail(this.email); if (response?.ssoAvailable && response?.verifiedDate) { this.identifierFormControl.setValue(response.organizationIdentifier); @@ -575,7 +636,8 @@ export class SsoComponent implements OnInit { this.loggingIn = false; } - // Fallback to state svc if domain is unclaimed + // If we don't find a claimed domain, check to see if we stored an identifier in state + // from their last attrempt to login via SSO. If so, we'll populate the field, but not submit. const storedIdentifier = await this.ssoLoginService.getOrganizationSsoIdentifier(); if (storedIdentifier != null) { this.identifierFormControl.setValue(storedIdentifier); diff --git a/libs/auth/src/common/services/index.ts b/libs/auth/src/common/services/index.ts index 44f6afa5d23..73d31799b7e 100644 --- a/libs/auth/src/common/services/index.ts +++ b/libs/auth/src/common/services/index.ts @@ -6,3 +6,4 @@ export * from "./auth-request/auth-request.service"; export * from "./auth-request/auth-request-api.service"; export * from "./accounts/lock.service"; export * from "./login-success-handler/default-login-success-handler.service"; +export * from "./sso-redirect/sso-url.service"; diff --git a/libs/auth/src/common/services/sso-redirect/sso-url.service.spec.ts b/libs/auth/src/common/services/sso-redirect/sso-url.service.spec.ts new file mode 100644 index 00000000000..074c3a1e0b1 --- /dev/null +++ b/libs/auth/src/common/services/sso-redirect/sso-url.service.spec.ts @@ -0,0 +1,95 @@ +import { ClientType } from "@bitwarden/common/enums"; + +import { DESKTOP_SSO_CALLBACK, SsoUrlService } from "./sso-url.service"; + +describe("SsoUrlService", () => { + let service: SsoUrlService; + + beforeEach(() => { + service = new SsoUrlService(); + }); + + it("should build Desktop SSO URL correctly", () => { + const baseUrl = "https://web-vault.bitwarden.com"; + const clientType = ClientType.Desktop; + const redirectUri = DESKTOP_SSO_CALLBACK; + const state = "abc123"; + const codeChallenge = "xyz789"; + const email = "test@bitwarden.com"; + + const expectedUrl = `${baseUrl}/#/sso?clientId=desktop&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`; + + const result = service.buildSsoUrl( + baseUrl, + clientType, + redirectUri, + state, + codeChallenge, + email, + ); + expect(result).toBe(expectedUrl); + }); + + it("should build Desktop localhost callback SSO URL correctly", () => { + const baseUrl = "https://web-vault.bitwarden.com"; + const clientType = ClientType.Desktop; + const redirectUri = `https://localhost:1000`; + const state = "abc123"; + const codeChallenge = "xyz789"; + const email = "test@bitwarden.com"; + + const expectedUrl = `${baseUrl}/#/sso?clientId=desktop&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`; + + const result = service.buildSsoUrl( + baseUrl, + clientType, + redirectUri, + state, + codeChallenge, + email, + ); + expect(result).toBe(expectedUrl); + }); + + it("should build Extension SSO URL correctly", () => { + const baseUrl = "https://web-vault.bitwarden.com"; + const clientType = ClientType.Browser; + const redirectUri = baseUrl + "/sso-connector.html"; + const state = "abc123"; + const codeChallenge = "xyz789"; + const email = "test@bitwarden.com"; + + const expectedUrl = `${baseUrl}/#/sso?clientId=browser&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`; + + const result = service.buildSsoUrl( + baseUrl, + clientType, + redirectUri, + state, + codeChallenge, + email, + ); + expect(result).toBe(expectedUrl); + }); + + it("should build CLI SSO URL correctly", () => { + const baseUrl = "https://web-vault.bitwarden.com"; + const clientType = ClientType.Cli; + const redirectUri = "https://localhost:1000"; + const state = "abc123"; + const codeChallenge = "xyz789"; + const email = "test@bitwarden.com"; + + const expectedUrl = `${baseUrl}/#/sso?clientId=cli&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`; + + const result = service.buildSsoUrl( + baseUrl, + clientType, + redirectUri, + state, + codeChallenge, + email, + ); + expect(result).toBe(expectedUrl); + }); +}); diff --git a/libs/auth/src/common/services/sso-redirect/sso-url.service.ts b/libs/auth/src/common/services/sso-redirect/sso-url.service.ts new file mode 100644 index 00000000000..667a27ad598 --- /dev/null +++ b/libs/auth/src/common/services/sso-redirect/sso-url.service.ts @@ -0,0 +1,41 @@ +import { ClientType } from "@bitwarden/common/enums"; + +export const DESKTOP_SSO_CALLBACK: string = "bitwarden://sso-callback"; + +export class SsoUrlService { + /** + * Builds a URL for redirecting users to the web app SSO component to complete SSO + * @param webAppUrl The URL of the web app + * @param clientType The client type that is initiating SSO, which will drive how the response is handled + * @param redirectUri The redirect URI or callback that will receive the SSO code after authentication + * @param state A state value that will be peristed through the SSO flow + * @param codeChallenge A challenge value that will be used to verify the SSO code after authentication + * @param email The optional email adddress of the user initiating SSO, which will be used to look up the org SSO identifier + * @returns The URL for redirecting users to the web app SSO component + */ + buildSsoUrl( + webAppUrl: string, + clientType: ClientType, + redirectUri: string, + state: string, + codeChallenge: string, + email?: string, + ): string { + let url = + webAppUrl + + "/#/sso?clientId=" + + clientType + + "&redirectUri=" + + encodeURIComponent(redirectUri) + + "&state=" + + state + + "&codeChallenge=" + + codeChallenge; + + if (email) { + url += "&email=" + encodeURIComponent(email); + } + + return url; + } +}