1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

Defect/SG-1083 - Fix SSO Form Validation (#4791)

* SG-1083 - Refactor SSO form validation to work per EC requirements

* Move SSO component into its own folder for better folder management for future components in auth.

* Defect SG-1086 - Domain verification table: Change domain name from anchor tag to button + add title

* SG-1083 - Send null instead of empty string for sso identifier to avoid duplicate key in database issues.

* SG-1086 - Add button type to domain verification button to pass lint rules.
This commit is contained in:
Jared Snider
2023-02-17 16:55:57 -05:00
committed by GitHub
parent 450df353a4
commit a348c78a79
8 changed files with 134 additions and 66 deletions

View File

@@ -6146,6 +6146,9 @@
"lastChecked": { "lastChecked": {
"message": "Last checked" "message": "Last checked"
}, },
"editDomain": {
"message": "Edit domain"
},
"domainFormInvalid": { "domainFormInvalid": {
"message": "There are form errors that need your attention" "message": "There are form errors that need your attention"
}, },
@@ -6395,5 +6398,8 @@
}, },
"errorReadingImportFile": { "errorReadingImportFile": {
"message": "An error occurred when trying to read the import file" "message": "An error occurred when trying to read the import file"
},
"selectionIsRequired": {
"message": "Selection is required."
} }
} }

View File

@@ -0,0 +1,19 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
import { SsoType } from "@bitwarden/common/auth/enums/sso";
export function ssoTypeValidator(errorMessage: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (value === SsoType.None) {
return {
validSsoTypeRequired: {
message: errorMessage,
},
};
}
return null;
};
}

View File

@@ -30,14 +30,14 @@
<ng-container> <ng-container>
<app-input-checkbox <app-input-checkbox
controlId="enabled" controlId="enabled"
[formControl]="enabled" formControlName="enabled"
[label]="'allowSso' | i18n" [label]="'allowSso' | i18n"
[helperText]="'allowSsoDesc' | i18n" [helperText]="'allowSsoDesc' | i18n"
></app-input-checkbox> ></app-input-checkbox>
<bit-form-field> <bit-form-field>
<bit-label>{{ "ssoIdentifier" | i18n }}</bit-label> <bit-label>{{ "ssoIdentifier" | i18n }}</bit-label>
<input bitInput type="text" [formControl]="ssoIdentifier" /> <input bitInput type="text" formControlName="ssoIdentifier" />
<bit-hint>{{ "ssoIdentifierHint" | i18n }}</bit-hint> <bit-hint>{{ "ssoIdentifierHint" | i18n }}</bit-hint>
</bit-form-field> </bit-form-field>

View File

