// FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { AbstractControl, FormBuilder, FormControl, UntypedFormGroup, Validators, } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MemberDecryptionType, OpenIdConnectRedirectBehavior, Saml2BindingType, Saml2NameIdFormat, Saml2SigningBehavior, SsoType, } from "@bitwarden/common/auth/enums/sso"; import { SsoConfigApi } from "@bitwarden/common/auth/models/api/sso-config.api"; import { OrganizationSsoRequest } from "@bitwarden/common/auth/models/request/organization-sso.request"; import { OrganizationSsoResponse } from "@bitwarden/common/auth/models/response/organization-sso.response"; import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.view"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { ssoTypeValidator } from "./sso-type.validator"; interface SelectOptions { name: string; value: any; disabled?: boolean; } const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; @Component({ selector: "app-org-manage-sso", templateUrl: "sso.component.html", }) export class SsoComponent implements OnInit, OnDestroy { readonly ssoType = SsoType; readonly memberDecryptionType = MemberDecryptionType; readonly ssoTypeOptions: SelectOptions[] = [ { name: this.i18nService.t("selectType"), value: SsoType.None, disabled: true }, { name: "OpenID Connect", value: SsoType.OpenIdConnect }, { name: "SAML 2.0", value: SsoType.Saml2 }, ]; readonly samlSigningAlgorithms = [ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "http://www.w3.org/2000/09/xmldsig#rsa-sha384", "http://www.w3.org/2000/09/xmldsig#rsa-sha512", ]; readonly samlSigningAlgorithmOptions: SelectOptions[] = this.samlSigningAlgorithms.map( (algorithm) => ({ name: algorithm, value: algorithm }), ); readonly saml2SigningBehaviourOptions: SelectOptions[] = [ { name: "If IdP Wants Authn Requests Signed", value: Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned, }, { name: "Always", value: Saml2SigningBehavior.Always }, { name: "Never", value: Saml2SigningBehavior.Never }, ]; readonly saml2BindingTypeOptions: SelectOptions[] = [ { name: "Redirect", value: Saml2BindingType.HttpRedirect }, { name: "HTTP POST", value: Saml2BindingType.HttpPost }, ]; readonly saml2NameIdFormatOptions: SelectOptions[] = [ { name: "Not Configured", value: Saml2NameIdFormat.NotConfigured }, { name: "Unspecified", value: Saml2NameIdFormat.Unspecified }, { name: "Email Address", value: Saml2NameIdFormat.EmailAddress }, { name: "X.509 Subject Name", value: Saml2NameIdFormat.X509SubjectName }, { name: "Windows Domain Qualified Name", value: Saml2NameIdFormat.WindowsDomainQualifiedName }, { name: "Kerberos Principal Name", value: Saml2NameIdFormat.KerberosPrincipalName }, { name: "Entity Identifier", value: Saml2NameIdFormat.EntityIdentifier }, { name: "Persistent", value: Saml2NameIdFormat.Persistent }, { name: "Transient", value: Saml2NameIdFormat.Transient }, ]; readonly connectRedirectOptions: SelectOptions[] = [ { name: "Redirect GET", value: OpenIdConnectRedirectBehavior.RedirectGet }, { name: "Form POST", value: OpenIdConnectRedirectBehavior.FormPost }, ]; private destroy$ = new Subject(); showTdeOptions = false; showKeyConnectorOptions = false; showOpenIdCustomizations = false; loading = true; haveTestedKeyConnector = false; organizationId: string; organization: Organization; callbackPath: string; signedOutCallbackPath: string; spEntityId: string; spEntityIdStatic: string; spMetadataUrl: string; spAcsUrl: string; protected openIdForm = this.formBuilder.group>( { authority: new FormControl("", Validators.required), clientId: new FormControl("", Validators.required), clientSecret: new FormControl("", Validators.required), metadataAddress: new FormControl(), redirectBehavior: new FormControl( OpenIdConnectRedirectBehavior.RedirectGet, Validators.required, ), getClaimsFromUserInfoEndpoint: new FormControl(), additionalScopes: new FormControl(), additionalUserIdClaimTypes: new FormControl(), additionalEmailClaimTypes: new FormControl(), additionalNameClaimTypes: new FormControl(), acrValues: new FormControl(), expectedReturnAcrValue: new FormControl(), }, { updateOn: "blur", }, ); protected samlForm = this.formBuilder.group>( { spUniqueEntityId: new FormControl(true, { updateOn: "change" }), spNameIdFormat: new FormControl(Saml2NameIdFormat.NotConfigured), spOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm), spSigningBehavior: new FormControl(Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned), spMinIncomingSigningAlgorithm: new FormControl(defaultSigningAlgorithm), spWantAssertionsSigned: new FormControl(), spValidateCertificates: new FormControl(), idpEntityId: new FormControl("", Validators.required), idpBindingType: new FormControl(Saml2BindingType.HttpRedirect), idpSingleSignOnServiceUrl: new FormControl(), idpSingleLogoutServiceUrl: new FormControl(), idpX509PublicCert: new FormControl("", Validators.required), idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm), idpAllowUnsolicitedAuthnResponse: new FormControl(), idpAllowOutboundLogoutRequests: new FormControl(true), idpWantAuthnRequestsSigned: new FormControl(), }, { updateOn: "blur", }, ); protected ssoConfigForm = this.formBuilder.group>({ configType: new FormControl(SsoType.None), memberDecryptionType: new FormControl(MemberDecryptionType.MasterPassword), keyConnectorUrl: new FormControl(""), openId: this.openIdForm, saml: this.samlForm, enabled: new FormControl(false), ssoIdentifier: new FormControl("", { validators: [Validators.maxLength(50), Validators.required], }), }); get enabledCtrl() { return this.ssoConfigForm?.controls?.enabled as FormControl; } get ssoIdentifierCtrl() { return this.ssoConfigForm?.controls?.ssoIdentifier as FormControl; } get configTypeCtrl() { return this.ssoConfigForm?.controls?.configType as FormControl; } constructor( private formBuilder: FormBuilder, private route: ActivatedRoute, private apiService: ApiService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private organizationService: OrganizationService, private accountService: AccountService, private organizationApiService: OrganizationApiServiceAbstraction, private toastService: ToastService, ) {} async ngOnInit() { this.enabledCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((enabled) => { if (enabled) { this.ssoIdentifierCtrl.setValidators([Validators.maxLength(50), Validators.required]); this.configTypeCtrl.setValidators([ ssoTypeValidator(this.i18nService.t("selectionIsRequired")), ]); } else { this.ssoIdentifierCtrl.setValidators([]); this.configTypeCtrl.setValidators([]); } this.ssoIdentifierCtrl.updateValueAndValidity(); this.configTypeCtrl.updateValueAndValidity(); }); this.ssoConfigForm .get("configType") .valueChanges.pipe(takeUntil(this.destroy$)) .subscribe((newType: SsoType) => { if (newType === SsoType.OpenIdConnect) { this.openIdForm.enable(); this.samlForm.disable(); } else if (newType === SsoType.Saml2) { this.openIdForm.disable(); this.samlForm.enable(); } else { this.openIdForm.disable(); this.samlForm.disable(); } }); this.samlForm .get("spSigningBehavior") .valueChanges.pipe(takeUntil(this.destroy$)) .subscribe(() => this.samlForm.get("idpX509PublicCert").updateValueAndValidity()); this.route.params .pipe( concatMap(async (params) => { this.organizationId = params.organizationId; await this.load(); }), takeUntil(this.destroy$), ) .subscribe(); this.showKeyConnectorOptions = this.platformUtilsService.isSelfHost(); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } async load() { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.organization = await firstValueFrom( this.organizationService .organizations$(userId) .pipe(getOrganizationById(this.organizationId)), ); const ssoSettings = await this.organizationApiService.getSso(this.organizationId); this.populateForm(ssoSettings); this.callbackPath = ssoSettings.urls.callbackPath; this.signedOutCallbackPath = ssoSettings.urls.signedOutCallbackPath; this.spEntityId = ssoSettings.urls.spEntityId; this.spEntityIdStatic = ssoSettings.urls.spEntityIdStatic; this.spMetadataUrl = ssoSettings.urls.spMetadataUrl; this.spAcsUrl = ssoSettings.urls.spAcsUrl; this.loading = false; } submit = async () => { this.updateFormValidationState(this.ssoConfigForm); if (this.ssoConfigForm.value.memberDecryptionType === MemberDecryptionType.KeyConnector) { this.haveTestedKeyConnector = false; await this.validateKeyConnectorUrl(); } if (!this.ssoConfigForm.valid) { this.readOutErrors(); return; } const request = new OrganizationSsoRequest(); request.enabled = this.enabledCtrl.value; // Return null instead of empty string to avoid duplicate id errors in database request.identifier = this.ssoIdentifierCtrl.value === "" ? null : this.ssoIdentifierCtrl.value; request.data = SsoConfigApi.fromView(this.ssoConfigForm.getRawValue()); const response = await this.organizationApiService.updateSso(this.organizationId, request); this.populateForm(response); this.toastService.showToast({ variant: "success", title: null, message: this.i18nService.t("ssoSettingsSaved"), }); }; async validateKeyConnectorUrl() { if (this.haveTestedKeyConnector) { return; } this.keyConnectorUrl.markAsPending(); try { await this.apiService.getKeyConnectorAlive(this.keyConnectorUrl.value); this.keyConnectorUrl.updateValueAndValidity(); } catch { this.keyConnectorUrl.setErrors({ invalidUrl: { message: this.i18nService.t("keyConnectorTestFail") }, }); } this.haveTestedKeyConnector = true; } toggleOpenIdCustomizations() { this.showOpenIdCustomizations = !this.showOpenIdCustomizations; } getErrorCount(form: UntypedFormGroup): number { return Object.values(form.controls).reduce((acc: number, control: AbstractControl) => { if (control instanceof UntypedFormGroup) { return acc + this.getErrorCount(control); } if (control.errors == null) { return acc; } return acc + Object.keys(control.errors).length; }, 0); } get enableTestKeyConnector() { return ( this.ssoConfigForm.value?.memberDecryptionType === MemberDecryptionType.KeyConnector && !Utils.isNullOrWhitespace(this.keyConnectorUrl?.value) ); } get keyConnectorUrl() { return this.ssoConfigForm.get("keyConnectorUrl"); } /** * Shows any validation errors for the form by marking all controls as dirty and touched. * If nested form groups are found, they are also updated. * @param form - the form to show validation errors for */ private updateFormValidationState(form: UntypedFormGroup) { Object.values(form.controls).forEach((control: AbstractControl) => { if (control.disabled) { return; } if (control instanceof UntypedFormGroup) { this.updateFormValidationState(control); } else { control.markAsDirty(); control.markAsTouched(); control.updateValueAndValidity(); } }); } private populateForm(orgSsoResponse: OrganizationSsoResponse) { const ssoConfigView = new SsoConfigView(orgSsoResponse); this.ssoConfigForm.patchValue(ssoConfigView); } private readOutErrors() { const errorText = this.i18nService.t("error"); const errorCount = this.getErrorCount(this.ssoConfigForm); const errorCountText = this.i18nService.t( errorCount === 1 ? "formErrorSummarySingle" : "formErrorSummaryPlural", errorCount.toString(), ); const div = document.createElement("div"); div.className = "tw-sr-only"; div.id = "srErrorCount"; div.setAttribute("aria-live", "polite"); div.innerText = errorText + ": " + errorCountText; const existing = document.getElementById("srErrorCount"); if (existing != null) { existing.remove(); } document.body.append(div); } }