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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user