From 0e277a411d3c37fcb4141cb652e6dddfd44ba37d Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:31:28 -0500 Subject: [PATCH] [PM-1632] Redirect on SSO required response from `connect/token` (#17637) * feat: add Identity Sso Required Response type as possible response from token endpoint. * feat: consume sso organization identifier to redirect user * feat: add get requiresSso to AuthResult for more ergonomic code. * feat: sso-redirect on sso-required for CLI and Desktop * chore: fixing type errors * test: fix and add tests for new sso method * docs: fix misspelling * fix: get email from AuthResult instead of the FormGroup * fix:claude: when email is not available for SSO login show error toast. * fix:claude: add null safety check --- .../extension-login-component.service.spec.ts | 30 +++++++ .../extension-login-component.service.ts | 2 + apps/cli/src/auth/commands/login.command.ts | 80 ++++++++++++++++--- .../desktop-login-component.service.spec.ts | 52 ++++++++++++ .../login/desktop-login-component.service.ts | 12 ++- apps/desktop/src/platform/preload.ts | 9 ++- .../sso-localhost-callback.service.ts | 33 +++++--- .../login/web-login-component.service.ts | 5 +- apps/web/src/locales/en/messages.json | 3 + .../login/default-login-component.service.ts | 36 ++++++--- .../angular/login/login-component.service.ts | 8 ++ .../auth/src/angular/login/login.component.ts | 18 +++++ .../common/login-strategies/login.strategy.ts | 21 ++++- .../password-login.strategy.ts | 11 ++- .../services/sso-redirect/sso-url.service.ts | 4 +- libs/common/src/abstractions/api.service.ts | 6 +- .../src/auth/models/domain/auth-result.ts | 8 ++ .../identity-sso-required.response.ts | 10 +++ libs/common/src/services/api.service.ts | 8 +- 19 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 libs/common/src/auth/models/response/identity-sso-required.response.ts 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 bd85ff9293e..dc1a7b4bb6b 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 @@ -102,6 +102,36 @@ describe("ExtensionLoginComponentService", () => { }); }); + describe("redirectToSsoLoginWithOrganizationSsoIdentifier", () => { + it("launches SSO browser window with correct Url", async () => { + const email = "test@bitwarden.com"; + const state = "testState"; + const expectedState = "testState:clientId=browser"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + const orgSsoIdentifier = "org-sso-identifier"; + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.redirectToSsoLoginWithOrganizationSsoIdentifier(email, orgSsoIdentifier); + + expect(ssoUrlService.buildSsoUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + email, + orgSsoIdentifier, + ); + 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 621c7d74876..cfaf6e04d10 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 @@ -47,6 +47,7 @@ export class ExtensionLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); @@ -60,6 +61,7 @@ export class ExtensionLoginComponentService state, codeChallenge, email, + orgSsoIdentifier, ); this.platformUtilsService.launchUri(webAppSsoUrl); diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 661e052fb72..d8859318b52 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -113,20 +113,14 @@ export class LoginCommand { } else if (options.sso != null && this.canInteract) { // If the optional Org SSO Identifier isn't provided, the option value is `true`. const orgSsoIdentifier = options.sso === true ? null : options.sso; - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; - const state = await this.passwordGenerationService.generatePassword(passwordOptions); - ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + const ssoPromptData = await this.makeSsoPromptData(); + ssoCodeVerifier = ssoPromptData.ssoCodeVerifier; try { - const ssoParams = await this.openSsoPrompt(codeChallenge, state, orgSsoIdentifier); + const ssoParams = await this.openSsoPrompt( + ssoPromptData.codeChallenge, + ssoPromptData.state, + orgSsoIdentifier, + ); ssoCode = ssoParams.ssoCode; orgIdentifier = ssoParams.orgIdentifier; } catch { @@ -231,9 +225,43 @@ export class LoginCommand { new PasswordLoginCredentials(email, password, twoFactor), ); } + + // Begin Acting on initial AuthResult + if (response.requiresEncryptionKeyMigration) { return Response.error(this.i18nService.t("legacyEncryptionUnsupported")); } + + // Opting for not checking feature flag since the server will not respond with + // SsoOrganizationIdentifier if the feature flag is not enabled. + if (response.requiresSso && this.canInteract) { + const ssoPromptData = await this.makeSsoPromptData(); + ssoCodeVerifier = ssoPromptData.ssoCodeVerifier; + try { + const ssoParams = await this.openSsoPrompt( + ssoPromptData.codeChallenge, + ssoPromptData.state, + response.ssoOrganizationIdentifier, + ); + ssoCode = ssoParams.ssoCode; + orgIdentifier = ssoParams.orgIdentifier; + if (ssoCode != null && ssoCodeVerifier != null) { + response = await this.loginStrategyService.logIn( + new SsoLoginCredentials( + ssoCode, + ssoCodeVerifier, + this.ssoRedirectUri, + orgIdentifier, + undefined, // email to look up 2FA token not required as CLI can't remember 2FA token + twoFactor, + ), + ); + } + } catch { + return Response.badRequest("Something went wrong. Try again."); + } + } + if (response.requiresTwoFactor) { const twoFactorProviders = await this.twoFactorService.getSupportedProviders(null); if (twoFactorProviders.length === 0) { @@ -279,6 +307,10 @@ export class LoginCommand { if (twoFactorToken == null && selectedProvider.type === TwoFactorProviderType.Email) { const emailReq = new TwoFactorEmailRequest(); emailReq.email = await this.loginStrategyService.getEmail(); + // if the user was logging in with SSO, we need to include the SSO session token + if (response.ssoEmail2FaSessionToken != null) { + emailReq.ssoEmail2FaSessionToken = response.ssoEmail2FaSessionToken; + } emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); await this.twoFactorApiService.postTwoFactorEmail(emailReq); } @@ -324,6 +356,7 @@ export class LoginCommand { response = await this.loginStrategyService.logInNewDeviceVerification(newDeviceToken); } + // We check response two factor again here since MFA could fail based on the logic on ln 226 if (response.requiresTwoFactor) { return Response.error("Login failed."); } @@ -692,6 +725,27 @@ export class LoginCommand { }; } + /// Generate SSO prompt data: code verifier, code challenge, and state + private async makeSsoPromptData(): Promise<{ + ssoCodeVerifier: string; + codeChallenge: string; + state: string; + }> { + const passwordOptions: any = { + type: "password", + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + const state = await this.passwordGenerationService.generatePassword(passwordOptions); + const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + return { ssoCodeVerifier, codeChallenge, state }; + } + private async openSsoPrompt( codeChallenge: string, state: string, 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 c88627250c9..414bbaca56f 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 @@ -136,6 +136,7 @@ describe("DesktopLoginComponentService", () => { codeChallenge, state, email, + undefined, ); } else { expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); @@ -145,4 +146,55 @@ describe("DesktopLoginComponentService", () => { }); }); }); + + describe("redirectToSsoLoginWithOrganizationSsoIdentifier", () => { + // Array of all permutations of isAppImage and isDev + const permutations = [ + [true, false], // Case 1: isAppImage true + [false, true], // Case 2: isDev true + [true, true], // Case 3: all true + [false, false], // Case 4: all false + ]; + + permutations.forEach(([isAppImage, isDev]) => { + it("calls redirectToSso with orgSsoIdentifier", async () => { + (global as any).ipc.platform.isAppImage = isAppImage; + (global as any).ipc.platform.isDev = isDev; + + const email = "test@bitwarden.com"; + const state = "testState"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + const orgSsoIdentifier = "orgSsoId"; + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.redirectToSsoLoginWithOrganizationSsoIdentifier(email, orgSsoIdentifier); + + if (isAppImage || isDev) { + expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith( + codeChallenge, + state, + email, + orgSsoIdentifier, + ); + } else { + expect(ssoUrlService.buildSsoUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + email, + orgSsoIdentifier, + ); + 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 d7e7ba0178b..6ef39eaa018 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -48,11 +48,12 @@ export class DesktopLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: 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.isDev) { - await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge); + await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge, orgSsoIdentifier); } else { const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); @@ -66,6 +67,7 @@ export class DesktopLoginComponentService state, codeChallenge, email, + orgSsoIdentifier, ); this.platformUtilsService.launchUri(ssoWebAppUrl); @@ -76,9 +78,15 @@ export class DesktopLoginComponentService email: string, state: string, challenge: string, + orgSsoIdentifier?: string, ): Promise { try { - await ipc.platform.localhostCallbackService.openSsoPrompt(challenge, state, email); + await ipc.platform.localhostCallbackService.openSsoPrompt( + challenge, + state, + email, + orgSsoIdentifier, + ); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 5af2fa571ec..a45ac753b3f 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -108,8 +108,13 @@ const ephemeralStore = { }; const localhostCallbackService = { - openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise => { - return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email }); + openSsoPrompt: ( + codeChallenge: string, + state: string, + email: string, + orgSsoIdentifier?: string, + ): Promise => { + return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email, orgSsoIdentifier }); }, }; 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 75a84919b07..fdd9bc29237 100644 --- a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -25,20 +25,25 @@ export class SSOLocalhostCallbackService { private messagingService: MessageSender, private ssoUrlService: SsoUrlService, ) { - ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { - // Close any existing server before starting new one - if (this.currentServer) { - await this.closeCurrentServer(); - } + ipcMain.handle( + "openSsoPrompt", + async (event, { codeChallenge, state, email, orgSsoIdentifier }) => { + // Close any existing server before starting new one + if (this.currentServer) { + await this.closeCurrentServer(); + } - return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => { - this.messagingService.send("ssoCallback", { - code: ssoCode, - state: recvState, - redirectUri: this.ssoRedirectUri, - }); - }); - }); + return this.openSsoPrompt(codeChallenge, state, email, orgSsoIdentifier).then( + ({ ssoCode, recvState }) => { + this.messagingService.send("ssoCallback", { + code: ssoCode, + state: recvState, + redirectUri: this.ssoRedirectUri, + }); + }, + ); + }, + ); } private async closeCurrentServer(): Promise { @@ -58,6 +63,7 @@ export class SSOLocalhostCallbackService { codeChallenge: string, state: string, email: string, + orgSsoIdentifier?: string, ): Promise<{ ssoCode: string; recvState: string }> { const env = await firstValueFrom(this.environmentService.environment$); @@ -121,6 +127,7 @@ export class SSOLocalhostCallbackService { state, codeChallenge, email, + orgSsoIdentifier, ); // Set up error handler before attempting to listen 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 5bea0908b0a..8c1bc4bd080 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 @@ -61,8 +61,11 @@ export class WebLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { - await this.router.navigate(["/sso"]); + await this.router.navigate(["/sso"], { + queryParams: { identifier: orgSsoIdentifier }, + }); return; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 85159c0230c..be2f72e34b0 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9490,6 +9490,9 @@ "ssoLoginIsRequired": { "message": "SSO login is required" }, + "emailRequiredForSsoLogin": { + "message": "Email is required for SSO" + }, "selectedRegionFlag": { "message": "Selected region flag" }, 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 7f98040d9c2..2d50a0ffeb7 100644 --- a/libs/auth/src/angular/login/default-login-component.service.ts +++ b/libs/auth/src/angular/login/default-login-component.service.ts @@ -33,19 +33,27 @@ export class DefaultLoginComponentService implements LoginComponentService { */ 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); + const [state, codeChallenge] = await this.setSsoPreLoginState(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); } + /** + * 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 redirectToSsoLoginWithOrganizationSsoIdentifier( + email: string, + orgSsoIdentifier: 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(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, orgSsoIdentifier); + } + /** * No-op implementation of redirectToSso */ @@ -53,6 +61,7 @@ export class DefaultLoginComponentService implements LoginComponentService { email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { return; } @@ -65,9 +74,9 @@ export class DefaultLoginComponentService implements LoginComponentService { } /** - * Sets the state required for verifying SSO login after completion + * Set the state that we'll need to verify the SSO login when we get the authorization code back */ - private async setSsoPreLoginState(): Promise<[string, string]> { + private async setSsoPreLoginState(email: string): Promise<[string, string]> { // Generate SSO params const passwordOptions: any = { type: "password", @@ -93,6 +102,13 @@ export class DefaultLoginComponentService implements LoginComponentService { await this.ssoLoginService.setSsoState(state); await this.ssoLoginService.setCodeVerifier(codeVerifier); + // 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); + 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 5ca83c97c5f..b7c2b16ce24 100644 --- a/libs/auth/src/angular/login/login-component.service.ts +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -35,6 +35,14 @@ export abstract class LoginComponentService { */ redirectToSsoLogin: (email: string) => Promise; + /** + * Redirects the user to the SSO login page with organization SSO identifier, either via route or in a new browser window. + */ + redirectToSsoLoginWithOrganizationSsoIdentifier: ( + email: string, + orgSsoIdentifier: string | null | undefined, + ) => 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 91ca2b614d1..8e688f3f830 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -381,6 +381,24 @@ export class LoginComponent implements OnInit, OnDestroy { return; } + // redirect to SSO if ssoOrganizationIdentifier is present in token response + if (authResult.requiresSso) { + const email = this.formGroup?.value?.email; + if (!email) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("emailRequiredForSsoLogin"), + }); + return; + } + await this.loginComponentService.redirectToSsoLoginWithOrganizationSsoIdentifier( + email, + authResult.ssoOrganizationIdentifier, + ); + return; + } + // User logged in successfully so execute side effects await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 08d5ae6246f..ae375c8b2f5 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; @@ -49,7 +50,8 @@ import { CacheData } from "../services/login-strategies/login-strategy.state"; type IdentityResponse = | IdentityTokenResponse | IdentityTwoFactorResponse - | IdentityDeviceVerificationResponse; + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse; export abstract class LoginStrategyData { tokenRequest: @@ -128,6 +130,8 @@ export abstract class LoginStrategy { return [await this.processTokenResponse(response), response]; } else if (response instanceof IdentityDeviceVerificationResponse) { return [await this.processDeviceVerificationResponse(response), response]; + } else if (response instanceof IdentitySsoRequiredResponse) { + return [await this.processSsoRequiredResponse(response), response]; } throw new Error("Invalid response object."); @@ -398,4 +402,19 @@ export abstract class LoginStrategy { result.requiresDeviceVerification = true; return result; } + + /** + * Handles the response from the server when a SSO Authentication is required. + * It hydrates the AuthResult with the SSO organization identifier. + * + * @param {IdentitySsoRequiredResponse} response - The response from the server indicating that SSO is required. + * @returns {Promise} - A promise that resolves to an AuthResult object + */ + protected async processSsoRequiredResponse( + response: IdentitySsoRequiredResponse, + ): Promise { + const result = new AuthResult(); + result.ssoOrganizationIdentifier = response.ssoOrganizationIdentifier; + return result; + } } diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index ad49567b2ff..842a48e28cd 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { HashPurpose } from "@bitwarden/common/platform/enums"; @@ -165,14 +166,20 @@ export class PasswordLoginStrategy extends LoginStrategy { identityResponse: | IdentityTokenResponse | IdentityTwoFactorResponse - | IdentityDeviceVerificationResponse, + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse, credentials: PasswordLoginCredentials, authResult: AuthResult, ): Promise { // TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the // IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse // If the response is a device verification response, we don't need to evaluate the password - if (identityResponse instanceof IdentityDeviceVerificationResponse) { + // If SSO is required, we also do not evaluate the password here, since the user needs to first + // authenticate with their SSO IdP Provider + if ( + identityResponse instanceof IdentityDeviceVerificationResponse || + identityResponse instanceof IdentitySsoRequiredResponse + ) { return; } 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 index b2d6231db7c..22404c5b1f7 100644 --- a/libs/auth/src/common/services/sso-redirect/sso-url.service.ts +++ b/libs/auth/src/common/services/sso-redirect/sso-url.service.ts @@ -8,9 +8,9 @@ export class SsoUrlService { * @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 state A state value that will be persisted 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 + * @param email The optional email address of the user initiating SSO, which will be used to look up the org SSO identifier * @param orgSsoIdentifier The optional SSO identifier of the org that is initiating SSO * @returns The URL for redirecting users to the web app SSO component */ diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index f7ca1964b76..72a17f0fa87 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -50,6 +50,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; @@ -140,7 +141,10 @@ export abstract class ApiService { | UserApiTokenRequest | WebAuthnLoginTokenRequest, ): Promise< - IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse >; abstract refreshIdentityToken(userId?: UserId): Promise; diff --git a/libs/common/src/auth/models/domain/auth-result.ts b/libs/common/src/auth/models/domain/auth-result.ts index ae3e9bdeda6..178866901d3 100644 --- a/libs/common/src/auth/models/domain/auth-result.ts +++ b/libs/common/src/auth/models/domain/auth-result.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Utils } from "@bitwarden/common/platform/misc/utils"; + import { UserId } from "../../../types/guid"; import { TwoFactorProviderType } from "../../enums/two-factor-provider-type"; @@ -18,10 +20,16 @@ export class AuthResult { email: string; requiresEncryptionKeyMigration: boolean; requiresDeviceVerification: boolean; + ssoOrganizationIdentifier?: string | null; // The master-password used in the authentication process masterPassword: string | null; get requiresTwoFactor() { return this.twoFactorProviders != null; } + + // This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach. + get requiresSso() { + return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier); + } } diff --git a/libs/common/src/auth/models/response/identity-sso-required.response.ts b/libs/common/src/auth/models/response/identity-sso-required.response.ts new file mode 100644 index 00000000000..b1b6df6fd08 --- /dev/null +++ b/libs/common/src/auth/models/response/identity-sso-required.response.ts @@ -0,0 +1,10 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class IdentitySsoRequiredResponse extends BaseResponse { + ssoOrganizationIdentifier: string | null; + + constructor(response: any) { + super(response); + this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier"); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 5f4d3de11b5..c60f6c5e907 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -63,6 +63,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; @@ -165,7 +166,10 @@ export class ApiService implements ApiServiceAbstraction { | SsoTokenRequest | WebAuthnLoginTokenRequest, ): Promise< - IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse > { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", @@ -212,6 +216,8 @@ export class ApiService implements ApiServiceAbstraction { responseJson?.ErrorModel?.Message === ApiService.NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE ) { return new IdentityDeviceVerificationResponse(responseJson); + } else if (response.status === 400 && responseJson?.SsoOrganizationIdentifier) { + return new IdentitySsoRequiredResponse(responseJson); } }