mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 00:03:56 +00:00
feat(SSO): (Auth/[PM-22110] Remove Alternate Login Options when SSO Required (#16340)
If a user is part of an org that has the `RequireSso` policy, when that user successfully logs in we add their email to a local `ssoRequiredCache` on their device. The next time this user goes to the `/login` screen on this device, we will use that cache to determine that for this email we should only show the "Use single sign-on" button and disable the alternate login buttons. These changes are behind the flag: `PM22110_DisableAlternateLoginMethods`
This commit is contained in:
@@ -38,7 +38,14 @@
|
||||
|
||||
<div class="tw-grid tw-gap-3">
|
||||
<!-- Continue button -->
|
||||
<button type="button" bitButton block buttonType="primary" (click)="continuePressed()">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
buttonType="primary"
|
||||
(click)="continuePressed()"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -52,6 +59,7 @@
|
||||
block
|
||||
buttonType="secondary"
|
||||
(click)="handleLoginWithPasskeyClick()"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "logInWithPasskey" | i18n }}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom, Subject, take, takeUntil } from "rxjs";
|
||||
@@ -17,9 +26,10 @@ import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.d
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
@@ -83,6 +93,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
LoginUiState = LoginUiState;
|
||||
isKnownDevice = false;
|
||||
loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY;
|
||||
ssoRequired = false;
|
||||
|
||||
formGroup = this.formBuilder.group(
|
||||
{
|
||||
@@ -108,6 +119,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private appIdService: AppIdService,
|
||||
private broadcasterService: BroadcasterService,
|
||||
private destroyRef: DestroyRef,
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
@@ -124,8 +136,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private validationService: ValidationService,
|
||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
}
|
||||
@@ -184,6 +196,15 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
if (!this.activatedRoute) {
|
||||
await this.loadRememberedEmail();
|
||||
}
|
||||
|
||||
const disableAlternateLoginMethodsFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22110_DisableAlternateLoginMethods,
|
||||
);
|
||||
if (disableAlternateLoginMethodsFlagEnabled) {
|
||||
// This SSO required check should come after email has had a chance to be pre-filled (if it
|
||||
// was found in query params or was the remembered email)
|
||||
await this.determineIfSsoRequired();
|
||||
}
|
||||
}
|
||||
|
||||
private async desktopOnInit(): Promise<void> {
|
||||
@@ -210,6 +231,40 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
this.messagingService.send("getWindowIsFocused");
|
||||
}
|
||||
|
||||
private async determineIfSsoRequired() {
|
||||
const ssoRequiredCache = await firstValueFrom(this.ssoLoginService.ssoRequiredCache$);
|
||||
|
||||
// Only perform initial update and setup a subscription if there is actually a populated ssoRequiredCache
|
||||
if (ssoRequiredCache != null && ssoRequiredCache.size > 0) {
|
||||
// If the pre-filled/remembered email field value exists in the cache, set to true
|
||||
if (
|
||||
this.emailFormControl.value &&
|
||||
ssoRequiredCache.has(this.emailFormControl.value.toLowerCase())
|
||||
) {
|
||||
this.ssoRequired = true;
|
||||
}
|
||||
|
||||
this.listenForEmailChanges(ssoRequiredCache);
|
||||
}
|
||||
}
|
||||
|
||||
private listenForEmailChanges(ssoRequiredCache: Set<string>) {
|
||||
// On subsequent email field value changes, check and set again. This allows alternate login buttons
|
||||
// to dynamically enable/disable depending on whether or not the entered email is in the ssoRequiredCache
|
||||
this.formGroup.controls.email.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
if (
|
||||
this.emailFormControl.value &&
|
||||
ssoRequiredCache.has(this.emailFormControl.value.toLowerCase())
|
||||
) {
|
||||
this.ssoRequired = true;
|
||||
} else {
|
||||
this.ssoRequired = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submit = async (): Promise<void> => {
|
||||
if (this.clientType === ClientType.Desktop) {
|
||||
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<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 }}
|
||||
|
||||
@@ -290,6 +290,7 @@ export class SsoComponent implements OnInit {
|
||||
this.identifier = this.identifierFormControl.value ?? "";
|
||||
await this.ssoLoginService.setOrganizationSsoIdentifier(this.identifier);
|
||||
this.ssoComponentService.setDocumentCookies?.();
|
||||
|
||||
try {
|
||||
await this.submitSso();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { LoginEmailService } from "../login-email/login-email.service";
|
||||
|
||||
import { DefaultLoginSuccessHandlerService } from "./default-login-success-handler.service";
|
||||
|
||||
describe("DefaultLoginSuccessHandlerService", () => {
|
||||
let service: DefaultLoginSuccessHandlerService;
|
||||
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let loginEmailService: MockProxy<LoginEmailService>;
|
||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let syncService: MockProxy<SyncService>;
|
||||
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const userId = "USER_ID" as UserId;
|
||||
const testEmail = "test@bitwarden.com";
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
loginEmailService = mock<LoginEmailService>();
|
||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
syncService = mock<SyncService>();
|
||||
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
service = new DefaultLoginSuccessHandlerService(
|
||||
configService,
|
||||
loginEmailService,
|
||||
ssoLoginService,
|
||||
syncService,
|
||||
userAsymmetricKeysRegenerationService,
|
||||
logService,
|
||||
);
|
||||
|
||||
syncService.fullSync.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("should call required services on successful login", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
|
||||
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
|
||||
expect(loginEmailService.clearLoginEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("when PM22110_DisableAlternateLoginMethods flag is disabled", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("should not check SSO requirements", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(ssoLoginService.getSsoEmail).not.toHaveBeenCalled();
|
||||
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given PM22110_DisableAlternateLoginMethods flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should check feature flag", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM22110_DisableAlternateLoginMethods,
|
||||
);
|
||||
});
|
||||
|
||||
it("should get SSO email", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("given SSO email is not found", () => {
|
||||
beforeEach(() => {
|
||||
ssoLoginService.getSsoEmail.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("should log error and return early", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith("SSO login email not found.");
|
||||
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given SSO email is found", () => {
|
||||
beforeEach(() => {
|
||||
ssoLoginService.getSsoEmail.mockResolvedValue(testEmail);
|
||||
});
|
||||
|
||||
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
|
||||
await service.run(userId);
|
||||
|
||||
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
|
||||
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,42 @@
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { LoginSuccessHandlerService } from "../../abstractions/login-success-handler.service";
|
||||
import { LoginEmailService } from "../login-email/login-email.service";
|
||||
|
||||
export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerService {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private loginEmailService: LoginEmailService,
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private syncService: SyncService,
|
||||
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private loginEmailService: LoginEmailService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
async run(userId: UserId): Promise<void> {
|
||||
await this.syncService.fullSync(true, { skipTokenRefresh: true });
|
||||
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
|
||||
await this.loginEmailService.clearLoginEmail();
|
||||
|
||||
const disableAlternateLoginMethodsFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22110_DisableAlternateLoginMethods,
|
||||
);
|
||||
|
||||
if (disableAlternateLoginMethodsFlagEnabled) {
|
||||
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
|
||||
|
||||
if (!ssoLoginEmail) {
|
||||
this.logService.error("SSO login email not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ssoLoginService.updateSsoRequiredCache(ssoLoginEmail, userId);
|
||||
await this.ssoLoginService.clearSsoEmail();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user