mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 09:43:23 +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:
@@ -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);
|
||||
|
||||
@@ -47,6 +47,7 @@ export class ExtensionLoginComponentService
|
||||
email: string,
|
||||
state: string,
|
||||
codeChallenge: string,
|
||||
orgSsoIdentifier?: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,11 +48,12 @@ export class DesktopLoginComponentService
|
||||
email: string,
|
||||
state: string,
|
||||
codeChallenge: string,
|
||||
orgSsoIdentifier?: string,
|
||||
): Promise<void> {
|
||||
// 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<void> {
|
||||
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) {
|
||||
|
||||
@@ -108,8 +108,13 @@ const ephemeralStore = {
|
||||
};
|
||||
|
||||
const localhostCallbackService = {
|
||||
openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise<void> => {
|
||||
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email });
|
||||
openSsoPrompt: (
|
||||
codeChallenge: string,
|
||||
state: string,
|
||||
email: string,
|
||||
orgSsoIdentifier?: string,
|
||||
): Promise<void> => {
|
||||
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email, orgSsoIdentifier });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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
|
||||
|
||||
@@ -61,8 +61,11 @@ export class WebLoginComponentService
|
||||
email: string,
|
||||
state: string,
|
||||
codeChallenge: string,
|
||||
orgSsoIdentifier?: string,
|
||||
): Promise<void> {
|
||||
await this.router.navigate(["/sso"]);
|
||||
await this.router.navigate(["/sso"], {
|
||||
queryParams: { identifier: orgSsoIdentifier },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9490,6 +9490,9 @@
|
||||
"ssoLoginIsRequired": {
|
||||
"message": "SSO login is required"
|
||||
},
|
||||
"emailRequiredForSsoLogin": {
|
||||
"message": "Email is required for SSO"
|
||||
},
|
||||
"selectedRegionFlag": {
|
||||
"message": "Selected region flag"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user