@@ -30,6 +30,8 @@ import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.vie
import { Utils } from "@bitwarden/common/misc/utils"; import { Utils } from "@bitwarden/common/misc/utils";
import { Organization } from "@bitwarden/common/models/domain/organization"; import { Organization } from "@bitwarden/common/models/domain/organization";
import { ssoTypeValidator } from "./sso-type.validator";
const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
@Component({ @Component({
@@ -80,7 +82,7 @@ export class SsoComponent implements OnInit, OnDestroy {
{ name: "Form POST", value: OpenIdConnectRedirectBehavior.FormPost }, { name: "Form POST", value: OpenIdConnectRedirectBehavior.FormPost },
]; ];
private destory$ = new Subject<void>(); private destroy$ = new Subject<void>();
showOpenIdCustomizations = false; showOpenIdCustomizations = false;
@@ -96,12 +98,6 @@ export class SsoComponent implements OnInit, OnDestroy {
spMetadataUrl: string; spMetadataUrl: string;
spAcsUrl: string; spAcsUrl: string;
protected enabled = this.formBuilder.control(false);
protected ssoIdentifier = this.formBuilder.control("", {
validators: [Validators.maxLength(50), Validators.required],
});
protected openIdForm = this.formBuilder.group<ControlsOf<SsoConfigView["openId"]>>( protected openIdForm = this.formBuilder.group<ControlsOf<SsoConfigView["openId"]>>(
{ {
authority: new FormControl("", Validators.required), authority: new FormControl("", Validators.required),
@@ -155,8 +151,22 @@ export class SsoComponent implements OnInit, OnDestroy {
keyConnectorUrl: new FormControl(""), keyConnectorUrl: new FormControl(""),
openId: this.openIdForm, openId: this.openIdForm,
saml: this.samlForm, 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( constructor(
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -168,9 +178,24 @@ export class SsoComponent implements OnInit, OnDestroy {
) {} ) {}
async ngOnInit() { 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 this.ssoConfigForm
.get("configType") .get("configType")
.valueChanges.pipe(takeUntil(this.destory$)) .valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((newType: SsoType) => { .subscribe((newType: SsoType) => {
if (newType === SsoType.OpenIdConnect) { if (newType === SsoType.OpenIdConnect) {
this.openIdForm.enable(); this.openIdForm.enable();
@@ -186,7 +211,7 @@ export class SsoComponent implements OnInit, OnDestroy {
this.samlForm this.samlForm
.get("spSigningBehavior") .get("spSigningBehavior")
.valueChanges.pipe(takeUntil(this.destory$)) .valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => this.samlForm.get("idpX509PublicCert").updateValueAndValidity()); .subscribe(() => this.samlForm.get("idpX509PublicCert").updateValueAndValidity());
this.route.params this.route.params
@@ -195,14 +220,14 @@ export class SsoComponent implements OnInit, OnDestroy {
this.organizationId = params.organizationId; this.organizationId = params.organizationId;
await this.load(); await this.load();
}), }),
takeUntil(this.destory$) takeUntil(this.destroy$)
) )
.subscribe(); .subscribe();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.destory$.next(); this.destroy$.next();
this.destory$.complete(); this.destroy$.complete();
} }
async load() { async load() {
@@ -220,7 +245,7 @@ export class SsoComponent implements OnInit, OnDestroy {
} }
async submit() { async submit() {
this.validateForm(this.ssoConfigForm); this.updateFormValidationState(this.ssoConfigForm);
if (this.ssoConfigForm.value.keyConnectorEnabled) { if (this.ssoConfigForm.value.keyConnectorEnabled) {
this.haveTestedKeyConnector = false; this.haveTestedKeyConnector = false;
@@ -231,10 +256,10 @@ export class SsoComponent implements OnInit, OnDestroy {
this.readOutErrors(); this.readOutErrors();
return; return;
} }
const request = new OrganizationSsoRequest(); const request = new OrganizationSsoRequest();
request.enabled = this.enabled.value; request.enabled = this.enabledCtrl.value;
request.identifier = this.ssoIdentifier.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()); request.data = SsoConfigApi.fromView(this.ssoConfigForm.getRawValue());
this.formPromise = this.organizationApiService.updateSso(this.organizationId, request); this.formPromise = this.organizationApiService.updateSso(this.organizationId, request);
@@ -301,14 +326,19 @@ export class SsoComponent implements OnInit, OnDestroy {
return this.samlSigningAlgorithms.map((algorithm) => ({ name: algorithm, value: algorithm })); return this.samlSigningAlgorithms.map((algorithm) => ({ name: algorithm, value: algorithm }));
} }
private validateForm(form: UntypedFormGroup) { /**
* 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) => { Object.values(form.controls).forEach((control: AbstractControl) => {
if (control.disabled) { if (control.disabled) {
return; return;
} }
if (control instanceof UntypedFormGroup) { if (control instanceof UntypedFormGroup) {
this.validateForm(control); this.updateFormValidationState(control);
} else { } else {
control.markAsDirty(); control.markAsDirty();
control.markAsTouched(); control.markAsTouched();
@@ -317,14 +347,10 @@ export class SsoComponent implements OnInit, OnDestroy {
}); });
} }
private populateForm(ssoSettings: OrganizationSsoResponse) { private populateForm(orgSsoResponse: OrganizationSsoResponse) {
this.enabled.setValue(ssoSettings.enabled); const ssoConfigView = new SsoConfigView(orgSsoResponse);
this.ssoIdentifier.setValue(ssoSettings.identifier);
if (ssoSettings.data != null) {
const ssoConfigView = new SsoConfigView(ssoSettings.data);
this.ssoConfigForm.patchValue(ssoConfigView); this.ssoConfigForm.patchValue(ssoConfigView);
} }
}
private readOutErrors() { private readOutErrors() {
const errorText = this.i18nService.t("error"); const errorText = this.i18nService.t("error");

View File

@@ -30,9 +30,15 @@
<ng-template body> <ng-template body>
<tr bitRow *ngFor="let orgDomain of orgDomains; index as i"> <tr bitRow *ngFor="let orgDomain of orgDomains; index as i">
<td bitCell> <td bitCell>
<a bitLink href appStopClick linkType="primary" (click)="editDomain(orgDomain)">{{ <button
orgDomain.domainName bitLink
}}</a> type="button"
linkType="primary"
(click)="editDomain(orgDomain)"
appA11yTitle="{{ 'editDomain' | i18n }}"
>
{{ orgDomain.domainName }}
</button>
</td> </td>
<td bitCell> <td bitCell>
<span *ngIf="!orgDomain?.verifiedDate" bitBadge badgeType="warning">{{ <span *ngIf="!orgDomain?.verifiedDate" bitBadge badgeType="warning">{{

View File

@@ -8,7 +8,7 @@ import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/organizat
import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/organizations/layouts/organization-layout.component"; import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/organizations/layouts/organization-layout.component";
import { SettingsComponent } from "@bitwarden/web-vault/app/organizations/settings/settings.component"; import { SettingsComponent } from "@bitwarden/web-vault/app/organizations/settings/settings.component";
import { SsoComponent } from "../auth/sso.component"; import { SsoComponent } from "../auth/sso/sso.component";
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component"; import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
import { ScimComponent } from "./manage/scim.component"; import { ScimComponent } from "./manage/scim.component";

View File

@@ -2,7 +2,7 @@ import { NgModule } from "@angular/core";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
import { SsoComponent } from "../auth/sso.component"; import { SsoComponent } from "../auth/sso/sso.component";
import { InputCheckboxComponent } from "./components/input-checkbox.component"; import { InputCheckboxComponent } from "./components/input-checkbox.component";
import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component"; import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component";

View File

@@ -6,9 +6,12 @@ import {
Saml2SigningBehavior, Saml2SigningBehavior,
SsoType, SsoType,
} from "../../enums/sso"; } from "../../enums/sso";
import { SsoConfigApi } from "../api/sso-config.api"; import { OrganizationSsoResponse } from "../response/organization-sso.response";
export class SsoConfigView extends View { export class SsoConfigView extends View {
enabled: boolean;
ssoIdentifier: string;
configType: SsoType; configType: SsoType;
keyConnectorEnabled: boolean; keyConnectorEnabled: boolean;
@@ -48,55 +51,63 @@ export class SsoConfigView extends View {
idpWantAuthnRequestsSigned: boolean; idpWantAuthnRequestsSigned: boolean;
}; };
constructor(api: SsoConfigApi) { constructor(orgSsoResponse: OrganizationSsoResponse) {
super(); super();
if (api == null) {
if (orgSsoResponse == null) {
return; return;
} }
this.configType = api.configType; this.enabled = orgSsoResponse.enabled;
this.ssoIdentifier = orgSsoResponse.identifier;
this.keyConnectorEnabled = api.keyConnectorEnabled; if (orgSsoResponse.data == null) {
this.keyConnectorUrl = api.keyConnectorUrl; return;
}
this.configType = orgSsoResponse.data.configType;
this.keyConnectorEnabled = orgSsoResponse.data.keyConnectorEnabled;
this.keyConnectorUrl = orgSsoResponse.data.keyConnectorUrl;
if (this.configType === SsoType.OpenIdConnect) { if (this.configType === SsoType.OpenIdConnect) {
this.openId = { this.openId = {
authority: api.authority, authority: orgSsoResponse.data.authority,
clientId: api.clientId, clientId: orgSsoResponse.data.clientId,
clientSecret: api.clientSecret, clientSecret: orgSsoResponse.data.clientSecret,
metadataAddress: api.metadataAddress, metadataAddress: orgSsoResponse.data.metadataAddress,
redirectBehavior: api.redirectBehavior, redirectBehavior: orgSsoResponse.data.redirectBehavior,
getClaimsFromUserInfoEndpoint: api.getClaimsFromUserInfoEndpoint, getClaimsFromUserInfoEndpoint: orgSsoResponse.data.getClaimsFromUserInfoEndpoint,
additionalScopes: api.additionalScopes, additionalScopes: orgSsoResponse.data.additionalScopes,
additionalUserIdClaimTypes: api.additionalUserIdClaimTypes, additionalUserIdClaimTypes: orgSsoResponse.data.additionalUserIdClaimTypes,
additionalEmailClaimTypes: api.additionalEmailClaimTypes, additionalEmailClaimTypes: orgSsoResponse.data.additionalEmailClaimTypes,
additionalNameClaimTypes: api.additionalNameClaimTypes, additionalNameClaimTypes: orgSsoResponse.data.additionalNameClaimTypes,
acrValues: api.acrValues, acrValues: orgSsoResponse.data.acrValues,
expectedReturnAcrValue: api.expectedReturnAcrValue, expectedReturnAcrValue: orgSsoResponse.data.expectedReturnAcrValue,
}; };
} else if (this.configType === SsoType.Saml2) { } else if (this.configType === SsoType.Saml2) {
this.saml = { this.saml = {
spNameIdFormat: api.spNameIdFormat, spNameIdFormat: orgSsoResponse.data.spNameIdFormat,
spOutboundSigningAlgorithm: api.spOutboundSigningAlgorithm, spOutboundSigningAlgorithm: orgSsoResponse.data.spOutboundSigningAlgorithm,
spSigningBehavior: api.spSigningBehavior, spSigningBehavior: orgSsoResponse.data.spSigningBehavior,
spMinIncomingSigningAlgorithm: api.spMinIncomingSigningAlgorithm, spMinIncomingSigningAlgorithm: orgSsoResponse.data.spMinIncomingSigningAlgorithm,
spWantAssertionsSigned: api.spWantAssertionsSigned, spWantAssertionsSigned: orgSsoResponse.data.spWantAssertionsSigned,
spValidateCertificates: api.spValidateCertificates, spValidateCertificates: orgSsoResponse.data.spValidateCertificates,
idpEntityId: api.idpEntityId, idpEntityId: orgSsoResponse.data.idpEntityId,
idpBindingType: api.idpBindingType, idpBindingType: orgSsoResponse.data.idpBindingType,
idpSingleSignOnServiceUrl: api.idpSingleSignOnServiceUrl, idpSingleSignOnServiceUrl: orgSsoResponse.data.idpSingleSignOnServiceUrl,
idpSingleLogoutServiceUrl: api.idpSingleLogoutServiceUrl, idpSingleLogoutServiceUrl: orgSsoResponse.data.idpSingleLogoutServiceUrl,
idpX509PublicCert: api.idpX509PublicCert, idpX509PublicCert: orgSsoResponse.data.idpX509PublicCert,
idpOutboundSigningAlgorithm: api.idpOutboundSigningAlgorithm, idpOutboundSigningAlgorithm: orgSsoResponse.data.idpOutboundSigningAlgorithm,
idpAllowUnsolicitedAuthnResponse: api.idpAllowUnsolicitedAuthnResponse, idpAllowUnsolicitedAuthnResponse: orgSsoResponse.data.idpAllowUnsolicitedAuthnResponse,
idpWantAuthnRequestsSigned: api.idpWantAuthnRequestsSigned, idpWantAuthnRequestsSigned: orgSsoResponse.data.idpWantAuthnRequestsSigned,
// Value is inverted in the view model (allow instead of disable) // Value is inverted in the view model (allow instead of disable)
idpAllowOutboundLogoutRequests: idpAllowOutboundLogoutRequests:
api.idpDisableOutboundLogoutRequests == null orgSsoResponse.data.idpDisableOutboundLogoutRequests == null
? null ? null
: !api.idpDisableOutboundLogoutRequests, : !orgSsoResponse.data.idpDisableOutboundLogoutRequests,
}; };
} }
} }