1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-26 13:13:22 +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

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

View File

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

View File

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

View File

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