mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
// 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<void>();
|
|
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<ControlsOf<SsoConfigView["openId"]>>(
|
|
{
|
|
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<ControlsOf<SsoConfigView["saml"]>>(
|
|
{
|
|
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<ControlsOf<SsoConfigView>>({
|
|
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);
|
|
}
|
|
}
|