mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-17751] Store SSO email in state on web client (#13295)
* Moved saving of SSO email outside of browser/desktop code * Clarified comments. * Tests * Refactored login component services to manage state * Fixed input on login component * Fixed tests * Linting * Moved web setting in state into web override * updated tests * Fixed typing. * Fixed type safety issues. * Added comments and renamed for clarity. * Removed method parameters that weren't used * Added clarifying comments * Added more comments. * Removed test that is not necessary on base * Test cleanup * More comments. * Linting * Fixed test. * Fixed base URL * Fixed typechecking. * Type checking * Moved setting of email state to default service * Added comments. * Consolidated SSO URL formatting * Updated comment * Fixed reference. * Fixed missing parameter. * Initialized service. * Added comments * Added initialization of new service * Made email optional due to CLI. * Fixed comment on handleSsoClick. * Added SSO email persistence to v1 component. --------- Co-authored-by: Bernd Schoolmann <mail@quexten.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
import { TestBed } from "@angular/core/testing";
|
||||||
import { MockProxy, mock } from "jest-mock-extended";
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
|
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
|
||||||
|
import { SsoUrlService } from "@bitwarden/auth/common";
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import {
|
||||||
|
Environment,
|
||||||
|
EnvironmentService,
|
||||||
|
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
|
|
||||||
import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service";
|
import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service";
|
||||||
@@ -18,6 +25,7 @@ jest.mock("../../../platform/flags", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("ExtensionLoginComponentService", () => {
|
describe("ExtensionLoginComponentService", () => {
|
||||||
|
const baseUrl = "https://webvault.bitwarden.com";
|
||||||
let service: ExtensionLoginComponentService;
|
let service: ExtensionLoginComponentService;
|
||||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||||
let environmentService: MockProxy<EnvironmentService>;
|
let environmentService: MockProxy<EnvironmentService>;
|
||||||
@@ -25,13 +33,20 @@ describe("ExtensionLoginComponentService", () => {
|
|||||||
let platformUtilsService: MockProxy<BrowserPlatformUtilsService>;
|
let platformUtilsService: MockProxy<BrowserPlatformUtilsService>;
|
||||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||||
let extensionAnonLayoutWrapperDataService: MockProxy<ExtensionAnonLayoutWrapperDataService>;
|
let extensionAnonLayoutWrapperDataService: MockProxy<ExtensionAnonLayoutWrapperDataService>;
|
||||||
|
let ssoUrlService: MockProxy<SsoUrlService>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
environmentService = mock<EnvironmentService>();
|
environmentService = mock<EnvironmentService>();
|
||||||
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
||||||
platformUtilsService = mock<BrowserPlatformUtilsService>();
|
platformUtilsService = mock<BrowserPlatformUtilsService>();
|
||||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||||
|
ssoUrlService = mock<SsoUrlService>();
|
||||||
extensionAnonLayoutWrapperDataService = mock<ExtensionAnonLayoutWrapperDataService>();
|
extensionAnonLayoutWrapperDataService = mock<ExtensionAnonLayoutWrapperDataService>();
|
||||||
|
environmentService.environment$ = new BehaviorSubject<Environment>({
|
||||||
|
getWebVaultUrl: () => baseUrl,
|
||||||
|
} as Environment);
|
||||||
|
platformUtilsService.getClientType.mockReturnValue(ClientType.Browser);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -44,6 +59,7 @@ describe("ExtensionLoginComponentService", () => {
|
|||||||
platformUtilsService,
|
platformUtilsService,
|
||||||
ssoLoginService,
|
ssoLoginService,
|
||||||
extensionAnonLayoutWrapperDataService,
|
extensionAnonLayoutWrapperDataService,
|
||||||
|
ssoUrlService,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ provide: DefaultLoginComponentService, useExisting: ExtensionLoginComponentService },
|
{ provide: DefaultLoginComponentService, useExisting: ExtensionLoginComponentService },
|
||||||
@@ -52,6 +68,11 @@ describe("ExtensionLoginComponentService", () => {
|
|||||||
{ provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService },
|
{ provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService },
|
||||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||||
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
|
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
|
||||||
|
{
|
||||||
|
provide: ExtensionAnonLayoutWrapperDataService,
|
||||||
|
useValue: extensionAnonLayoutWrapperDataService,
|
||||||
|
},
|
||||||
|
{ provide: SsoUrlService, useValue: ssoUrlService },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
service = TestBed.inject(ExtensionLoginComponentService);
|
service = TestBed.inject(ExtensionLoginComponentService);
|
||||||
@@ -61,6 +82,26 @@ describe("ExtensionLoginComponentService", () => {
|
|||||||
expect(service).toBeTruthy();
|
expect(service).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redirectToSso", () => {
|
||||||
|
it("launches SSO browser window", async () => {
|
||||||
|
const email = "test@bitwarden.com";
|
||||||
|
const state = "testState";
|
||||||
|
const expectedState = "testState:clientId=browser";
|
||||||
|
const codeVerifier = "testCodeVerifier";
|
||||||
|
const codeChallenge = "testCodeChallenge";
|
||||||
|
|
||||||
|
passwordGenerationService.generatePassword.mockResolvedValueOnce(state);
|
||||||
|
passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier);
|
||||||
|
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge);
|
||||||
|
|
||||||
|
await service.redirectToSsoLogin(email);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// 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 { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular";
|
import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular";
|
||||||
|
import { SsoUrlService } from "@bitwarden/auth/common";
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
@@ -23,6 +25,7 @@ export class ExtensionLoginComponentService
|
|||||||
platformUtilsService: PlatformUtilsService,
|
platformUtilsService: PlatformUtilsService,
|
||||||
ssoLoginService: SsoLoginServiceAbstraction,
|
ssoLoginService: SsoLoginServiceAbstraction,
|
||||||
private extensionAnonLayoutWrapperDataService: ExtensionAnonLayoutWrapperDataService,
|
private extensionAnonLayoutWrapperDataService: ExtensionAnonLayoutWrapperDataService,
|
||||||
|
private ssoUrlService: SsoUrlService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
cryptoFunctionService,
|
cryptoFunctionService,
|
||||||
@@ -31,7 +34,35 @@ export class ExtensionLoginComponentService
|
|||||||
platformUtilsService,
|
platformUtilsService,
|
||||||
ssoLoginService,
|
ssoLoginService,
|
||||||
);
|
);
|
||||||
this.clientType = this.platformUtilsService.getClientType();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On the extension, redirecting to the SSO login page is done via a new browser window, opened
|
||||||
|
* to the SSO component on the web client.
|
||||||
|
* @param email the email of the user trying to log in, used to look up the org SSO identifier
|
||||||
|
* @param state the state that will be used to verify the SSO login, which needs to be passed to the IdP
|
||||||
|
* @param codeChallenge the challenge that will be verified after the code is returned from the IdP, which needs to be passed to the IdP
|
||||||
|
*/
|
||||||
|
protected override async redirectToSso(
|
||||||
|
email: string,
|
||||||
|
state: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const env = await firstValueFrom(this.environmentService.environment$);
|
||||||
|
const webVaultUrl = env.getWebVaultUrl();
|
||||||
|
|
||||||
|
const redirectUri = webVaultUrl + "/sso-connector.html";
|
||||||
|
|
||||||
|
const webAppSsoUrl = this.ssoUrlService.buildSsoUrl(
|
||||||
|
webVaultUrl,
|
||||||
|
this.clientType,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.platformUtilsService.launchUri(webAppSsoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
showBackButton(showBackButton: boolean): void {
|
showBackButton(showBackButton: boolean): void {
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ import {
|
|||||||
LoginDecryptionOptionsService,
|
LoginDecryptionOptionsService,
|
||||||
SsoComponentService,
|
SsoComponentService,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
import {
|
||||||
|
LockService,
|
||||||
|
LoginEmailService,
|
||||||
|
PinServiceAbstraction,
|
||||||
|
SsoUrlService,
|
||||||
|
} from "@bitwarden/auth/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||||
@@ -550,6 +555,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
useExisting: ExtensionAnonLayoutWrapperDataService,
|
useExisting: ExtensionAnonLayoutWrapperDataService,
|
||||||
deps: [],
|
deps: [],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: SsoUrlService,
|
||||||
|
useClass: SsoUrlService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: LoginComponentService,
|
provide: LoginComponentService,
|
||||||
useClass: ExtensionLoginComponentService,
|
useClass: ExtensionLoginComponentService,
|
||||||
@@ -560,6 +570,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
PlatformUtilsService,
|
PlatformUtilsService,
|
||||||
SsoLoginServiceAbstraction,
|
SsoLoginServiceAbstraction,
|
||||||
ExtensionAnonLayoutWrapperDataService,
|
ExtensionAnonLayoutWrapperDataService,
|
||||||
|
SsoUrlService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
LoginStrategyServiceAbstraction,
|
LoginStrategyServiceAbstraction,
|
||||||
PasswordLoginCredentials,
|
PasswordLoginCredentials,
|
||||||
SsoLoginCredentials,
|
SsoLoginCredentials,
|
||||||
|
SsoUrlService,
|
||||||
UserApiLoginCredentials,
|
UserApiLoginCredentials,
|
||||||
} from "@bitwarden/auth/common";
|
} from "@bitwarden/auth/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
@@ -28,6 +29,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
|
|||||||
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
|
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
|
||||||
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
||||||
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
|
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
@@ -71,6 +73,7 @@ export class LoginCommand {
|
|||||||
protected orgService: OrganizationService,
|
protected orgService: OrganizationService,
|
||||||
protected logoutCallback: () => Promise<void>,
|
protected logoutCallback: () => Promise<void>,
|
||||||
protected kdfConfigService: KdfConfigService,
|
protected kdfConfigService: KdfConfigService,
|
||||||
|
protected ssoUrlService: SsoUrlService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async run(email: string, password: string, options: OptionValues) {
|
async run(email: string, password: string, options: OptionValues) {
|
||||||
@@ -738,17 +741,14 @@ export class LoginCommand {
|
|||||||
try {
|
try {
|
||||||
this.ssoRedirectUri = "http://localhost:" + port;
|
this.ssoRedirectUri = "http://localhost:" + port;
|
||||||
callbackServer.listen(port, () => {
|
callbackServer.listen(port, () => {
|
||||||
this.platformUtilsService.launchUri(
|
const webAppSsoUrl = this.ssoUrlService.buildSsoUrl(
|
||||||
webUrl +
|
webUrl,
|
||||||
"/#/sso?clientId=" +
|
ClientType.Cli,
|
||||||
"cli" +
|
this.ssoRedirectUri,
|
||||||
"&redirectUri=" +
|
state,
|
||||||
encodeURIComponent(this.ssoRedirectUri) +
|
|
||||||
"&state=" +
|
|
||||||
state +
|
|
||||||
"&codeChallenge=" +
|
|
||||||
codeChallenge,
|
codeChallenge,
|
||||||
);
|
);
|
||||||
|
this.platformUtilsService.launchUri(webAppSsoUrl);
|
||||||
});
|
});
|
||||||
foundPort = true;
|
foundPort = true;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export class Program extends BaseProgram {
|
|||||||
this.serviceContainer.organizationService,
|
this.serviceContainer.organizationService,
|
||||||
async () => await this.serviceContainer.logout(),
|
async () => await this.serviceContainer.logout(),
|
||||||
this.serviceContainer.kdfConfigService,
|
this.serviceContainer.kdfConfigService,
|
||||||
|
this.serviceContainer.ssoUrlService,
|
||||||
);
|
);
|
||||||
const response = await command.run(email, password, options);
|
const response = await command.run(email, password, options);
|
||||||
this.processResponse(response, true);
|
this.processResponse(response, true);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PinService,
|
PinService,
|
||||||
PinServiceAbstraction,
|
PinServiceAbstraction,
|
||||||
UserDecryptionOptionsService,
|
UserDecryptionOptionsService,
|
||||||
|
SsoUrlService,
|
||||||
} from "@bitwarden/auth/common";
|
} from "@bitwarden/auth/common";
|
||||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||||
@@ -274,6 +275,7 @@ export class ServiceContainer {
|
|||||||
sdkService: SdkService;
|
sdkService: SdkService;
|
||||||
sdkLoadService: SdkLoadService;
|
sdkLoadService: SdkLoadService;
|
||||||
cipherAuthorizationService: CipherAuthorizationService;
|
cipherAuthorizationService: CipherAuthorizationService;
|
||||||
|
ssoUrlService: SsoUrlService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let p = null;
|
let p = null;
|
||||||
@@ -457,6 +459,7 @@ export class ServiceContainer {
|
|||||||
|
|
||||||
this.biometricStateService = new DefaultBiometricStateService(this.stateProvider);
|
this.biometricStateService = new DefaultBiometricStateService(this.stateProvider);
|
||||||
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
|
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
|
||||||
|
this.ssoUrlService = new SsoUrlService();
|
||||||
|
|
||||||
this.organizationService = new DefaultOrganizationService(this.stateProvider);
|
this.organizationService = new DefaultOrganizationService(this.stateProvider);
|
||||||
this.policyService = new PolicyService(this.stateProvider, this.organizationService);
|
this.policyService = new PolicyService(this.stateProvider, this.organizationService);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
|||||||
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
|
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
|
||||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
|
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||||
import { LogoutReason } from "@bitwarden/auth/common";
|
import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common";
|
||||||
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
|
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
@@ -299,7 +299,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
const queryParams = {
|
const queryParams = {
|
||||||
code: message.code,
|
code: message.code,
|
||||||
state: message.state,
|
state: message.state,
|
||||||
redirectUri: message.redirectUri ?? "bitwarden://sso-callback",
|
redirectUri: message.redirectUri ?? DESKTOP_SSO_CALLBACK,
|
||||||
};
|
};
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -812,7 +812,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (urlString.indexOf("bitwarden://import-callback-lp") === 0) {
|
if (urlString.indexOf("bitwarden://import-callback-lp") === 0) {
|
||||||
message = "importCallbackLastPass";
|
message = "importCallbackLastPass";
|
||||||
} else if (urlString.indexOf("bitwarden://sso-callback") === 0) {
|
} else if (urlString.indexOf(DESKTOP_SSO_CALLBACK) === 0) {
|
||||||
message = "ssoCallback";
|
message = "ssoCallback";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
LoginApprovalComponentServiceAbstraction,
|
LoginApprovalComponentServiceAbstraction,
|
||||||
LoginEmailService,
|
LoginEmailService,
|
||||||
PinServiceAbstraction,
|
PinServiceAbstraction,
|
||||||
|
SsoUrlService,
|
||||||
} from "@bitwarden/auth/common";
|
} from "@bitwarden/auth/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
@@ -378,6 +379,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
InternalUserDecryptionOptionsServiceAbstraction,
|
InternalUserDecryptionOptionsServiceAbstraction,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: SsoUrlService,
|
||||||
|
useClass: SsoUrlService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: LoginComponentService,
|
provide: LoginComponentService,
|
||||||
useClass: DesktopLoginComponentService,
|
useClass: DesktopLoginComponentService,
|
||||||
@@ -389,6 +395,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
SsoLoginServiceAbstraction,
|
SsoLoginServiceAbstraction,
|
||||||
I18nServiceAbstraction,
|
I18nServiceAbstraction,
|
||||||
ToastService,
|
ToastService,
|
||||||
|
SsoUrlService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { MockProxy, mock } from "jest-mock-extended";
|
|||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
|
|
||||||
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
|
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
|
||||||
|
import { SsoUrlService } from "@bitwarden/auth/common";
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import {
|
import {
|
||||||
Environment,
|
Environment,
|
||||||
@@ -41,8 +43,7 @@ describe("DesktopLoginComponentService", () => {
|
|||||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||||
let i18nService: MockProxy<I18nService>;
|
let i18nService: MockProxy<I18nService>;
|
||||||
let toastService: MockProxy<ToastService>;
|
let toastService: MockProxy<ToastService>;
|
||||||
|
let ssoUrlService: MockProxy<SsoUrlService>;
|
||||||
let superLaunchSsoBrowserWindowSpy: jest.SpyInstance;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
@@ -60,6 +61,8 @@ describe("DesktopLoginComponentService", () => {
|
|||||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||||
i18nService = mock<I18nService>();
|
i18nService = mock<I18nService>();
|
||||||
toastService = mock<ToastService>();
|
toastService = mock<ToastService>();
|
||||||
|
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||||
|
ssoUrlService = mock<SsoUrlService>();
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -74,6 +77,7 @@ describe("DesktopLoginComponentService", () => {
|
|||||||
ssoLoginService,
|
ssoLoginService,
|
||||||
i18nService,
|
i18nService,
|
||||||
toastService,
|
toastService,
|
||||||
|
ssoUrlService,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ provide: DefaultLoginComponentService, useExisting: DesktopLoginComponentService },
|
{ provide: DefaultLoginComponentService, useExisting: DesktopLoginComponentService },
|
||||||
@@ -84,15 +88,11 @@ describe("DesktopLoginComponentService", () => {
|
|||||||
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
|
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
|
||||||
{ provide: I18nService, useValue: i18nService },
|
{ provide: I18nService, useValue: i18nService },
|
||||||
{ provide: ToastService, useValue: toastService },
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
{ provide: SsoUrlService, useValue: ssoUrlService },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
service = TestBed.inject(DesktopLoginComponentService);
|
service = TestBed.inject(DesktopLoginComponentService);
|
||||||
|
|
||||||
superLaunchSsoBrowserWindowSpy = jest.spyOn(
|
|
||||||
DefaultLoginComponentService.prototype,
|
|
||||||
"launchSsoBrowserWindow",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -106,7 +106,7 @@ describe("DesktopLoginComponentService", () => {
|
|||||||
expect(service).toBeTruthy();
|
expect(service).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("launchSsoBrowserWindow", () => {
|
describe("redirectToSso", () => {
|
||||||
// Array of all permutations of isAppImage, isSnapStore, and isDev
|
// Array of all permutations of isAppImage, isSnapStore, and isDev
|
||||||
const permutations = [
|
const permutations = [
|
||||||
[true, false, false], // Case 1: isAppImage true
|
[true, false, false], // Case 1: isAppImage true
|
||||||
@@ -125,36 +125,27 @@ describe("DesktopLoginComponentService", () => {
|
|||||||
(global as any).ipc.platform.isSnapStore = isSnapStore;
|
(global as any).ipc.platform.isSnapStore = isSnapStore;
|
||||||
(global as any).ipc.platform.isDev = isDev;
|
(global as any).ipc.platform.isDev = isDev;
|
||||||
|
|
||||||
const email = "user@example.com";
|
const email = "test@bitwarden.com";
|
||||||
const clientId = "desktop";
|
|
||||||
const codeChallenge = "testCodeChallenge";
|
|
||||||
const codeVerifier = "testCodeVerifier";
|
|
||||||
const state = "testState";
|
const state = "testState";
|
||||||
const codeVerifierHash = new Uint8Array(64);
|
const codeVerifier = "testCodeVerifier";
|
||||||
|
const codeChallenge = "testCodeChallenge";
|
||||||
|
|
||||||
passwordGenerationService.generatePassword.mockResolvedValueOnce(state);
|
passwordGenerationService.generatePassword.mockResolvedValueOnce(state);
|
||||||
passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier);
|
passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier);
|
||||||
cryptoFunctionService.hash.mockResolvedValueOnce(codeVerifierHash);
|
|
||||||
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge);
|
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge);
|
||||||
|
|
||||||
await service.launchSsoBrowserWindow(email, clientId);
|
await service.redirectToSsoLogin(email);
|
||||||
|
|
||||||
if (isAppImage || isSnapStore || isDev) {
|
if (isAppImage || isSnapStore || isDev) {
|
||||||
expect(superLaunchSsoBrowserWindowSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Assert that the standard logic is executed
|
|
||||||
expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email);
|
|
||||||
expect(passwordGenerationService.generatePassword).toHaveBeenCalledTimes(2);
|
|
||||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith(codeVerifier, "sha256");
|
|
||||||
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state);
|
|
||||||
expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier);
|
|
||||||
expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith(
|
expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith(
|
||||||
codeChallenge,
|
codeChallenge,
|
||||||
state,
|
state,
|
||||||
|
email,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// If all values are false, expect the super method to be called
|
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state);
|
||||||
expect(superLaunchSsoBrowserWindowSpy).toHaveBeenCalledWith(email, clientId);
|
expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier);
|
||||||
|
expect(platformUtilsService.launchUri).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// 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 { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular";
|
import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular";
|
||||||
|
import { DESKTOP_SSO_CALLBACK, SsoUrlService } from "@bitwarden/auth/common";
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class DesktopLoginComponentService
|
|||||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected toastService: ToastService,
|
protected toastService: ToastService,
|
||||||
|
protected ssoUrlService: SsoUrlService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
cryptoFunctionService,
|
cryptoFunctionService,
|
||||||
@@ -33,38 +35,50 @@ export class DesktopLoginComponentService
|
|||||||
platformUtilsService,
|
platformUtilsService,
|
||||||
ssoLoginService,
|
ssoLoginService,
|
||||||
);
|
);
|
||||||
this.clientType = this.platformUtilsService.getClientType();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async launchSsoBrowserWindow(email: string, clientId: "desktop"): Promise<void | null> {
|
/**
|
||||||
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
|
* On the desktop, redirecting to the SSO login page is done via a new browser window, opened
|
||||||
return super.launchSsoBrowserWindow(email, clientId);
|
* to the SSO component on the web client.
|
||||||
|
* @param email the email of the user trying to log in, used to look up the org SSO identifier
|
||||||
|
* @param state the state that will be used to verify the SSO login, which needs to be passed to the IdP
|
||||||
|
* @param codeChallenge the challenge that will be verified after the code is returned from the IdP, which needs to be passed to the IdP
|
||||||
|
*/
|
||||||
|
protected override async redirectToSso(
|
||||||
|
email: string,
|
||||||
|
state: string,
|
||||||
|
codeChallenge: 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.isSnapStore || ipc.platform.isDev) {
|
||||||
|
await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge);
|
||||||
|
} else {
|
||||||
|
const env = await firstValueFrom(this.environmentService.environment$);
|
||||||
|
const webVaultUrl = env.getWebVaultUrl();
|
||||||
|
|
||||||
|
const redirectUri = DESKTOP_SSO_CALLBACK;
|
||||||
|
|
||||||
|
const ssoWebAppUrl = this.ssoUrlService.buildSsoUrl(
|
||||||
|
webVaultUrl,
|
||||||
|
this.clientType,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.platformUtilsService.launchUri(ssoWebAppUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save email for SSO
|
private async initiateSsoThroughLocalhostCallback(
|
||||||
await this.ssoLoginService.setSsoEmail(email);
|
email: string,
|
||||||
|
state: string,
|
||||||
// Generate SSO params
|
challenge: string,
|
||||||
const passwordOptions: any = {
|
): Promise<void> {
|
||||||
type: "password",
|
|
||||||
length: 64,
|
|
||||||
uppercase: true,
|
|
||||||
lowercase: true,
|
|
||||||
numbers: true,
|
|
||||||
special: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
|
||||||
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
|
||||||
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
|
|
||||||
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
|
||||||
|
|
||||||
// Save SSO params
|
|
||||||
await this.ssoLoginService.setSsoState(state);
|
|
||||||
await this.ssoLoginService.setCodeVerifier(codeVerifier);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state);
|
await ipc.platform.localhostCallbackService.openSsoPrompt(challenge, state, email);
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -220,9 +220,10 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
|
|||||||
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
|
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
|
||||||
return super.launchSsoBrowser(clientId, ssoRedirectUri);
|
return super.launchSsoBrowser(clientId, ssoRedirectUri);
|
||||||
}
|
}
|
||||||
|
const email = this.formGroup.controls.email.value;
|
||||||
|
|
||||||
// Save off email for SSO
|
// Save off email for SSO
|
||||||
await this.ssoLoginService.setSsoEmail(this.formGroup.controls.email.value);
|
await this.ssoLoginService.setSsoEmail(email);
|
||||||
|
|
||||||
// Generate necessary sso params
|
// Generate necessary sso params
|
||||||
const passwordOptions: any = {
|
const passwordOptions: any = {
|
||||||
@@ -243,7 +244,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
|
|||||||
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
|
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state);
|
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state, email);
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as path from "path";
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { Subject, firstValueFrom } from "rxjs";
|
import { Subject, firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { SsoUrlService } from "@bitwarden/auth/common";
|
||||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { ClientType } from "@bitwarden/common/enums";
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
@@ -66,6 +67,7 @@ export class Main {
|
|||||||
desktopSettingsService: DesktopSettingsService;
|
desktopSettingsService: DesktopSettingsService;
|
||||||
mainCryptoFunctionService: MainCryptoFunctionService;
|
mainCryptoFunctionService: MainCryptoFunctionService;
|
||||||
migrationRunner: MigrationRunner;
|
migrationRunner: MigrationRunner;
|
||||||
|
ssoUrlService: SsoUrlService;
|
||||||
|
|
||||||
windowMain: WindowMain;
|
windowMain: WindowMain;
|
||||||
messagingMain: MessagingMain;
|
messagingMain: MessagingMain;
|
||||||
@@ -261,7 +263,13 @@ export class Main {
|
|||||||
this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService);
|
this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService);
|
||||||
|
|
||||||
new EphemeralValueStorageService();
|
new EphemeralValueStorageService();
|
||||||
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
|
||||||
|
this.ssoUrlService = new SsoUrlService();
|
||||||
|
new SSOLocalhostCallbackService(
|
||||||
|
this.environmentService,
|
||||||
|
this.messagingService,
|
||||||
|
this.ssoUrlService,
|
||||||
|
);
|
||||||
|
|
||||||
this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain);
|
this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain);
|
||||||
void this.nativeAutofillMain.init();
|
void this.nativeAutofillMain.init();
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ const ephemeralStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const localhostCallbackService = {
|
const localhostCallbackService = {
|
||||||
openSsoPrompt: (codeChallenge: string, state: string): Promise<void> => {
|
openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise<void> => {
|
||||||
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state });
|
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import * as http from "http";
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { SsoUrlService } from "@bitwarden/auth/common";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||||
|
|
||||||
@@ -18,9 +20,10 @@ export class SSOLocalhostCallbackService {
|
|||||||
constructor(
|
constructor(
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private messagingService: MessageSender,
|
private messagingService: MessageSender,
|
||||||
|
private ssoUrlService: SsoUrlService,
|
||||||
) {
|
) {
|
||||||
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state }) => {
|
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => {
|
||||||
const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state);
|
const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email);
|
||||||
this.messagingService.send("ssoCallback", {
|
this.messagingService.send("ssoCallback", {
|
||||||
code: ssoCode,
|
code: ssoCode,
|
||||||
state: recvState,
|
state: recvState,
|
||||||
@@ -32,6 +35,7 @@ export class SSOLocalhostCallbackService {
|
|||||||
private async openSsoPrompt(
|
private async openSsoPrompt(
|
||||||
codeChallenge: string,
|
codeChallenge: string,
|
||||||
state: string,
|
state: string,
|
||||||
|
email: 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$);
|
||||||
|
|
||||||
@@ -78,18 +82,17 @@ export class SSOLocalhostCallbackService {
|
|||||||
for (let port = 8065; port <= 8070; port++) {
|
for (let port = 8065; port <= 8070; port++) {
|
||||||
try {
|
try {
|
||||||
this.ssoRedirectUri = "http://localhost:" + port;
|
this.ssoRedirectUri = "http://localhost:" + port;
|
||||||
|
const ssoUrl = this.ssoUrlService.buildSsoUrl(
|
||||||
|
webUrl,
|
||||||
|
ClientType.Desktop,
|
||||||
|
this.ssoRedirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
callbackServer.listen(port, () => {
|
callbackServer.listen(port, () => {
|
||||||
this.messagingService.send("launchUri", {
|
this.messagingService.send("launchUri", {
|
||||||
url:
|
url: ssoUrl,
|
||||||
webUrl +
|
|
||||||
"/#/sso?clientId=" +
|
|
||||||
"desktop" +
|
|
||||||
"&redirectUri=" +
|
|
||||||
encodeURIComponent(this.ssoRedirectUri) +
|
|
||||||
"&state=" +
|
|
||||||
state +
|
|
||||||
"&codeChallenge=" +
|
|
||||||
codeChallenge,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
foundPort = true;
|
foundPort = true;
|
||||||
@@ -112,15 +115,6 @@ export class SSOLocalhostCallbackService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrgIdentifierFromState(state: string): string {
|
|
||||||
if (state === null || state === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateSplit = state.split("_identifier=");
|
|
||||||
return stateSplit.length > 1 ? stateSplit[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkState(state: string, checkState: string): boolean {
|
private checkState(state: string, checkState: string): boolean {
|
||||||
if (state === null || state === undefined) {
|
if (state === null || state === undefined) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,6 +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 { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@ export class WebLoginComponentService
|
|||||||
passwordGenerationService: PasswordGenerationServiceAbstraction,
|
passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||||
platformUtilsService: PlatformUtilsService,
|
platformUtilsService: PlatformUtilsService,
|
||||||
ssoLoginService: SsoLoginServiceAbstraction,
|
ssoLoginService: SsoLoginServiceAbstraction,
|
||||||
|
private router: Router,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
cryptoFunctionService,
|
cryptoFunctionService,
|
||||||
@@ -45,7 +47,20 @@ export class WebLoginComponentService
|
|||||||
platformUtilsService,
|
platformUtilsService,
|
||||||
ssoLoginService,
|
ssoLoginService,
|
||||||
);
|
);
|
||||||
this.clientType = this.platformUtilsService.getClientType();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the web client, redirecting to the SSO component is done via the router.
|
||||||
|
* We do not need to provide email, state, or code challenge since those are set in state
|
||||||
|
* or generated on the SSO component.
|
||||||
|
*/
|
||||||
|
protected override async redirectToSso(
|
||||||
|
email: string,
|
||||||
|
state: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.router.navigate(["/sso"]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | null> {
|
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | null> {
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
PasswordGenerationServiceAbstraction,
|
PasswordGenerationServiceAbstraction,
|
||||||
PlatformUtilsService,
|
PlatformUtilsService,
|
||||||
SsoLoginServiceAbstraction,
|
SsoLoginServiceAbstraction,
|
||||||
|
Router,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ window.addEventListener("load", () => {
|
|||||||
} else if (state != null && state.includes(":clientId=browser")) {
|
} else if (state != null && state.includes(":clientId=browser")) {
|
||||||
initiateBrowserSso(code, state, false);
|
initiateBrowserSso(code, state, false);
|
||||||
} else {
|
} else {
|
||||||
window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state;
|
initiateWebAppSso(code, state);
|
||||||
// Match any characters between "_returnUri='" and the next "'"
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initiateWebAppSso(code: string, state: string) {
|
||||||
|
// If we've initiated SSO from somewhere other than the SSO component on the web app, the SSO component will add
|
||||||
|
// a _returnUri to the state variable. Here we're extracting that URI and sending the user there instead of to the SSO component.
|
||||||
const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')");
|
const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')");
|
||||||
if (returnUri) {
|
if (returnUri) {
|
||||||
window.location.href = window.location.origin + `/#${returnUri}`;
|
window.location.href = window.location.origin + `/#${returnUri}`;
|
||||||
@@ -25,7 +30,6 @@ window.addEventListener("load", () => {
|
|||||||
window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state;
|
window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
|
function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
|
||||||
window.postMessage({ command: "authResult", code: code, state: state, lastpass: lastpass }, "*");
|
window.postMessage({ command: "authResult", code: code, state: state, lastpass: lastpass }, "*");
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ export class LoginComponentV1 extends CaptchaProtectedComponent implements OnIni
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async saveEmailSettings() {
|
protected async saveEmailSettings() {
|
||||||
|
// Save off email for SSO
|
||||||
|
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
|
||||||
|
|
||||||
this.loginEmailService.setLoginEmail(this.formGroup.value.email);
|
this.loginEmailService.setLoginEmail(this.formGroup.value.email);
|
||||||
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
|
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
|
||||||
await this.loginEmailService.saveEmailSettings();
|
await this.loginEmailService.saveEmailSettings();
|
||||||
|
|||||||
@@ -68,50 +68,21 @@ describe("DefaultLoginComponentService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("launchSsoBrowserWindow", () => {
|
describe("redirectToSsoLogin", () => {
|
||||||
|
it("sets the pre-SSO state", async () => {
|
||||||
const email = "test@bitwarden.com";
|
const email = "test@bitwarden.com";
|
||||||
let state = "testState";
|
const state = "testState";
|
||||||
const codeVerifier = "testCodeVerifier";
|
const codeVerifier = "testCodeVerifier";
|
||||||
const codeChallenge = "testCodeChallenge";
|
const codeChallenge = "testCodeChallenge";
|
||||||
const baseUrl = "https://webvault.bitwarden.com/#/sso";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state = "testState";
|
|
||||||
|
|
||||||
passwordGenerationService.generatePassword.mockResolvedValueOnce(state);
|
passwordGenerationService.generatePassword.mockResolvedValueOnce(state);
|
||||||
passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier);
|
passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier);
|
||||||
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge);
|
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge);
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
clientType: ClientType.Browser,
|
|
||||||
clientId: "browser",
|
|
||||||
expectedRedirectUri: "https://webvault.bitwarden.com/sso-connector.html",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
clientType: ClientType.Desktop,
|
|
||||||
clientId: "desktop",
|
|
||||||
expectedRedirectUri: "bitwarden://sso-callback",
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"launches SSO browser window with correct URL for $clientId client",
|
|
||||||
async ({ clientType, clientId, expectedRedirectUri }) => {
|
|
||||||
service["clientType"] = clientType;
|
|
||||||
|
|
||||||
await service.launchSsoBrowserWindow(email, clientId as "browser" | "desktop");
|
|
||||||
|
|
||||||
if (clientType === ClientType.Browser) {
|
|
||||||
state += ":clientId=browser";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedUrl = `${baseUrl}?clientId=${clientId}&redirectUri=${encodeURIComponent(expectedRedirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`;
|
|
||||||
|
|
||||||
|
await service.redirectToSsoLogin(email);
|
||||||
expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email);
|
expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email);
|
||||||
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state);
|
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state);
|
||||||
expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier);
|
expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier);
|
||||||
expect(platformUtilsService.launchUri).toHaveBeenCalledWith(expectedUrl);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// 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 { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { LoginComponentService } from "@bitwarden/auth/angular";
|
import { LoginComponentService } from "@bitwarden/auth/angular";
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
import { ClientType } from "@bitwarden/common/enums";
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
@@ -21,19 +19,55 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
|||||||
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
|
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||||
protected platformUtilsService: PlatformUtilsService,
|
protected platformUtilsService: PlatformUtilsService,
|
||||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||||
) {}
|
) {
|
||||||
|
this.clientType = this.platformUtilsService.getClientType();
|
||||||
|
}
|
||||||
|
|
||||||
isLoginWithPasskeySupported(): boolean {
|
isLoginWithPasskeySupported(): boolean {
|
||||||
return this.clientType === ClientType.Web;
|
return this.clientType === ClientType.Web;
|
||||||
}
|
}
|
||||||
|
|
||||||
async launchSsoBrowserWindow(
|
/**
|
||||||
email: string,
|
* Redirects the user to the SSO login page, either via route or in a new browser window.
|
||||||
clientId: "browser" | "desktop",
|
* @param email The email address of the user attempting to log in
|
||||||
): Promise<void | null> {
|
*/
|
||||||
// Save email for SSO
|
async redirectToSsoLogin(email: string): Promise<void | null> {
|
||||||
|
// Set the state that we'll need to verify the SSO login when we get the code back
|
||||||
|
const [state, codeChallenge] = await this.setSsoPreLoginState();
|
||||||
|
|
||||||
|
// Set the email address in state. This is used in 2 places:
|
||||||
|
// 1. On the web client, on the SSO component we need the email address to look up
|
||||||
|
// the org SSO identifier. The email address is passed via query param for the other clients.
|
||||||
|
// 2. On all clients, after authentication on the originating client the SSO component
|
||||||
|
// will need to look up 2FA Remember token by email.
|
||||||
await this.ssoLoginService.setSsoEmail(email);
|
await this.ssoLoginService.setSsoEmail(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op implementation of redirectToSso
|
||||||
|
*/
|
||||||
|
protected async redirectToSso(
|
||||||
|
email: string,
|
||||||
|
state: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op implementation of showBackButton
|
||||||
|
*/
|
||||||
|
showBackButton(showBackButton: boolean): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the state required for verifying SSO login after completion
|
||||||
|
*/
|
||||||
|
private async setSsoPreLoginState(): Promise<[string, string]> {
|
||||||
// Generate SSO params
|
// Generate SSO params
|
||||||
const passwordOptions: any = {
|
const passwordOptions: any = {
|
||||||
type: "password",
|
type: "password",
|
||||||
@@ -46,8 +80,8 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
|||||||
|
|
||||||
let state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
let state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
|
|
||||||
if (clientId === "browser") {
|
// For the browser extension, we persist the clientId on state so that it will be included after SSO in the callback
|
||||||
// Need to persist the clientId in the state for the extension
|
if (this.clientType === ClientType.Browser) {
|
||||||
state += ":clientId=browser";
|
state += ":clientId=browser";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,35 +93,6 @@ 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);
|
||||||
|
|
||||||
// Build URL
|
return [state, codeChallenge];
|
||||||
const env = await firstValueFrom(this.environmentService.environment$);
|
|
||||||
const webVaultUrl = env.getWebVaultUrl();
|
|
||||||
|
|
||||||
const redirectUri =
|
|
||||||
clientId === "browser"
|
|
||||||
? webVaultUrl + "/sso-connector.html" // Browser
|
|
||||||
: "bitwarden://sso-callback"; // Desktop
|
|
||||||
|
|
||||||
// Launch browser window with URL
|
|
||||||
this.platformUtilsService.launchUri(
|
|
||||||
webVaultUrl +
|
|
||||||
"/#/sso?clientId=" +
|
|
||||||
clientId +
|
|
||||||
"&redirectUri=" +
|
|
||||||
encodeURIComponent(redirectUri) +
|
|
||||||
"&state=" +
|
|
||||||
state +
|
|
||||||
"&codeChallenge=" +
|
|
||||||
codeChallenge +
|
|
||||||
"&email=" +
|
|
||||||
encodeURIComponent(email),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No-op implementation of showBackButton
|
|
||||||
*/
|
|
||||||
showBackButton(showBackButton: boolean): void {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ export abstract class LoginComponentService {
|
|||||||
isLoginWithPasskeySupported: () => boolean;
|
isLoginWithPasskeySupported: () => boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launches the SSO flow in a new browser window.
|
* Redirects the user to the SSO login page, either via route or in a new browser window.
|
||||||
* - Used by: Browser, Desktop
|
|
||||||
*/
|
*/
|
||||||
launchSsoBrowserWindow: (email: string, clientId: "browser" | "desktop") => Promise<void>;
|
redirectToSsoLogin: (email: string) => Promise<void | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the back button.
|
* Shows the back button.
|
||||||
|
|||||||
@@ -318,15 +318,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async launchSsoBrowserWindow(clientId: "browser" | "desktop"): Promise<void> {
|
|
||||||
const email = this.emailFormControl.value;
|
|
||||||
if (!email) {
|
|
||||||
this.logService.error("Email is required for SSO login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.loginComponentService.launchSsoBrowserWindow(email, clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the master password meets the enforced policy requirements
|
* Checks if the master password meets the enforced policy requirements
|
||||||
* and if the user is required to change their password.
|
* and if the user is required to change their password.
|
||||||
@@ -636,26 +627,25 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the SSO button click.
|
* Handle the SSO button click.
|
||||||
* @param event - The event object.
|
|
||||||
*/
|
*/
|
||||||
async handleSsoClick() {
|
async handleSsoClick() {
|
||||||
const isEmailValid = await this.validateEmail();
|
// Make sure the email is not empty, for type safety
|
||||||
|
const email = this.formGroup.value.email;
|
||||||
|
if (!email) {
|
||||||
|
this.logService.error("Email is required for SSO");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the email is valid
|
||||||
|
const isEmailValid = await this.validateEmail();
|
||||||
if (!isEmailValid) {
|
if (!isEmailValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the email configuration for the login component
|
||||||
await this.saveEmailSettings();
|
await this.saveEmailSettings();
|
||||||
|
|
||||||
if (this.clientType === ClientType.Web) {
|
// Send the user to SSO, either through routing or through redirecting to the web app
|
||||||
await this.router.navigate(["/sso"], {
|
await this.loginComponentService.redirectToSsoLogin(email);
|
||||||
queryParams: { email: this.formGroup.value.email },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.launchSsoBrowserWindow(
|
|
||||||
this.clientType === ClientType.Browser ? "browser" : "desktop",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export class SsoComponent implements OnInit {
|
|||||||
protected state: string | undefined;
|
protected state: string | undefined;
|
||||||
protected codeChallenge: string | undefined;
|
protected codeChallenge: string | undefined;
|
||||||
protected clientId: SsoClientType | undefined;
|
protected clientId: SsoClientType | undefined;
|
||||||
|
protected email: string | null | undefined;
|
||||||
|
|
||||||
formPromise: Promise<AuthResult> | undefined;
|
formPromise: Promise<AuthResult> | undefined;
|
||||||
initiateSsoFormPromise: Promise<SsoPreValidateResponse> | undefined;
|
initiateSsoFormPromise: Promise<SsoPreValidateResponse> | undefined;
|
||||||
@@ -129,38 +130,58 @@ export class SsoComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like several components in our app (e.g. our invite acceptance components), the SSO component is engaged both
|
||||||
|
* before and after the user authenticates.
|
||||||
|
* Flow 1: Initialize SSO state and redirect to IdP
|
||||||
|
* - We can get here several ways:
|
||||||
|
* - The user is on the web client and is routed here
|
||||||
|
* - The user is on a different client and is redirected by opening a new browser window, passing query params
|
||||||
|
* - A customer integration has been set up to direct users to the `/sso` route to initiate SSO with an identifier
|
||||||
|
* Flow 2: Handle callback from IdP and verify the state that was set pre-authentication
|
||||||
|
*/
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const qParams: QueryParams = await firstValueFrom(this.route.queryParams);
|
const qParams: QueryParams = await firstValueFrom(this.route.queryParams);
|
||||||
|
|
||||||
// This if statement will pass on the second portion of the SSO flow
|
// SSO on web uses a service to provide the email via state that's set on login,
|
||||||
|
// but because we have clients that delegate SSO to web we have to accept the email in the query params as well.
|
||||||
|
// We also can't require the email, because it isn't provided in the CLI SSO flow.
|
||||||
|
this.email = qParams.email ?? (await this.ssoLoginService.getSsoEmail());
|
||||||
|
|
||||||
|
// Detect if we are on the second portion of the SSO flow,
|
||||||
// where the user has already authenticated with the identity provider
|
// where the user has already authenticated with the identity provider
|
||||||
if (this.hasCodeOrStateParams(qParams)) {
|
if (this.userCompletedSsoAuthentication(qParams)) {
|
||||||
await this.handleCodeAndStateParams(qParams);
|
await this.handleTokenRequestForAuthenticatedUser(qParams);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This if statement will pass on the first portion of the SSO flow
|
// Detect if we are on the first portion of the SSO flow
|
||||||
if (this.hasRequiredSsoParams(qParams)) {
|
// and have been sent here from another client with the info in query params
|
||||||
this.setRequiredSsoVariables(qParams);
|
if (this.hasParametersFromOtherClientRedirect(qParams)) {
|
||||||
|
this.initializeFromRedirectFromOtherClient(qParams);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect if we have landed here but only have an SSO identifier in the URL.
|
||||||
|
// This is used by integrations that want to "short-circuit" the login to send users
|
||||||
|
// directly to their IdP to simulate IdP-initiated SSO, so we submit automatically.
|
||||||
if (qParams.identifier != null) {
|
if (qParams.identifier != null) {
|
||||||
// SSO Org Identifier in query params takes precedence over claimed domains
|
|
||||||
this.identifierFormControl.setValue(qParams.identifier);
|
this.identifierFormControl.setValue(qParams.identifier);
|
||||||
this.loggingIn = true;
|
this.loggingIn = true;
|
||||||
await this.submit();
|
await this.submit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.initializeIdentifierFromEmailOrStorage(qParams);
|
// If we're routed here with no additional parameters, we'll try to determine the
|
||||||
|
// identifier using claimed domain or local state saved from their last attempt.
|
||||||
|
await this.initializeIdentifierFromEmailOrStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the required SSO variables from the query params
|
* Sets the required SSO variables from the query params
|
||||||
* @param qParams - The query params
|
* @param qParams - The query params
|
||||||
*/
|
*/
|
||||||
private setRequiredSsoVariables(qParams: QueryParams): void {
|
private initializeFromRedirectFromOtherClient(qParams: QueryParams): void {
|
||||||
this.redirectUri = qParams.redirectUri ?? "";
|
this.redirectUri = qParams.redirectUri ?? "";
|
||||||
this.state = qParams.state ?? "";
|
this.state = qParams.state ?? "";
|
||||||
this.codeChallenge = qParams.codeChallenge ?? "";
|
this.codeChallenge = qParams.codeChallenge ?? "";
|
||||||
@@ -182,11 +203,16 @@ export class SsoComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the query params have the required SSO params
|
* Checks if the query params have the required SSO params to initiate SSO
|
||||||
|
* * The query params presented here are:
|
||||||
|
* - clientId: The client type (e.g. web, browser, desktop)
|
||||||
|
* - redirectUri: The URI to redirect to after authentication
|
||||||
|
* - state: The state to verify on the client after authentication
|
||||||
|
* - codeChallenge: The PKCE code challenge that is sent up when authenticating with the IdP
|
||||||
* @param qParams - The query params
|
* @param qParams - The query params
|
||||||
* @returns True if the query params have the required SSO params, false otherwise
|
* @returns True if the query params have the required SSO params, false otherwise
|
||||||
*/
|
*/
|
||||||
private hasRequiredSsoParams(qParams: QueryParams): boolean {
|
private hasParametersFromOtherClientRedirect(qParams: QueryParams): boolean {
|
||||||
return (
|
return (
|
||||||
qParams.clientId != null &&
|
qParams.clientId != null &&
|
||||||
qParams.redirectUri != null &&
|
qParams.redirectUri != null &&
|
||||||
@@ -196,12 +222,18 @@ export class SsoComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the code and state params
|
* Handles the case in which the user has completed SSO authentication, has a code
|
||||||
|
* and has been redirected back to the SSO component to exchange the code for a token.
|
||||||
|
* This will be on the client originating the SSO request, not always the web client, as that
|
||||||
|
* is where the state and verifier are stored.
|
||||||
* @param qParams - The query params
|
* @param qParams - The query params
|
||||||
*/
|
*/
|
||||||
private async handleCodeAndStateParams(qParams: QueryParams): Promise<void> {
|
private async handleTokenRequestForAuthenticatedUser(qParams: QueryParams): Promise<void> {
|
||||||
|
// We set these in state prior to starting SSO, so we can retrieve them here
|
||||||
const codeVerifier = await this.ssoLoginService.getCodeVerifier();
|
const codeVerifier = await this.ssoLoginService.getCodeVerifier();
|
||||||
const state = await this.ssoLoginService.getSsoState();
|
const stateFromPrelogin = await this.ssoLoginService.getSsoState();
|
||||||
|
|
||||||
|
// Reset the code verifier and state so we don't accidentally use them again
|
||||||
await this.ssoLoginService.setCodeVerifier("");
|
await this.ssoLoginService.setCodeVerifier("");
|
||||||
await this.ssoLoginService.setSsoState("");
|
await this.ssoLoginService.setSsoState("");
|
||||||
|
|
||||||
@@ -209,11 +241,13 @@ export class SsoComponent implements OnInit {
|
|||||||
this.redirectUri = qParams.redirectUri;
|
this.redirectUri = qParams.redirectUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify that the state matches the state we set prior to starting SSO.
|
||||||
|
// If it does, we can proceed with exchanging the code for a token.
|
||||||
if (
|
if (
|
||||||
qParams.code != null &&
|
qParams.code != null &&
|
||||||
codeVerifier != null &&
|
codeVerifier != null &&
|
||||||
state != null &&
|
stateFromPrelogin != null &&
|
||||||
this.checkState(state, qParams.state ?? "")
|
this.verifyStateMatches(stateFromPrelogin, qParams.state ?? "")
|
||||||
) {
|
) {
|
||||||
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state ?? "");
|
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state ?? "");
|
||||||
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
|
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
|
||||||
@@ -221,11 +255,12 @@ export class SsoComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the query params have a code or state
|
* Checks if the query params have a code and state, indicating that we've completed SSO authentication
|
||||||
|
* and have been redirected back to the SSO component on the originating client to complete login.
|
||||||
* @param qParams - The query params
|
* @param qParams - The query params
|
||||||
* @returns True if the query params have a code or state, false otherwise
|
* @returns True if the query params have a code and state, false otherwise
|
||||||
*/
|
*/
|
||||||
private hasCodeOrStateParams(qParams: QueryParams): boolean {
|
private userCompletedSsoAuthentication(qParams: QueryParams): boolean {
|
||||||
return qParams.code != null && qParams.state != null;
|
return qParams.code != null && qParams.state != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +300,11 @@ export class SsoComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects the user to `/connect/authorize` on IdentityServer to begin SSO.
|
||||||
|
* @param returnUri - The URI to redirect to after authentication (used to link user to SSO)
|
||||||
|
* @param includeUserIdentifier - Whether to include the user identifier in the request (used to link user to SSO)
|
||||||
|
*/
|
||||||
private async submitSso(returnUri?: string, includeUserIdentifier?: boolean) {
|
private async submitSso(returnUri?: string, includeUserIdentifier?: boolean) {
|
||||||
if (this.identifier == null || this.identifier === "") {
|
if (this.identifier == null || this.identifier === "") {
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
@@ -307,6 +347,9 @@ export class SsoComponent implements OnInit {
|
|||||||
special: false,
|
special: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize the challenge and state if they aren't passed in. If we're performing SSO initiated on a
|
||||||
|
// different client, they'll be passed in, as they will need to be verified on that client and not the web.
|
||||||
|
// If they're not passed in, then we need to set them here on the web client to be verified here after SSO.
|
||||||
if (codeChallenge == null) {
|
if (codeChallenge == null) {
|
||||||
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
|
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
|
||||||
@@ -316,15 +359,20 @@ export class SsoComponent implements OnInit {
|
|||||||
|
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a returnUri, add it to the state parameter. This will be used after SSO
|
||||||
|
// is complete, on the sso-connector, in order to route the user somewhere other than the SSO component.
|
||||||
if (returnUri) {
|
if (returnUri) {
|
||||||
state += `_returnUri='${returnUri}'`;
|
state += `_returnUri='${returnUri}'`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Add Organization Identifier to state
|
// Add Organization Identifier to state
|
||||||
state += `_identifier=${this.identifier}`;
|
state += `_identifier=${this.identifier}`;
|
||||||
|
|
||||||
// Save state (regardless of new or existing)
|
// Save the pre-SSO state.
|
||||||
|
// We need to do this here as even if it was generated on the intiating client (e.g. browser, desktop),
|
||||||
|
// we need it on the web client to verify after the user authenticates with the identity provider and is redirected back.
|
||||||
await this.ssoLoginService.setSsoState(state);
|
await this.ssoLoginService.setSsoState(state);
|
||||||
|
|
||||||
const env = await firstValueFrom(this.environmentService.environment$);
|
const env = await firstValueFrom(this.environmentService.environment$);
|
||||||
@@ -349,6 +397,8 @@ export class SsoComponent implements OnInit {
|
|||||||
"&ssoToken=" +
|
"&ssoToken=" +
|
||||||
encodeURIComponent(token ?? "");
|
encodeURIComponent(token ?? "");
|
||||||
|
|
||||||
|
// If we're linking a user to SSO, we need to provide a user identifier that will be passed
|
||||||
|
// on to the SSO provider so that after SSO we can link the user to the SSO identity.
|
||||||
if (includeUserIdentifier) {
|
if (includeUserIdentifier) {
|
||||||
const userIdentifier = await this.apiService.getSsoUserIdentifier();
|
const userIdentifier = await this.apiService.getSsoUserIdentifier();
|
||||||
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
|
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
|
||||||
@@ -357,17 +407,23 @@ export class SsoComponent implements OnInit {
|
|||||||
return authorizeUrl;
|
return authorizeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We are using the Auth Code + PKCE flow.
|
||||||
|
* We have received the code from IdentityServer, which we will now present with the code verifier to get a token.
|
||||||
|
*/
|
||||||
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
|
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
|
||||||
this.loggingIn = true;
|
this.loggingIn = true;
|
||||||
try {
|
try {
|
||||||
const email = await this.ssoLoginService.getSsoEmail();
|
// The code verifier is used to ensure that the client presenting the code is the same one that initiated the authentication request.
|
||||||
|
// The redirect URI is also supplied on the request to the token endpoint, so the server can ensure it matches the original request
|
||||||
|
// for the code and prevent authorization code injection attacks.
|
||||||
const redirectUri = this.redirectUri ?? "";
|
const redirectUri = this.redirectUri ?? "";
|
||||||
const credentials = new SsoLoginCredentials(
|
const credentials = new SsoLoginCredentials(
|
||||||
code,
|
code,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
orgSsoIdentifier,
|
orgSsoIdentifier,
|
||||||
email ?? undefined,
|
this.email ?? undefined,
|
||||||
);
|
);
|
||||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||||
const authResult = await this.formPromise;
|
const authResult = await this.formPromise;
|
||||||
@@ -524,16 +580,22 @@ export class SsoComponent implements OnInit {
|
|||||||
return stateSplit.length > 1 ? stateSplit[1] : "";
|
return stateSplit.length > 1 ? stateSplit[1] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkState(state: string, checkState: string): boolean {
|
/**
|
||||||
if (state === null || state === undefined) {
|
* Checks if the state matches the checkState
|
||||||
|
* @param originalStateValue - The state to check
|
||||||
|
* @param stateValueToCheck - The state to check against
|
||||||
|
* @returns True if the state matches the checkState, false otherwise
|
||||||
|
*/
|
||||||
|
private verifyStateMatches(originalStateValue: string, stateValueToCheck: string): boolean {
|
||||||
|
if (originalStateValue === null || originalStateValue === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (checkState === null || checkState === undefined) {
|
if (stateValueToCheck === null || stateValueToCheck === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateSplit = state.split("_identifier=");
|
const stateSplit = originalStateValue.split("_identifier=");
|
||||||
const checkStateSplit = checkState.split("_identifier=");
|
const checkStateSplit = stateValueToCheck.split("_identifier=");
|
||||||
return stateSplit[0] === checkStateSplit[0];
|
return stateSplit[0] === checkStateSplit[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,17 +603,16 @@ export class SsoComponent implements OnInit {
|
|||||||
* Attempts to initialize the SSO identifier from email or storage.
|
* Attempts to initialize the SSO identifier from email or storage.
|
||||||
* Note: this flow is written for web but both browser and desktop
|
* Note: this flow is written for web but both browser and desktop
|
||||||
* redirect here on SSO button click.
|
* redirect here on SSO button click.
|
||||||
* @param qParams - The query params
|
|
||||||
*/
|
*/
|
||||||
private async initializeIdentifierFromEmailOrStorage(qParams: QueryParams): Promise<void> {
|
private async initializeIdentifierFromEmailOrStorage(): Promise<void> {
|
||||||
// Check if email matches any claimed domains
|
if (this.email) {
|
||||||
if (qParams.email) {
|
|
||||||
// show loading spinner
|
// show loading spinner
|
||||||
this.loggingIn = true;
|
this.loggingIn = true;
|
||||||
try {
|
try {
|
||||||
|
// Check if email matches any claimed domains
|
||||||
if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) {
|
if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) {
|
||||||
const response: ListResponse<VerifiedOrganizationDomainSsoDetailsResponse> =
|
const response: ListResponse<VerifiedOrganizationDomainSsoDetailsResponse> =
|
||||||
await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email);
|
await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(this.email);
|
||||||
|
|
||||||
if (response.data.length > 0) {
|
if (response.data.length > 0) {
|
||||||
this.identifierFormControl.setValue(response.data[0].organizationIdentifier);
|
this.identifierFormControl.setValue(response.data[0].organizationIdentifier);
|
||||||
@@ -560,7 +621,7 @@ export class SsoComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const response: OrganizationDomainSsoDetailsResponse =
|
const response: OrganizationDomainSsoDetailsResponse =
|
||||||
await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email);
|
await this.orgDomainApiService.getClaimedOrgDomainByEmail(this.email);
|
||||||
|
|
||||||
if (response?.ssoAvailable && response?.verifiedDate) {
|
if (response?.ssoAvailable && response?.verifiedDate) {
|
||||||
this.identifierFormControl.setValue(response.organizationIdentifier);
|
this.identifierFormControl.setValue(response.organizationIdentifier);
|
||||||
@@ -575,7 +636,8 @@ export class SsoComponent implements OnInit {
|
|||||||
this.loggingIn = false;
|
this.loggingIn = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to state svc if domain is unclaimed
|
// If we don't find a claimed domain, check to see if we stored an identifier in state
|
||||||
|
// from their last attrempt to login via SSO. If so, we'll populate the field, but not submit.
|
||||||
const storedIdentifier = await this.ssoLoginService.getOrganizationSsoIdentifier();
|
const storedIdentifier = await this.ssoLoginService.getOrganizationSsoIdentifier();
|
||||||
if (storedIdentifier != null) {
|
if (storedIdentifier != null) {
|
||||||
this.identifierFormControl.setValue(storedIdentifier);
|
this.identifierFormControl.setValue(storedIdentifier);
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from "./auth-request/auth-request.service";
|
|||||||
export * from "./auth-request/auth-request-api.service";
|
export * from "./auth-request/auth-request-api.service";
|
||||||
export * from "./accounts/lock.service";
|
export * from "./accounts/lock.service";
|
||||||
export * from "./login-success-handler/default-login-success-handler.service";
|
export * from "./login-success-handler/default-login-success-handler.service";
|
||||||
|
export * from "./sso-redirect/sso-url.service";
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
|
|
||||||
|
import { DESKTOP_SSO_CALLBACK, SsoUrlService } from "./sso-url.service";
|
||||||
|
|
||||||
|
describe("SsoUrlService", () => {
|
||||||
|
let service: SsoUrlService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new SsoUrlService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build Desktop SSO URL correctly", () => {
|
||||||
|
const baseUrl = "https://web-vault.bitwarden.com";
|
||||||
|
const clientType = ClientType.Desktop;
|
||||||
|
const redirectUri = DESKTOP_SSO_CALLBACK;
|
||||||
|
const state = "abc123";
|
||||||
|
const codeChallenge = "xyz789";
|
||||||
|
const email = "test@bitwarden.com";
|
||||||
|
|
||||||
|
const expectedUrl = `${baseUrl}/#/sso?clientId=desktop&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
const result = service.buildSsoUrl(
|
||||||
|
baseUrl,
|
||||||
|
clientType,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
expect(result).toBe(expectedUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build Desktop localhost callback SSO URL correctly", () => {
|
||||||
|
const baseUrl = "https://web-vault.bitwarden.com";
|
||||||
|
const clientType = ClientType.Desktop;
|
||||||
|
const redirectUri = `https://localhost:1000`;
|
||||||
|
const state = "abc123";
|
||||||
|
const codeChallenge = "xyz789";
|
||||||
|
const email = "test@bitwarden.com";
|
||||||
|
|
||||||
|
const expectedUrl = `${baseUrl}/#/sso?clientId=desktop&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
const result = service.buildSsoUrl(
|
||||||
|
baseUrl,
|
||||||
|
clientType,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
expect(result).toBe(expectedUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build Extension SSO URL correctly", () => {
|
||||||
|
const baseUrl = "https://web-vault.bitwarden.com";
|
||||||
|
const clientType = ClientType.Browser;
|
||||||
|
const redirectUri = baseUrl + "/sso-connector.html";
|
||||||
|
const state = "abc123";
|
||||||
|
const codeChallenge = "xyz789";
|
||||||
|
const email = "test@bitwarden.com";
|
||||||
|
|
||||||
|
const expectedUrl = `${baseUrl}/#/sso?clientId=browser&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
const result = service.buildSsoUrl(
|
||||||
|
baseUrl,
|
||||||
|
clientType,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
expect(result).toBe(expectedUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build CLI SSO URL correctly", () => {
|
||||||
|
const baseUrl = "https://web-vault.bitwarden.com";
|
||||||
|
const clientType = ClientType.Cli;
|
||||||
|
const redirectUri = "https://localhost:1000";
|
||||||
|
const state = "abc123";
|
||||||
|
const codeChallenge = "xyz789";
|
||||||
|
const email = "test@bitwarden.com";
|
||||||
|
|
||||||
|
const expectedUrl = `${baseUrl}/#/sso?clientId=cli&redirectUri=${encodeURIComponent(redirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
const result = service.buildSsoUrl(
|
||||||
|
baseUrl,
|
||||||
|
clientType,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
expect(result).toBe(expectedUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
|
|
||||||
|
export const DESKTOP_SSO_CALLBACK: string = "bitwarden://sso-callback";
|
||||||
|
|
||||||
|
export class SsoUrlService {
|
||||||
|
/**
|
||||||
|
* Builds a URL for redirecting users to the web app SSO component to complete SSO
|
||||||
|
* @param webAppUrl The URL of the web app
|
||||||
|
* @param clientType The client type that is initiating SSO, which will drive how the response is handled
|
||||||
|
* @param redirectUri The redirect URI or callback that will receive the SSO code after authentication
|
||||||
|
* @param state A state value that will be peristed through the SSO flow
|
||||||
|
* @param 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
|
||||||
|
* @returns The URL for redirecting users to the web app SSO component
|
||||||
|
*/
|
||||||
|
buildSsoUrl(
|
||||||
|
webAppUrl: string,
|
||||||
|
clientType: ClientType,
|
||||||
|
redirectUri: string,
|
||||||
|
state: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
email?: string,
|
||||||
|
): string {
|
||||||
|
let url =
|
||||||
|
webAppUrl +
|
||||||
|
"/#/sso?clientId=" +
|
||||||
|
clientType +
|
||||||
|
"&redirectUri=" +
|
||||||
|
encodeURIComponent(redirectUri) +
|
||||||
|
"&state=" +
|
||||||
|
state +
|
||||||
|
"&codeChallenge=" +
|
||||||
|
codeChallenge;
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
url += "&email=" + encodeURIComponent(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user