1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 10:13:31 +00:00

[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
This commit is contained in:
Ike
2025-12-10 10:31:28 -05:00
committed by GitHub
parent 852248d5fa
commit 0e277a411d
19 changed files with 308 additions and 48 deletions

View File

@@ -33,19 +33,27 @@ export class DefaultLoginComponentService implements LoginComponentService {
*/
async redirectToSsoLogin(email: string): Promise<void | null> {
// 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<void | null> {
// 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<void> {
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];
}
}

View File

@@ -35,6 +35,14 @@ export abstract class LoginComponentService {
*/
redirectToSsoLogin: (email: string) => Promise<void | null>;
/**
* 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<void | null>;
/**
* Shows the back button.
*/

View File

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

View File

@@ -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<AuthResult>} - A promise that resolves to an AuthResult object
*/
protected async processSsoRequiredResponse(
response: IdentitySsoRequiredResponse,
): Promise<AuthResult> {
const result = new AuthResult();
result.ssoOrganizationIdentifier = response.ssoOrganizationIdentifier;
return result;
}
}

View File

@@ -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<void> {
// 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;
}

View File

@@ -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
*/

View File

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

View File

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

View File

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

View File

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