325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { Component, OnInit } from "@angular/core";
|
|
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from "@angular/forms";
|
|
import { ActivatedRoute } from "@angular/router";
|
|
|
|
import { SelectOptions } from "@bitwarden/angular/interfaces/selectOptions";
|
|
import { dirtyRequired } from "@bitwarden/angular/validators/dirty.validator";
|
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
|
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
|
|
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction";
|
|
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
|
import {
|
|
OpenIdConnectRedirectBehavior,
|
|
Saml2BindingType,
|
|
Saml2NameIdFormat,
|
|
Saml2SigningBehavior,
|
|
SsoType,
|
|
} from "@bitwarden/common/enums/ssoEnums";
|
|
import { Utils } from "@bitwarden/common/misc/utils";
|
|
import { SsoConfigApi } from "@bitwarden/common/models/api/ssoConfigApi";
|
|
import { Organization } from "@bitwarden/common/models/domain/organization";
|
|
import { OrganizationSsoRequest } from "@bitwarden/common/models/request/organization/organizationSsoRequest";
|
|
import { OrganizationSsoResponse } from "@bitwarden/common/models/response/organization/organizationSsoResponse";
|
|
import { SsoConfigView } from "@bitwarden/common/models/view/ssoConfigView";
|
|
|
|
const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
|
|
|
|
@Component({
|
|
selector: "app-org-manage-sso",
|
|
templateUrl: "sso.component.html",
|
|
})
|
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
|
export class SsoComponent implements OnInit {
|
|
readonly ssoType = SsoType;
|
|
|
|
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",
|
|
"http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
];
|
|
|
|
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 },
|
|
];
|
|
|
|
showOpenIdCustomizations = false;
|
|
|
|
loading = true;
|
|
haveTestedKeyConnector = false;
|
|
organizationId: string;
|
|
organization: Organization;
|
|
formPromise: Promise<OrganizationSsoResponse>;
|
|
|
|
callbackPath: string;
|
|
signedOutCallbackPath: string;
|
|
spEntityId: string;
|
|
spMetadataUrl: string;
|
|
spAcsUrl: string;
|
|
|
|
enabled = this.formBuilder.control(false);
|
|
|
|
openIdForm = this.formBuilder.group(
|
|
{
|
|
authority: ["", dirtyRequired],
|
|
clientId: ["", dirtyRequired],
|
|
clientSecret: ["", dirtyRequired],
|
|
metadataAddress: [],
|
|
redirectBehavior: [OpenIdConnectRedirectBehavior.RedirectGet, dirtyRequired],
|
|
getClaimsFromUserInfoEndpoint: [],
|
|
additionalScopes: [],
|
|
additionalUserIdClaimTypes: [],
|
|
additionalEmailClaimTypes: [],
|
|
additionalNameClaimTypes: [],
|
|
acrValues: [],
|
|
expectedReturnAcrValue: [],
|
|
},
|
|
{
|
|
updateOn: "blur",
|
|
}
|
|
);
|
|
|
|
samlForm = this.formBuilder.group(
|
|
{
|
|
spNameIdFormat: [Saml2NameIdFormat.NotConfigured],
|
|
spOutboundSigningAlgorithm: [defaultSigningAlgorithm],
|
|
spSigningBehavior: [Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned],
|
|
spMinIncomingSigningAlgorithm: [defaultSigningAlgorithm],
|
|
spWantAssertionsSigned: [],
|
|
spValidateCertificates: [],
|
|
|
|
idpEntityId: ["", dirtyRequired],
|
|
idpBindingType: [Saml2BindingType.HttpRedirect],
|
|
idpSingleSignOnServiceUrl: [],
|
|
idpSingleLogoutServiceUrl: [],
|
|
idpX509PublicCert: ["", dirtyRequired],
|
|
idpOutboundSigningAlgorithm: [defaultSigningAlgorithm],
|
|
idpAllowUnsolicitedAuthnResponse: [],
|
|
idpAllowOutboundLogoutRequests: [true],
|
|
idpWantAuthnRequestsSigned: [],
|
|
},
|
|
{
|
|
updateOn: "blur",
|
|
}
|
|
);
|
|
|
|
ssoConfigForm = this.formBuilder.group({
|
|
configType: [SsoType.None],
|
|
keyConnectorEnabled: [false],
|
|
keyConnectorUrl: [""],
|
|
openId: this.openIdForm,
|
|
saml: this.samlForm,
|
|
});
|
|
|
|
constructor(
|
|
private formBuilder: UntypedFormBuilder,
|
|
private route: ActivatedRoute,
|
|
private apiService: ApiService,
|
|
private platformUtilsService: PlatformUtilsService,
|
|
private i18nService: I18nService,
|
|
private organizationService: OrganizationService,
|
|
private organizationApiService: OrganizationApiServiceAbstraction
|
|
) {}
|
|
|
|
async ngOnInit() {
|
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
|
this.ssoConfigForm.get("configType").valueChanges.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")
|
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
|
.valueChanges.subscribe(() =>
|
|
this.samlForm.get("idpX509PublicCert").updateValueAndValidity()
|
|
);
|
|
|
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
|
this.route.parent.parent.params.subscribe(async (params) => {
|
|
this.organizationId = params.organizationId;
|
|
await this.load();
|
|
});
|
|
}
|
|
|
|
async load() {
|
|
this.organization = await this.organizationService.get(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.spMetadataUrl = ssoSettings.urls.spMetadataUrl;
|
|
this.spAcsUrl = ssoSettings.urls.spAcsUrl;
|
|
|
|
this.loading = false;
|
|
}
|
|
|
|
async submit() {
|
|
this.validateForm(this.ssoConfigForm);
|
|
|
|
if (this.ssoConfigForm.get("keyConnectorEnabled").value) {
|
|
await this.validateKeyConnectorUrl();
|
|
}
|
|
|
|
if (!this.ssoConfigForm.valid) {
|
|
this.readOutErrors();
|
|
return;
|
|
}
|
|
|
|
const request = new OrganizationSsoRequest();
|
|
request.enabled = this.enabled.value;
|
|
request.data = SsoConfigApi.fromView(this.ssoConfigForm.value as SsoConfigView);
|
|
|
|
this.formPromise = this.organizationApiService.updateSso(this.organizationId, request);
|
|
|
|
try {
|
|
const response = await this.formPromise;
|
|
this.populateForm(response);
|
|
this.platformUtilsService.showToast("success", null, this.i18nService.t("ssoSettingsSaved"));
|
|
} catch {
|
|
// Logged by appApiAction, do nothing
|
|
}
|
|
|
|
this.formPromise = null;
|
|
}
|
|
|
|
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: true,
|
|
});
|
|
}
|
|
|
|
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.get("keyConnectorEnabled").value &&
|
|
!Utils.isNullOrWhitespace(this.keyConnectorUrl?.value)
|
|
);
|
|
}
|
|
|
|
get keyConnectorUrl() {
|
|
return this.ssoConfigForm.get("keyConnectorUrl");
|
|
}
|
|
|
|
get samlSigningAlgorithmOptions(): SelectOptions[] {
|
|
return this.samlSigningAlgorithms.map((algorithm) => ({ name: algorithm, value: algorithm }));
|
|
}
|
|
|
|
private validateForm(form: UntypedFormGroup) {
|
|
Object.values(form.controls).forEach((control: AbstractControl) => {
|
|
if (control.disabled) {
|
|
return;
|
|
}
|
|
|
|
if (control instanceof UntypedFormGroup) {
|
|
this.validateForm(control);
|
|
} else {
|
|
control.markAsDirty();
|
|
control.markAsTouched();
|
|
control.updateValueAndValidity();
|
|
}
|
|
});
|
|
}
|
|
|
|
private populateForm(ssoSettings: OrganizationSsoResponse) {
|
|
this.enabled.setValue(ssoSettings.enabled);
|
|
if (ssoSettings.data != null) {
|
|
const ssoConfigView = new SsoConfigView(ssoSettings.data);
|
|
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 = "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);
|
|
}
|
|
}
|