1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 17:53:39 +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

@@ -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", () => { describe("showBackButton", () => {
it("sets showBackButton in extensionAnonLayoutWrapperDataService", () => { it("sets showBackButton in extensionAnonLayoutWrapperDataService", () => {
service.showBackButton(true); service.showBackButton(true);

View File

@@ -47,6 +47,7 @@ export class ExtensionLoginComponentService
email: string, email: string,
state: string, state: string,
codeChallenge: string, codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> { ): Promise<void> {
const env = await firstValueFrom(this.environmentService.environment$); const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl(); const webVaultUrl = env.getWebVaultUrl();
@@ -60,6 +61,7 @@ export class ExtensionLoginComponentService
state, state,
codeChallenge, codeChallenge,
email, email,
orgSsoIdentifier,
); );
this.platformUtilsService.launchUri(webAppSsoUrl); this.platformUtilsService.launchUri(webAppSsoUrl);

View File

@@ -113,20 +113,14 @@ export class LoginCommand {
} else if (options.sso != null && this.canInteract) { } else if (options.sso != null && this.canInteract) {
// If the optional Org SSO Identifier isn't provided, the option value is `true`. // If the optional Org SSO Identifier isn't provided, the option value is `true`.
const orgSsoIdentifier = options.sso === true ? null : options.sso; const orgSsoIdentifier = options.sso === true ? null : options.sso;
const passwordOptions: any = { const ssoPromptData = await this.makeSsoPromptData();
type: "password", ssoCodeVerifier = ssoPromptData.ssoCodeVerifier;
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);
try { try {
const ssoParams = await this.openSsoPrompt(codeChallenge, state, orgSsoIdentifier); const ssoParams = await this.openSsoPrompt(
ssoPromptData.codeChallenge,
ssoPromptData.state,
orgSsoIdentifier,
);
ssoCode = ssoParams.ssoCode; ssoCode = ssoParams.ssoCode;
orgIdentifier = ssoParams.orgIdentifier; orgIdentifier = ssoParams.orgIdentifier;
} catch { } catch {
@@ -231,9 +225,43 @@ export class LoginCommand {
new PasswordLoginCredentials(email, password, twoFactor), new PasswordLoginCredentials(email, password, twoFactor),
); );
} }
// Begin Acting on initial AuthResult
if (response.requiresEncryptionKeyMigration) { if (response.requiresEncryptionKeyMigration) {
return Response.error(this.i18nService.t("legacyEncryptionUnsupported")); 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) { if (response.requiresTwoFactor) {
const twoFactorProviders = await this.twoFactorService.getSupportedProviders(null); const twoFactorProviders = await this.twoFactorService.getSupportedProviders(null);
if (twoFactorProviders.length === 0) { if (twoFactorProviders.length === 0) {
@@ -279,6 +307,10 @@ export class LoginCommand {
if (twoFactorToken == null && selectedProvider.type === TwoFactorProviderType.Email) { if (twoFactorToken == null && selectedProvider.type === TwoFactorProviderType.Email) {
const emailReq = new TwoFactorEmailRequest(); const emailReq = new TwoFactorEmailRequest();
emailReq.email = await this.loginStrategyService.getEmail(); 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(); emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
await this.twoFactorApiService.postTwoFactorEmail(emailReq); await this.twoFactorApiService.postTwoFactorEmail(emailReq);
} }
@@ -324,6 +356,7 @@ export class LoginCommand {
response = await this.loginStrategyService.logInNewDeviceVerification(newDeviceToken); 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) { if (response.requiresTwoFactor) {
return Response.error("Login failed."); 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( private async openSsoPrompt(
codeChallenge: string, codeChallenge: string,
state: string, state: string,

View File

@@ -136,6 +136,7 @@ describe("DesktopLoginComponentService", () => {
codeChallenge, codeChallenge,
state, state,
email, email,
undefined,
); );
} else { } else {
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); 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();
}
});
});
});
}); });

View File

@@ -48,11 +48,12 @@ export class DesktopLoginComponentService
email: string, email: string,
state: string, state: string,
codeChallenge: string, codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> { ): Promise<void> {
// For platforms that cannot support a protocol-based (e.g. bitwarden://) callback, we use a localhost callback // 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 // Otherwise, we launch the SSO component in a browser window and wait for the callback
if (ipc.platform.isAppImage || ipc.platform.isDev) { if (ipc.platform.isAppImage || ipc.platform.isDev) {
await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge); await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge, orgSsoIdentifier);
} else { } else {
const env = await firstValueFrom(this.environmentService.environment$); const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl(); const webVaultUrl = env.getWebVaultUrl();
@@ -66,6 +67,7 @@ export class DesktopLoginComponentService
state, state,
codeChallenge, codeChallenge,
email, email,
orgSsoIdentifier,
); );
this.platformUtilsService.launchUri(ssoWebAppUrl); this.platformUtilsService.launchUri(ssoWebAppUrl);
@@ -76,9 +78,15 @@ export class DesktopLoginComponentService
email: string, email: string,
state: string, state: string,
challenge: string, challenge: string,
orgSsoIdentifier?: string,
): Promise<void> { ): Promise<void> {
try { 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 // FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) { } catch (err) {

View File

@@ -108,8 +108,13 @@ const ephemeralStore = {
}; };
const localhostCallbackService = { const localhostCallbackService = {
openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise<void> => { openSsoPrompt: (
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email }); codeChallenge: string,
state: string,
email: string,
orgSsoIdentifier?: string,
): Promise<void> => {
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email, orgSsoIdentifier });
}, },
}; };

