1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-20 16:07:45 +01:00

feat(sso): [PM-8114] implement SSO component UI refresh

Consolidates existing SSO components into a single unified component in
libs/auth, matching the new design system. This implementation:

- Creates a new shared SsoComponent with extracted business logic
- Adds feature flag support for unauth-ui-refresh
- Updates page styling including new icons and typography
- Preserves web client claimed domain logic
- Maintains backwards compatibility with legacy views

PM-8114

---------

Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
Co-authored-by: Jared Snider <jsnider@bitwarden.com>
This commit is contained in:
Alec Rippberger 2024-12-12 10:28:30 -06:00 committed by GitHub
parent bfa9cf3623
commit 0df7b53bb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1005 additions and 45 deletions

View File

@ -0,0 +1,67 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import {
EnvironmentService,
Environment,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { ExtensionSsoComponentService } from "./extension-sso-component.service";
describe("ExtensionSsoComponentService", () => {
let service: ExtensionSsoComponentService;
const baseUrl = "https://vault.bitwarden.com";
let syncService: MockProxy<SyncService>;
let authService: MockProxy<AuthService>;
let environmentService: MockProxy<EnvironmentService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
beforeEach(() => {
syncService = mock<SyncService>();
authService = mock<AuthService>();
environmentService = mock<EnvironmentService>();
i18nService = mock<I18nService>();
logService = mock<LogService>();
environmentService.environment$ = new BehaviorSubject<Environment>({
getWebVaultUrl: () => baseUrl,
} as Environment);
TestBed.configureTestingModule({
providers: [
{ provide: SyncService, useValue: syncService },
{ provide: AuthService, useValue: authService },
{ provide: EnvironmentService, useValue: environmentService },
{ provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: logService },
ExtensionSsoComponentService,
],
});
service = TestBed.inject(ExtensionSsoComponentService);
jest.spyOn(BrowserApi, "reloadOpenWindows").mockImplementation();
});
it("creates the service", () => {
expect(service).toBeTruthy();
});
describe("closeWindow", () => {
it("closes window", async () => {
const windowSpy = jest.spyOn(window, "close").mockImplementation();
await service.closeWindow?.();
expect(windowSpy).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,34 @@
import { Injectable } from "@angular/core";
import { DefaultSsoComponentService, SsoComponentService } from "@bitwarden/auth/angular";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
/**
* This service is used to handle the SSO login process for the browser extension.
*/
@Injectable()
export class ExtensionSsoComponentService
extends DefaultSsoComponentService
implements SsoComponentService
{
constructor(
protected syncService: SyncService,
protected authService: AuthService,
protected environmentService: EnvironmentService,
protected i18nService: I18nService,
protected logService: LogService,
) {
super();
}
/**
* Closes the popup window after a successful login.
*/
async closeWindow() {
window.close();
}
}

View File

@ -29,9 +29,9 @@ import { BrowserApi } from "../../platform/browser/browser-api";
@Component({ @Component({
selector: "app-sso", selector: "app-sso",
templateUrl: "sso.component.html", templateUrl: "sso-v1.component.html",
}) })
export class SsoComponent extends BaseSsoComponent { export class SsoComponentV1 extends BaseSsoComponent {
constructor( constructor(
ssoLoginService: SsoLoginServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction,
loginStrategyService: LoginStrategyServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction,

View File

@ -39,6 +39,7 @@ import {
VaultIcon, VaultIcon,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
DevicesIcon, DevicesIcon,
SsoComponent,
TwoFactorTimeoutIcon, TwoFactorTimeoutIcon,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -62,7 +63,7 @@ import { RemovePasswordComponent } from "../auth/popup/remove-password.component
import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent as AccountSecurityV1Component } from "../auth/popup/settings/account-security-v1.component"; import { AccountSecurityComponent as AccountSecurityV1Component } from "../auth/popup/settings/account-security-v1.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { SsoComponent } from "../auth/popup/sso.component"; import { SsoComponentV1 } from "../auth/popup/sso-v1.component";
import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"; import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component";
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component";
@ -230,12 +231,40 @@ const routes: Routes = [
canActivate: [unauthGuardFn(unauthRouteOverrides)], canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties, data: { elevation: 1 } satisfies RouteDataProperties,
}, },
...unauthUiRefreshSwap(
SsoComponentV1,
ExtensionAnonLayoutWrapperComponent,
{ {
path: "sso", path: "sso",
component: SsoComponent,
canActivate: [unauthGuardFn(unauthRouteOverrides)], canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: { elevation: 1 } satisfies RouteDataProperties, data: { elevation: 1 } satisfies RouteDataProperties,
}, },
{
path: "sso",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: ExtensionDefaultOverlayPosition,
} satisfies EnvironmentSelectorRouteData,
},
],
},
),
{ {
path: "set-password", path: "set-password",
component: SetPasswordComponent, component: SetPasswordComponent,

View File

@ -33,7 +33,7 @@ import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent as AccountSecurityComponentV1 } from "../auth/popup/settings/account-security-v1.component"; import { AccountSecurityComponent as AccountSecurityComponentV1 } from "../auth/popup/settings/account-security-v1.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component";
import { SsoComponent } from "../auth/popup/sso.component"; import { SsoComponentV1 } from "../auth/popup/sso-v1.component";
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
@ -177,7 +177,7 @@ import "../platform/popup/locales";
SettingsComponent, SettingsComponent,
VaultSettingsComponent, VaultSettingsComponent,
ShareComponent, ShareComponent,
SsoComponent, SsoComponentV1,
SyncComponent, SyncComponent,
TabsComponent, TabsComponent,
TabsV2Component, TabsV2Component,

View File

@ -25,6 +25,7 @@ import {
AnonLayoutWrapperDataService, AnonLayoutWrapperDataService,
LoginComponentService, LoginComponentService,
LockComponentService, LockComponentService,
SsoComponentService,
LoginDecryptionOptionsService, LoginDecryptionOptionsService,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common";
@ -119,6 +120,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service";
import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service";
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service"; import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service";
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
import AutofillService from "../../autofill/services/autofill.service"; import AutofillService from "../../autofill/services/autofill.service";
@ -597,6 +599,11 @@ const safeProviders: SafeProvider[] = [
useExisting: PopupCompactModeService, useExisting: PopupCompactModeService,
deps: [], deps: [],
}), }),
safeProvider({
provide: SsoComponentService,
useClass: ExtensionSsoComponentService,
deps: [SyncService, AuthService, EnvironmentService, I18nServiceAbstraction, LogService],
}),
safeProvider({ safeProvider({
provide: LoginDecryptionOptionsService, provide: LoginDecryptionOptionsService,
useClass: ExtensionLoginDecryptionOptionsService, useClass: ExtensionLoginDecryptionOptionsService,

View File

@ -36,6 +36,7 @@ import {
VaultIcon, VaultIcon,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
DevicesIcon, DevicesIcon,
SsoComponent,
TwoFactorTimeoutIcon, TwoFactorTimeoutIcon,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -51,7 +52,7 @@ import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-req
import { RegisterComponent } from "../auth/register.component"; import { RegisterComponent } from "../auth/register.component";
import { RemovePasswordComponent } from "../auth/remove-password.component"; import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component"; import { SetPasswordComponent } from "../auth/set-password.component";
import { SsoComponent } from "../auth/sso.component"; import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorAuthComponent } from "../auth/two-factor-auth.component"; import { TwoFactorAuthComponent } from "../auth/two-factor-auth.component";
import { TwoFactorComponent } from "../auth/two-factor.component"; import { TwoFactorComponent } from "../auth/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
@ -122,7 +123,33 @@ const routes: Routes = [
}, },
{ path: "accessibility-cookie", component: AccessibilityCookieComponent }, { path: "accessibility-cookie", component: AccessibilityCookieComponent },
{ path: "set-password", component: SetPasswordComponent }, { path: "set-password", component: SetPasswordComponent },
{ path: "sso", component: SsoComponent }, ...unauthUiRefreshSwap(
SsoComponentV1,
AnonLayoutWrapperComponent,
{
path: "sso",
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
{ {
path: "send", path: "send",
component: SendComponent, component: SendComponent,

View File

@ -18,7 +18,7 @@ import { LoginModule } from "../auth/login/login.module";
import { RegisterComponent } from "../auth/register.component"; import { RegisterComponent } from "../auth/register.component";
import { RemovePasswordComponent } from "../auth/remove-password.component"; import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component"; import { SetPasswordComponent } from "../auth/set-password.component";
import { SsoComponent } from "../auth/sso.component"; import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
import { TwoFactorComponent } from "../auth/two-factor.component"; import { TwoFactorComponent } from "../auth/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
@ -92,7 +92,7 @@ import { SendComponent } from "./tools/send/send.component";
SetPasswordComponent, SetPasswordComponent,
SettingsComponent, SettingsComponent,
ShareComponent, ShareComponent,
SsoComponent, SsoComponentV1,
TwoFactorComponent, TwoFactorComponent,
TwoFactorOptionsComponent, TwoFactorOptionsComponent,
UpdateTempPasswordComponent, UpdateTempPasswordComponent,

View File

@ -25,6 +25,8 @@ import {
LoginComponentService, LoginComponentService,
SetPasswordJitService, SetPasswordJitService,
LockComponentService, LockComponentService,
SsoComponentService,
DefaultSsoComponentService,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { import {
InternalUserDecryptionOptionsServiceAbstraction, InternalUserDecryptionOptionsServiceAbstraction,
@ -361,6 +363,11 @@ const safeProviders: SafeProvider[] = [
useClass: LoginEmailService, useClass: LoginEmailService,
deps: [AccountService, AuthService, StateProvider], deps: [AccountService, AuthService, StateProvider],
}), }),
safeProvider({
provide: SsoComponentService,
useClass: DefaultSsoComponentService,
deps: [],
}),
safeProvider({ safeProvider({
provide: LoginApprovalComponentServiceAbstraction, provide: LoginApprovalComponentServiceAbstraction,
useClass: DesktopLoginApprovalComponentService, useClass: DesktopLoginApprovalComponentService,

View File

@ -23,9 +23,9 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
@Component({ @Component({
selector: "app-sso", selector: "app-sso",
templateUrl: "sso.component.html", templateUrl: "sso-v1.component.html",
}) })
export class SsoComponent extends BaseSsoComponent { export class SsoComponentV1 extends BaseSsoComponent {
constructor( constructor(
ssoLoginService: SsoLoginServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction,
loginStrategyService: LoginStrategyServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction,

View File

@ -0,0 +1,36 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { WebSsoComponentService } from "./web-sso-component.service";
describe("WebSsoComponentService", () => {
let service: WebSsoComponentService;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
i18nService = mock<I18nService>();
TestBed.configureTestingModule({
providers: [WebSsoComponentService, { provide: I18nService, useValue: i18nService }],
});
service = TestBed.inject(WebSsoComponentService);
});
it("creates the service", () => {
expect(service).toBeTruthy();
});
describe("setDocumentCookies", () => {
it("sets ssoHandOffMessage cookie with translated message", () => {
const mockMessage = "Test SSO Message";
i18nService.t.mockReturnValue(mockMessage);
service.setDocumentCookies?.();
expect(document.cookie).toContain(`ssoHandOffMessage=${mockMessage}`);
expect(i18nService.t).toHaveBeenCalledWith("ssoHandOff");
});
});
});

View File

@ -0,0 +1,21 @@
import { Injectable } from "@angular/core";
import { DefaultSsoComponentService, SsoComponentService } from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
/**
* This service is used to handle the SSO login process for the web client.
*/
@Injectable()
export class WebSsoComponentService
extends DefaultSsoComponentService
implements SsoComponentService
{
constructor(private i18nService: I18nService) {
super();
}
setDocumentCookies() {
document.cookie = `ssoHandOffMessage=${this.i18nService.t("ssoHandOff")};SameSite=strict`;
}
}

View File

@ -35,10 +35,10 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
@Component({ @Component({
selector: "app-sso", selector: "app-sso",
templateUrl: "sso.component.html", templateUrl: "sso-v1.component.html",
}) })
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class SsoComponent extends BaseSsoComponent implements OnInit { export class SsoComponentV1 extends BaseSsoComponent implements OnInit {
protected formGroup = new FormGroup({ protected formGroup = new FormGroup({
identifier: new FormControl(null, [Validators.required]), identifier: new FormControl(null, [Validators.required]),
}); });

View File

@ -32,6 +32,7 @@ import {
LoginComponentService, LoginComponentService,
LockComponentService, LockComponentService,
SetPasswordJitService, SetPasswordJitService,
SsoComponentService,
LoginDecryptionOptionsService, LoginDecryptionOptionsService,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { import {
@ -101,6 +102,7 @@ import {
WebLockComponentService, WebLockComponentService,
WebLoginDecryptionOptionsService, WebLoginDecryptionOptionsService,
} from "../auth"; } from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
import { HtmlStorageService } from "../core/html-storage.service"; import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service"; import { I18nService } from "../core/i18n.service";
@ -301,6 +303,11 @@ const safeProviders: SafeProvider[] = [
useClass: LoginEmailService, useClass: LoginEmailService,
deps: [AccountService, AuthService, StateProvider], deps: [AccountService, AuthService, StateProvider],
}), }),
safeProvider({
provide: SsoComponentService,
useClass: WebSsoComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({ safeProvider({
provide: LoginDecryptionOptionsService, provide: LoginDecryptionOptionsService,
useClass: WebLoginDecryptionOptionsService, useClass: WebLoginDecryptionOptionsService,

View File

@ -29,11 +29,13 @@ import {
LockIcon, LockIcon,
TwoFactorTimeoutIcon, TwoFactorTimeoutIcon,
UserLockIcon, UserLockIcon,
SsoKeyIcon,
LoginViaAuthRequestComponent, LoginViaAuthRequestComponent,
DevicesIcon, DevicesIcon,
RegistrationUserAddIcon, RegistrationUserAddIcon,
RegistrationLockAltIcon, RegistrationLockAltIcon,
RegistrationExpiredLinkIcon, RegistrationExpiredLinkIcon,
SsoComponent,
VaultIcon, VaultIcon,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
@ -62,7 +64,7 @@ import { AccountComponent } from "./auth/settings/account/account.component";
import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component";
import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component";
import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module";
import { SsoComponent } from "./auth/sso.component"; import { SsoComponentV1 } from "./auth/sso-v1.component";
import { CompleteTrialInitiationComponent } from "./auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component"; import { CompleteTrialInitiationComponent } from "./auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component";
import { freeTrialTextResolver } from "./auth/trial-initiation/complete-trial-initiation/resolver/free-trial-text.resolver"; import { freeTrialTextResolver } from "./auth/trial-initiation/complete-trial-initiation/resolver/free-trial-text.resolver";
import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component"; import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component";
@ -430,6 +432,9 @@ const routes: Routes = [
}, },
], ],
}, },
...unauthUiRefreshSwap(
SsoComponentV1,
SsoComponent,
{ {
path: "sso", path: "sso",
canActivate: [unauthGuardFn()], canActivate: [unauthGuardFn()],
@ -439,6 +444,32 @@ const routes: Routes = [
}, },
titleId: "enterpriseSingleSignOn", titleId: "enterpriseSingleSignOn",
} satisfies RouteDataProperties & AnonLayoutWrapperData, } satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: SsoComponentV1,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "sso",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "singleSignOn",
},
titleId: "enterpriseSingleSignOn",
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
titleAreaMaxWidth: "md",
pageIcon: SsoKeyIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [ children: [
{ {
path: "", path: "",
@ -451,6 +482,7 @@ const routes: Routes = [
}, },
], ],
}, },
),
{ {
path: "login", path: "login",
canActivate: [unauthGuardFn()], canActivate: [unauthGuardFn()],

View File

@ -50,7 +50,7 @@ import { TwoFactorSetupYubiKeyComponent } from "../auth/settings/two-factor/two-
import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor-setup.component"; import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor-setup.component";
import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component"; import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component";
import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { UserVerificationModule } from "../auth/shared/components/user-verification";
import { SsoComponent } from "../auth/sso.component"; import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
import { TwoFactorComponent } from "../auth/two-factor.component"; import { TwoFactorComponent } from "../auth/two-factor.component";
import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdatePasswordComponent } from "../auth/update-password.component";
@ -158,7 +158,7 @@ import { SharedModule } from "./shared.module";
SetPasswordComponent, SetPasswordComponent,
SponsoredFamiliesComponent, SponsoredFamiliesComponent,
SponsoringOrgRowComponent, SponsoringOrgRowComponent,
SsoComponent, SsoComponentV1,
TwoFactorSetupAuthenticatorComponent, TwoFactorSetupAuthenticatorComponent,
TwoFactorComponent, TwoFactorComponent,
TwoFactorSetupDuoComponent, TwoFactorSetupDuoComponent,
@ -225,7 +225,7 @@ import { SharedModule } from "./shared.module";
SetPasswordComponent, SetPasswordComponent,
SponsoredFamiliesComponent, SponsoredFamiliesComponent,
SponsoringOrgRowComponent, SponsoringOrgRowComponent,
SsoComponent, SsoComponentV1,
TwoFactorSetupAuthenticatorComponent, TwoFactorSetupAuthenticatorComponent,
TwoFactorComponent, TwoFactorComponent,
TwoFactorSetupDuoComponent, TwoFactorSetupDuoComponent,

View File

@ -4739,6 +4739,12 @@
"ssoLogInWithOrgIdentifier": { "ssoLogInWithOrgIdentifier": {
"message": "Log in using your organization's single sign-on portal. Please enter your organization's SSO identifier to begin." "message": "Log in using your organization's single sign-on portal. Please enter your organization's SSO identifier to begin."
}, },
"singleSignOnEnterOrgIdentifier": {
"message": "Enter your organization's SSO identifier to begin"
},
"singleSignOnEnterOrgIdentifierText": {
"message": "To log in with your SSO provider, enter your organization's SSO identifier to begin. You may need to enter this SSO identifier when you log in from a new device."
},
"enterpriseSingleSignOn": { "enterpriseSingleSignOn": {
"message": "Enterprise single sign-on" "message": "Enterprise single sign-on"
}, },

View File

@ -15,6 +15,7 @@ import { componentRouteSwap } from "../../utils/component-route-swap";
* @param defaultComponent - The current non-refreshed component to render. * @param defaultComponent - The current non-refreshed component to render.
* @param refreshedComponent - The new refreshed component to render. * @param refreshedComponent - The new refreshed component to render.
* @param options - The shared route options to apply to both components. * @param options - The shared route options to apply to both components.
* @param altOptions - The alt route options to apply to the alt component. If not provided, the base options will be used.
*/ */
export function unauthUiRefreshSwap( export function unauthUiRefreshSwap(
defaultComponent: Type<any>, defaultComponent: Type<any>,

View File

@ -4,6 +4,7 @@
[icon]="pageIcon" [icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname" [showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth" [maxWidth]="maxWidth"
[titleAreaMaxWidth]="titleAreaMaxWidth"
> >
<router-outlet></router-outlet> <router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet> <router-outlet slot="secondary" name="secondary"></router-outlet>

View File

@ -35,6 +35,10 @@ export interface AnonLayoutWrapperData {
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided. * Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
*/ */
maxWidth?: "md" | "3xl"; maxWidth?: "md" | "3xl";
/**
* Optional flag to set the max-width of the title area. Defaults to null if not provided.
*/
titleAreaMaxWidth?: "md";
} }
@Component({ @Component({
@ -50,6 +54,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
protected pageIcon: Icon; protected pageIcon: Icon;
protected showReadonlyHostname: boolean; protected showReadonlyHostname: boolean;
protected maxWidth: "md" | "3xl"; protected maxWidth: "md" | "3xl";
protected titleAreaMaxWidth: "md";
constructor( constructor(
private router: Router, private router: Router,
@ -100,6 +105,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
this.maxWidth = firstChildRouteData["maxWidth"]; this.maxWidth = firstChildRouteData["maxWidth"];
this.titleAreaMaxWidth = firstChildRouteData["titleAreaMaxWidth"];
} }
private listenForServiceDataChanges() { private listenForServiceDataChanges() {
@ -157,6 +163,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
this.pageIcon = null; this.pageIcon = null;
this.showReadonlyHostname = null; this.showReadonlyHostname = null;
this.maxWidth = null; this.maxWidth = null;
this.titleAreaMaxWidth = null;
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -13,7 +13,10 @@
<bit-icon [icon]="logo"></bit-icon> <bit-icon [icon]="logo"></bit-icon>
</a> </a>
<div class="tw-text-center tw-mb-6"> <div
class="tw-text-center tw-mb-6"
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
>
<div class="tw-mx-auto tw-max-w-28 sm:tw-max-w-32"> <div class="tw-mx-auto tw-max-w-28 sm:tw-max-w-32">
<bit-icon [icon]="icon"></bit-icon> <bit-icon [icon]="icon"></bit-icon>
</div> </div>

View File

@ -34,6 +34,13 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
@Input() hideLogo: boolean = false; @Input() hideLogo: boolean = false;
@Input() hideFooter: boolean = false; @Input() hideFooter: boolean = false;
/**
* Max width of the title area content
*
* @default null
*/
@Input() titleAreaMaxWidth?: "md";
/** /**
* Max width of the layout content * Max width of the layout content
* *
@ -60,6 +67,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
async ngOnInit() { async ngOnInit() {
this.maxWidth = this.maxWidth ?? "md"; this.maxWidth = this.maxWidth ?? "md";
this.titleAreaMaxWidth = this.titleAreaMaxWidth ?? null;
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname(); this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
this.version = await this.platformUtilsService.getApplicationVersion(); this.version = await this.platformUtilsService.getApplicationVersion();

View File

@ -190,3 +190,22 @@ export const HideFooter: Story = {
`, `,
}), }),
}; };
export const WithTitleAreaMaxWidth: Story = {
render: (args) => ({
props: {
...args,
title: "This is a very long long title to demonstrate titleAreaMaxWidth set to 'md'",
subtitle:
"This is a very long subtitle that demonstrates how the max width container handles longer text content with the titleAreaMaxWidth input set to 'md'. Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?",
},
template: `
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [titleAreaMaxWidth]="'md'">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};

View File

@ -10,4 +10,5 @@ export * from "./vault.icon";
export * from "./registration-user-add.icon"; export * from "./registration-user-add.icon";
export * from "./registration-lock-alt.icon"; export * from "./registration-lock-alt.icon";
export * from "./registration-expired-link.icon"; export * from "./registration-expired-link.icon";
export * from "./sso-key.icon";
export * from "./two-factor-timeout.icon"; export * from "./two-factor-timeout.icon";

View File

@ -0,0 +1,10 @@
import { svgIcon } from "@bitwarden/components";
export const SsoKeyIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100">
<path class="tw-fill-art-primary" d="M28.573 23.888c6.488-8.225 16.56-13.51 27.87-13.51 15.454 0 28.595 9.864 33.446 23.62a23.969 23.969 0 0 1 2.844-.168c13.083 0 23.689 10.58 23.689 23.629 0 13.049-10.606 23.628-23.69 23.628H56.445v-2.393h36.289c11.757 0 21.289-9.507 21.289-21.236 0-11.728-9.532-21.235-21.29-21.235-1.182 0-2.34.096-3.469.28l-1.022.168-.315-.984c-4.26-13.293-16.746-22.915-31.482-22.915-10.712 0-20.234 5.083-26.274 12.968l-.31.404-.506.059C16.22 27.718 6.022 38.852 6.022 52.36c0 9.427 4.965 17.696 12.429 22.348l-.954 2.226C9.179 71.897 3.622 62.776 3.622 52.36c0-14.563 10.865-26.595 24.951-28.472Z"/>
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M110.626 64.594a.598.598 0 0 1-.393-.75 18.278 18.278 0 0 0-1.008-13.552 18.326 18.326 0 0 0-7.607-8.025.597.597 0 0 1-.233-.814.6.6 0 0 1 .816-.232 19.52 19.52 0 0 1 8.103 8.548 19.474 19.474 0 0 1 1.074 14.434.6.6 0 0 1-.752.39ZM33.17 30.116c-13.02 0-23.574 10.524-23.574 23.506a.6.6 0 0 1-1.2 0c0-13.642 11.092-24.702 24.773-24.702a.6.6 0 1 1 0 1.196Z" clip-rule="evenodd"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" d="M81.528 43.563a1.198 1.198 0 0 0-1.058-1.306l-11.08-1.273c-.32-.037-.641.055-.892.256L46.665 58.748c-6.998-3.579-15.75-3.07-22.226 2.123-8.84 7.088-10.054 19.941-2.89 28.787 7.168 8.847 20.1 10.352 28.888 3.306 6.524-5.232 8.897-13.726 6.742-21.358l3.146-2.523c.25-.2.41-.493.441-.812l.509-5.09 5.29.609c.32.036.64-.056.89-.257.251-.2.41-.493.442-.812l.144-1.439 1.169.135c.317.036.635-.055.885-.252s.41-.485.446-.8l.612-5.372 5.764.663c.32.036.641-.056.892-.257l2.366-1.897c.25-.201.41-.494.441-.812l.912-9.127ZM68.289 58.904l-1.186-.136c-.32-.037-.64.056-.891.257-.25.2-.41.493-.441.812l-.144 1.438-5.29-.607c-.32-.037-.64.055-.891.256-.25.201-.41.494-.441.812l-.58 5.8-3.384 2.713a1.191 1.191 0 0 0-.389 1.3c2.266 6.94.211 14.794-5.724 19.553-7.726 6.196-19.152 4.902-25.508-2.944-6.357-7.848-5.248-19.19 2.527-25.425 5.886-4.72 13.952-5.061 20.26-1.516.43.241.962.198 1.345-.11l22.063-17.691 9.405 1.08-.745 7.458-1.583 1.27-6.46-.743a1.205 1.205 0 0 0-.885.251c-.25.198-.41.486-.446.801l-.612 5.371Z" clip-rule="evenodd"/>
<path class="tw-fill-art-accent" fill-rule="evenodd" d="M35.251 78.67c2.144 2.647 1.721 6.453-.864 8.526-2.587 2.074-6.414 1.676-8.558-.97-2.137-2.638-1.78-6.405.865-8.525 2.653-2.127 6.468-1.61 8.557.97Zm-2.373 6.665c1.543-1.237 1.823-3.535.503-5.164-1.291-1.595-3.602-1.873-5.179-.609-1.584 1.271-1.829 3.528-.503 5.165 1.32 1.629 3.637 1.845 5.18.608Z" clip-rule="evenodd"/>
</svg>
`;

View File

@ -64,6 +64,11 @@ export * from "./lock/lock-component.service";
// vault timeout // vault timeout
export * from "./vault-timeout-input/vault-timeout-input.component"; export * from "./vault-timeout-input/vault-timeout-input.component";
// sso
export * from "./sso/sso.component";
export * from "./sso/sso-component.service";
export * from "./sso/default-sso-component.service";
// self hosted environment configuration dialog // self hosted environment configuration dialog
export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component"; export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component";

View File

@ -0,0 +1,3 @@
import { SsoComponentService } from "./sso-component.service";
export class DefaultSsoComponentService implements SsoComponentService {}

View File

@ -0,0 +1,20 @@
import { ClientType } from "@bitwarden/common/enums";
export type SsoClientType = ClientType.Web | ClientType.Browser | ClientType.Desktop;
/**
* Abstract class for SSO component services.
*/
export abstract class SsoComponentService {
/**
* Sets the cookies for the SSO component service.
* Used to pass translation messages to the SSO connector page (apps/web/src/connectors/sso.ts) during the SSO handoff process.
* See implementation in WebSsoComponentService for example usage.
*/
setDocumentCookies?(): void;
/**
* Closes the window.
*/
closeWindow?(): Promise<void>;
}

View File

@ -0,0 +1,18 @@
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-container">
<div *ngIf="loggingIn">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<div *ngIf="!loggingIn">
<bit-form-field>
<bit-label>{{ "ssoIdentifier" | i18n }}</bit-label>
<input bitInput type="text" formControlName="identifier" appAutofocus />
</bit-form-field>
<hr />
<div class="tw-flex tw-gap-2">
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
{{ "continue" | i18n }}
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,591 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginStrategyServiceAbstraction,
SsoLoginCredentials,
TrustedDeviceUserDecryptionOption,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction";
import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response";
import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
AsyncActionsModule,
ButtonModule,
CheckboxModule,
FormFieldModule,
IconButtonModule,
LinkModule,
ToastService,
} from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { SsoClientType, SsoComponentService } from "./sso-component.service";
interface QueryParams {
code?: string;
state?: string;
redirectUri?: string;
clientId?: string;
codeChallenge?: string;
identifier?: string;
email?: string;
}
/**
* This component handles the SSO flow.
*/
@Component({
standalone: true,
templateUrl: "sso.component.html",
imports: [
AsyncActionsModule,
ButtonModule,
CheckboxModule,
CommonModule,
FormFieldModule,
IconButtonModule,
LinkModule,
JslibModule,
ReactiveFormsModule,
RouterModule,
],
})
export class SsoComponent implements OnInit {
protected formGroup = new FormGroup({
identifier: new FormControl<string | null>(null, [Validators.required]),
});
protected redirectUri: string | undefined;
protected loggingIn = false;
protected identifier: string | undefined;
protected state: string | undefined;
protected codeChallenge: string | undefined;
protected clientId: SsoClientType | undefined;
formPromise: Promise<AuthResult> | undefined;
initiateSsoFormPromise: Promise<SsoPreValidateResponse> | undefined;
get identifierFormControl() {
return this.formGroup.controls.identifier;
}
constructor(
private ssoLoginService: SsoLoginServiceAbstraction,
private loginStrategyService: LoginStrategyServiceAbstraction,
private router: Router,
private i18nService: I18nService,
private route: ActivatedRoute,
private orgDomainApiService: OrgDomainApiServiceAbstraction,
private validationService: ValidationService,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
private apiService: ApiService,
private cryptoFunctionService: CryptoFunctionService,
private environmentService: EnvironmentService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private logService: LogService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private accountService: AccountService,
private toastService: ToastService,
private ssoComponentService: SsoComponentService,
private syncService: SyncService,
) {
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
});
const clientType = this.platformUtilsService.getClientType();
if (this.isValidSsoClientType(clientType)) {
this.clientId = clientType as SsoClientType;
}
}
async ngOnInit() {
const qParams: QueryParams = await firstValueFrom(this.route.queryParams);
// This if statement will pass on the second portion of the SSO flow
// where the user has already authenticated with the identity provider
if (this.hasCodeOrStateParams(qParams)) {
await this.handleCodeAndStateParams(qParams);
return;
}
// This if statement will pass on the first portion of the SSO flow
if (this.hasRequiredSsoParams(qParams)) {
this.setRequiredSsoVariables(qParams);
return;
}
if (qParams.identifier != null) {
// SSO Org Identifier in query params takes precedence over claimed domains
this.identifierFormControl.setValue(qParams.identifier);
this.loggingIn = true;
await this.submit();
return;
}
await this.initializeIdentifierFromEmailOrStorage(qParams);
}
/**
* Sets the required SSO variables from the query params
* @param qParams - The query params
*/
private setRequiredSsoVariables(qParams: QueryParams): void {
this.redirectUri = qParams.redirectUri ?? "";
this.state = qParams.state ?? "";
this.codeChallenge = qParams.codeChallenge ?? "";
const clientId = qParams.clientId ?? "";
if (this.isValidSsoClientType(clientId)) {
this.clientId = clientId;
} else {
throw new Error(`Invalid SSO client type: ${qParams.clientId}`);
}
}
/**
* Checks if the value is a valid SSO client type
* @param value - The value to check
* @returns True if the value is a valid SSO client type, otherwise false
*/
private isValidSsoClientType(value: string): value is SsoClientType {
return [ClientType.Web, ClientType.Browser, ClientType.Desktop].includes(value as ClientType);
}
/**
* Checks if the query params have the required SSO params
* @param qParams - The query params
* @returns True if the query params have the required SSO params, false otherwise
*/
private hasRequiredSsoParams(qParams: QueryParams): boolean {
return (
qParams.clientId != null &&
qParams.redirectUri != null &&
qParams.state != null &&
qParams.codeChallenge != null
);
}
/**
* Handles the code and state params
* @param qParams - The query params
*/
private async handleCodeAndStateParams(qParams: QueryParams): Promise<void> {
const codeVerifier = await this.ssoLoginService.getCodeVerifier();
const state = await this.ssoLoginService.getSsoState();
await this.ssoLoginService.setCodeVerifier("");
await this.ssoLoginService.setSsoState("");
if (qParams.redirectUri != null) {
this.redirectUri = qParams.redirectUri;
}
if (
qParams.code != null &&
codeVerifier != null &&
state != null &&
this.checkState(state, qParams.state ?? "")
) {
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state ?? "");
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
}
}
/**
* Checks if the query params have a code or state
* @param qParams - The query params
* @returns True if the query params have a code or state, false otherwise
*/
private hasCodeOrStateParams(qParams: QueryParams): boolean {
return qParams.code != null && qParams.state != null;
}
private handleGetClaimedDomainByEmailError(error: unknown): void {
if (error instanceof ErrorResponse) {
const errorResponse: ErrorResponse = error as ErrorResponse;
switch (errorResponse.statusCode) {
case HttpStatusCode.NotFound:
//this is a valid case for a domain not found
return;
default:
this.validationService.showError(errorResponse);
break;
}
}
}
submit = async (): Promise<void> => {
if (this.formGroup.invalid) {
return;
}
const autoSubmit = (await firstValueFrom(this.route.queryParams)).identifier != null;
this.identifier = this.identifierFormControl.value ?? "";
await this.ssoLoginService.setOrganizationSsoIdentifier(this.identifier);
this.ssoComponentService.setDocumentCookies?.();
try {
await this.submitSso();
} catch (error) {
if (autoSubmit) {
await this.router.navigate(["/login"]);
} else {
this.validationService.showError(error);
}
}
};
private async submitSso(returnUri?: string, includeUserIdentifier?: boolean) {
if (this.identifier == null || this.identifier === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("ssoValidationFailed"),
message: this.i18nService.t("ssoIdentifierRequired"),
});
return;
}
if (this.clientId == null) {
throw new Error("Client ID is required");
}
this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier);
const response = await this.initiateSsoFormPromise;
const authorizeUrl = await this.buildAuthorizeUrl(
returnUri,
includeUserIdentifier,
response.token,
);
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
}
private async buildAuthorizeUrl(
returnUri?: string,
includeUserIdentifier?: boolean,
token?: string,
): Promise<string> {
let codeChallenge = this.codeChallenge;
let state = this.state;
const passwordOptions = {
type: "password" as const,
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
if (codeChallenge == null) {
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
}
if (state == null) {
state = await this.passwordGenerationService.generatePassword(passwordOptions);
if (returnUri) {
state += `_returnUri='${returnUri}'`;
}
}
// Add Organization Identifier to state
state += `_identifier=${this.identifier}`;
// Save state (regardless of new or existing)
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let authorizeUrl =
env.getIdentityUrl() +
"/connect/authorize?" +
"client_id=" +
this.clientId +
"&redirect_uri=" +
encodeURIComponent(this.redirectUri ?? "") +
"&" +
"response_type=code&scope=api offline_access&" +
"state=" +
state +
"&code_challenge=" +
codeChallenge +
"&" +
"code_challenge_method=S256&response_mode=query&" +
"domain_hint=" +
encodeURIComponent(this.identifier ?? "") +
"&ssoToken=" +
encodeURIComponent(token ?? "");
if (includeUserIdentifier) {
const userIdentifier = await this.apiService.getSsoUserIdentifier();
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
}
return authorizeUrl;
}
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
this.loggingIn = true;
try {
const email = await this.ssoLoginService.getSsoEmail();
const redirectUri = this.redirectUri ?? "";
const credentials = new SsoLoginCredentials(
code,
codeVerifier,
redirectUri,
orgSsoIdentifier,
email,
);
this.formPromise = this.loginStrategyService.logIn(credentials);
const authResult = await this.formPromise;
if (authResult.requiresTwoFactor) {
return await this.handleTwoFactorRequired(orgSsoIdentifier);
}
// Everything after the 2FA check is considered a successful login
// Just have to figure out where to send the user
await this.syncService.fullSync(true);
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
// - TDE login decryption options component
// - Browser SSO on extension open
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
// Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet
return await this.handleForcePasswordReset(orgSsoIdentifier);
}
// must come after 2fa check since user decryption options aren't available if 2fa is required
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
const tdeEnabled = userDecryptionOpts.trustedDeviceOption
? await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption)
: false;
if (tdeEnabled) {
return await this.handleTrustedDeviceEncryptionEnabled(userDecryptionOpts);
}
// In the standard, non TDE case, a user must set password if they don't
// have one and they aren't using key connector.
// Note: TDE & Key connector are mutually exclusive org config options.
const requireSetPassword =
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.keyConnectorOption === undefined;
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(orgSsoIdentifier);
}
// Standard SSO login success case
return await this.handleSuccessfulLogin();
} catch (e) {
await this.handleLoginError(e);
}
}
private async isTrustedDeviceEncEnabled(
trustedDeviceOption: TrustedDeviceUserDecryptionOption,
): Promise<boolean> {
return trustedDeviceOption !== undefined;
}
private async handleTwoFactorRequired(orgIdentifier: string) {
await this.router.navigate(["2fa"], {
queryParams: {
identifier: orgIdentifier,
sso: "true",
},
});
}
private async handleTrustedDeviceEncryptionEnabled(
userDecryptionOpts: UserDecryptionOptions,
): Promise<void> {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
return;
}
// Tde offboarding takes precedence
if (
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.trustedDeviceOption?.isTdeOffboarding
) {
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeOffboarding,
userId,
);
} else if (
// If user doesn't have a MP, but has reset password permission, they must set a MP
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.trustedDeviceOption?.hasManageResetPasswordPermission
) {
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
// Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and
// if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key.
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);
}
if (this.ssoComponentService?.closeWindow) {
await this.ssoComponentService.closeWindow();
} else {
await this.router.navigate(["login-initiated"]);
}
}
private async handleChangePasswordRequired(orgIdentifier: string) {
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
let route = "set-password";
if (emailVerification) {
route = "set-password-jit";
}
await this.router.navigate([route], {
queryParams: {
identifier: orgIdentifier,
},
});
}
private async handleForcePasswordReset(orgIdentifier: string) {
await this.router.navigate(["update-temp-password"], {
queryParams: {
identifier: orgIdentifier,
},
});
}
private async handleSuccessfulLogin() {
await this.router.navigate(["lock"]);
}
private async handleLoginError(e: unknown) {
this.logService.error(e);
// TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here
if (e instanceof Error && e.message === "Key Connector error") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("ssoKeyConnectorError"),
});
}
}
private getOrgIdentifierFromState(state: string): string {
if (state === null || state === undefined) {
return "";
}
const stateSplit = state.split("_identifier=");
return stateSplit.length > 1 ? stateSplit[1] : "";
}
private checkState(state: string, checkState: string): boolean {
if (state === null || state === undefined) {
return false;
}
if (checkState === null || checkState === undefined) {
return false;
}
const stateSplit = state.split("_identifier=");
const checkStateSplit = checkState.split("_identifier=");
return stateSplit[0] === checkStateSplit[0];
}
/**
* Attempts to initialize the SSO identifier from email or storage.
* Note: this flow is written for web but both browser and desktop
* redirect here on SSO button click.
* @param qParams - The query params
*/
private async initializeIdentifierFromEmailOrStorage(qParams: QueryParams): Promise<void> {
// Check if email matches any claimed domains
if (qParams.email) {
// show loading spinner
this.loggingIn = true;
try {
if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) {
const response: ListResponse<VerifiedOrganizationDomainSsoDetailsResponse> =
await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email);
if (response.data.length > 0) {
this.identifierFormControl.setValue(response.data[0].organizationIdentifier);
await this.submit();
return;
}
} else {
const response: OrganizationDomainSsoDetailsResponse =
await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email);
if (response?.ssoAvailable && response?.verifiedDate) {
this.identifierFormControl.setValue(response.organizationIdentifier);
await this.submit();
return;
}
}
} catch (error) {
this.handleGetClaimedDomainByEmailError(error);
}
this.loggingIn = false;
}
// Fallback to state svc if domain is unclaimed
const storedIdentifier = await this.ssoLoginService.getOrganizationSsoIdentifier();
if (storedIdentifier != null) {
this.identifierFormControl.setValue(storedIdentifier);
}
}
}