View File

@@ -25,20 +25,25 @@ export class SSOLocalhostCallbackService {
private messagingService: MessageSender, private messagingService: MessageSender,
private ssoUrlService: SsoUrlService, private ssoUrlService: SsoUrlService,
) { ) {
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { ipcMain.handle(
"openSsoPrompt",
async (event, { codeChallenge, state, email, orgSsoIdentifier }) => {
// Close any existing server before starting new one // Close any existing server before starting new one
if (this.currentServer) { if (this.currentServer) {
await this.closeCurrentServer(); await this.closeCurrentServer();
} }
return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => { return this.openSsoPrompt(codeChallenge, state, email, orgSsoIdentifier).then(
({ ssoCode, recvState }) => {
this.messagingService.send("ssoCallback", { this.messagingService.send("ssoCallback", {
code: ssoCode, code: ssoCode,
state: recvState, state: recvState,
redirectUri: this.ssoRedirectUri, redirectUri: this.ssoRedirectUri,
}); });
}); },
}); );
},
);
} }
private async closeCurrentServer(): Promise<void> { private async closeCurrentServer(): Promise<void> {
@@ -58,6 +63,7 @@ export class SSOLocalhostCallbackService {
codeChallenge: string, codeChallenge: string,
state: string, state: string,
email: string, email: string,
orgSsoIdentifier?: string,
): Promise<{ ssoCode: string; recvState: string }> { ): Promise<{ ssoCode: string; recvState: string }> {
const env = await firstValueFrom(this.environmentService.environment$); const env = await firstValueFrom(this.environmentService.environment$);
@@ -121,6 +127,7 @@ export class SSOLocalhostCallbackService {
state, state,
codeChallenge, codeChallenge,
email, email,
orgSsoIdentifier,
); );
// Set up error handler before attempting to listen // Set up error handler before attempting to listen

View File

@@ -61,8 +61,11 @@ export class WebLoginComponentService
email: string, email: string,
state: string, state: string,
codeChallenge: string, codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> { ): Promise<void> {
await this.router.navigate(["/sso"]); await this.router.navigate(["/sso"], {
queryParams: { identifier: orgSsoIdentifier },
});
return; return;
} }

View File

@@ -9490,6 +9490,9 @@
"ssoLoginIsRequired": { "ssoLoginIsRequired": {
"message": "SSO login is required" "message": "SSO login is required"
}, },
"emailRequiredForSsoLogin": {
"message": "Email is required for SSO"
},
"selectedRegionFlag": { "selectedRegionFlag": {
"message": "Selected region flag" "message": "Selected region flag"
}, },

View File

@@ -33,19 +33,27 @@ export class DefaultLoginComponentService implements LoginComponentService {
*/ */
async redirectToSsoLogin(email: string): Promise<void | null> { 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 // Set the state that we'll need to verify the SSO login when we get the code back
const [state, codeChallenge] = await this.setSsoPreLoginState(); const [state, codeChallenge] = await this.setSsoPreLoginState(email);
// 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. // 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); 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 * No-op implementation of redirectToSso
*/ */
@@ -53,6 +61,7 @@ export class DefaultLoginComponentService implements LoginComponentService {
email: string, email: string,
state: string, state: string,
codeChallenge: string, codeChallenge: string,
orgSsoIdentifier?: string,
): Promise<void> { ): Promise<void> {
return; 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 // Generate SSO params
const passwordOptions: any = { const passwordOptions: any = {
type: "password", type: "password",
@@ -93,6 +102,13 @@ export class DefaultLoginComponentService implements LoginComponentService {
await this.ssoLoginService.setSsoState(state); await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(codeVerifier); 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]; return [state, codeChallenge];
} }
} }

View File

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

View File

@@ -381,6 +381,24 @@ export class LoginComponent implements OnInit, OnDestroy {
return; 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 // User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword); 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 { 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 { 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 { 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 { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
@@ -49,7 +50,8 @@ import { CacheData } from "../services/login-strategies/login-strategy.state";
type IdentityResponse = type IdentityResponse =
| IdentityTokenResponse | IdentityTokenResponse
| IdentityTwoFactorResponse | IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse; | IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse;
export abstract class LoginStrategyData { export abstract class LoginStrategyData {
tokenRequest: tokenRequest:
@@ -128,6 +130,8 @@ export abstract class LoginStrategy {
return [await this.processTokenResponse(response), response]; return [await this.processTokenResponse(response), response];
} else if (response instanceof IdentityDeviceVerificationResponse) { } else if (response instanceof IdentityDeviceVerificationResponse) {
return [await this.processDeviceVerificationResponse(response), response]; return [await this.processDeviceVerificationResponse(response), response];
} else if (response instanceof IdentitySsoRequiredResponse) {
return [await this.processSsoRequiredResponse(response), response];
} }
throw new Error("Invalid response object."); throw new Error("Invalid response object.");
@@ -398,4 +402,19 @@ export abstract class LoginStrategy {
result.requiresDeviceVerification = true; result.requiresDeviceVerification = true;
return result; 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 { 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 { 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 { 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 { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { HashPurpose } from "@bitwarden/common/platform/enums"; import { HashPurpose } from "@bitwarden/common/platform/enums";
@@ -165,14 +166,20 @@ export class PasswordLoginStrategy extends LoginStrategy {
identityResponse: identityResponse:
| IdentityTokenResponse | IdentityTokenResponse
| IdentityTwoFactorResponse | IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse, | IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse,
credentials: PasswordLoginCredentials, credentials: PasswordLoginCredentials,
authResult: AuthResult, authResult: AuthResult,
): Promise<void> { ): Promise<void> {
// TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the // TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the
// IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse // IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse
// If the response is a device verification response, we don't need to evaluate the password // 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; return;
} }

View File

@@ -8,9 +8,9 @@ export class SsoUrlService {
* @param webAppUrl The URL of the web app * @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 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 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 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 * @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 * @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 { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.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 { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
@@ -140,7 +141,10 @@ export abstract class ApiService {
| UserApiTokenRequest | UserApiTokenRequest
| WebAuthnLoginTokenRequest, | WebAuthnLoginTokenRequest,
): Promise< ): Promise<
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse | IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse
>; >;
abstract refreshIdentityToken(userId?: UserId): Promise<any>; 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 // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type"; import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
@@ -18,10 +20,16 @@ export class AuthResult {
email: string; email: string;
requiresEncryptionKeyMigration: boolean; requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean; requiresDeviceVerification: boolean;
ssoOrganizationIdentifier?: string | null;
// The master-password used in the authentication process // The master-password used in the authentication process
masterPassword: string | null; masterPassword: string | null;
get requiresTwoFactor() { get requiresTwoFactor() {
return this.twoFactorProviders != null; 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 { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.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 { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
@@ -165,7 +166,10 @@ export class ApiService implements ApiServiceAbstraction {
| SsoTokenRequest | SsoTokenRequest
| WebAuthnLoginTokenRequest, | WebAuthnLoginTokenRequest,
): Promise< ): Promise<
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse | IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse
> { > {
const headers = new Headers({ const headers = new Headers({
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8", "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 responseJson?.ErrorModel?.Message === ApiService.NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE
) { ) {
return new IdentityDeviceVerificationResponse(responseJson); return new IdentityDeviceVerificationResponse(responseJson);
} else if (response.status === 400 && responseJson?.SsoOrganizationIdentifier) {
return new IdentitySsoRequiredResponse(responseJson);
} }
} }