mattermost-mobile/app/managers/security_manager/index.ts
Elias Nahum 44eb76bed7
feat(iOS): Add Microsoft Intune MAM integration with multi-server support (#9312)
* refactor: implement custom ExpoImage wrapper for cache control

Add ExpoImage component with automatic cacheKey/cachePath management and replace all expo-image imports across the app

* refactor(ios): convert Gekidou to CocoaPods

Migrate from Swift Package Manager to CocoaPods, add Keychain write operations, refactor notification handler to remove react-native-notifications headers, and upgrade Swift to 5.0

* npm audit

* update fastlane

* feat(ci): integrate Intune MAM for enterprise builds with strict OSS protection

Add Intune submodule, CI actions, Fastlane configuration, developer scripts, pre-commit hooks, and validation workflows to enable internal MAM builds while protecting OSS repository

* fix tests by mocking @mattermost/intune

* feat: implement Intune MAM integration with comprehensive security enforcement

Add IntuneManager, refactor SecurityManager/SessionManager for MAM policies, implement native OIDC auth flow, add biometric enforcement, conditional launch blocking, and file protection controls

* fix alerts when no server database is present

* Unify cache strategy

* fix emit config changed after it was stored in the db

* Handle Mid-Session Enrollment Detection

* fix ADALLogOverrideDisabled missing in Fastfile

* fix flow for initial enrollment

* fix and add unit tests

* enable Intune configuration for PR and beta builds, CLIENT_ID should be changed before actual release

* Update intune submodule with addressed feedback

* fix validate-intune-clean workflow

* feat(intune): add comprehensive error handling and SAML+Entra support

Add production-ready error handling for native Entra authentication with
user-friendly i18n messages, comprehensive test coverage, and support for
Entra login when server requires SAML.

* update i18n

* update intune submodule

* update build-pr token

* fix race condition between server auth and intune enrollment

* fix CI workflow to build with intune

* use deploy key for intune submodule

* set the config directly in the submodule .git

* debug injection

* try setting GIT_SSH_COMMAND

* remove action debug

* fix server url input

* match pod cache with intune hash

* Fastfile and envs

* have workflows check for intune/.git

* have ci cache intune frameworks as well

* update Fastlane to set no-cache to artifacts uploaded

* fix s3 upload

* fix pblist template

* Attempt to remove the cache control for PR uploads to s3

* use hash from commit for S3 path

* Implement crash-resilient selective wipe with automatic retry and add removeInternetPassword to Gekidou Keychain

* Fix surface errors from intune login

* fix postinstall scripts

* use cacheKey for draft md images

* remove unnecessary double await during test

* Have isMinimumLicenseTier accept valid license sku tier as target

* Add missing Auth error messages

* remove the last period for intune errors in i18n

* do not call unenroll with wipe on manual logout

* Fix tests and Intune error messages

* do not filter any SSO type regardless of which is used for Intune

* fix 412 to not retry

* fix tests, app logs sharing and share_extension avatar cache

* apply setScreenCapturePolicy on license change

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>

* re-apply screen capture on enrollment

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>

* use userData from intunr login and prevent getMe

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>

* Check for Biometrics and Jailbreak as we used to

---------

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>
2025-12-10 13:07:28 +02:00

946 lines
34 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import Emm from '@mattermost/react-native-emm';
import {isRootedExperimentalAsync} from 'expo-device';
import {AppState, DeviceEventEmitter, type AppStateStatus, type EventSubscription} from 'react-native';
import {terminateSession} from '@actions/local/session';
import {getCurrentUserLocale} from '@actions/local/user';
import {logout} from '@actions/remote/session';
import {Events} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import ManagedApp from '@init/managed_app';
import IntuneManager from '@managers/intune_manager';
import {
IntuneConditionalLaunchBlockedReasons,
type IntuneAuthRequiredEvent,
type IntuneConditionalLaunchBlockedEvent,
type IntuneEnrollmentChangedEvent,
type IntuneIdentitySwitchRequiredEvent,
type IntunePolicy,
type IntunePolicyChangedEvent,
type IntuneWipeRequestedEvent,
} from '@managers/intune_manager/types';
import {queryAllActiveServers} from '@queries/app/servers';
import {getConfig, getConfigValue, getSecurityConfig} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {
messages,
showAuthenticationRequiredAlert,
showBiometricFailureAlert,
showBiometricFailureAlertForOrganization,
showConditionalAccessAlert,
showDeviceNotTrustedAlert,
showIdentitySwitchRequiredAlert,
showMAMDeclinedAlert,
showMAMEnrollmentFailedAlert,
showMAMEnrollmentRequiredAlert,
showNotSecuredAlert,
} from '@utils/alerts';
import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers';
import {logDebug, logError} from '@utils/log';
import type {AvailableScreens} from '@typings/screens/navigation';
type SecurityManagerServerConfig = {
Biometrics?: boolean;
JailbreakProtection?: boolean;
PreventScreenCapture?: boolean;
authenticated?: boolean;
lastAccessed?: number;
siteName?: string;
intunePolicy?: IntunePolicy | null;
};
type SecurityManagerServersCollection = Record<string, SecurityManagerServerConfig>;
class SecurityManagerSingleton {
activeServer?: string;
serverConfig: SecurityManagerServersCollection = {};
backgroundSince = 0;
previousAppState?: AppStateStatus;
initialized = false;
started = false;
isEnrolling = false;
isCheckingBiometrics = false;
needsEnrollmentCheck = false;
intunePolicySubscription?: EventSubscription;
intuneEnrollmentSubscription?: EventSubscription;
intuneWipeSubscription?: EventSubscription;
intuneAuthSubscription?: EventSubscription;
intuneBlockedSubscription?: EventSubscription;
intuneIdentitySwitchSubscription?: EventSubscription;
constructor() {
AppState.addEventListener('change', this.onAppStateChange);
DeviceEventEmitter.addListener(Events.ACTIVE_SERVER_CHANGED, this.setActiveServer);
DeviceEventEmitter.addListener(Events.LICENSE_CHANGED, this.onLicenseChanged);
DeviceEventEmitter.addListener(Events.CONFIG_CHANGED, this.onConfigChanged);
// Setup Intune event listeners
this.intunePolicySubscription = IntuneManager.subscribeToPolicyChanges(this.onIntunePolicyChanged);
this.intuneEnrollmentSubscription = IntuneManager.subscribeToEnrollmentChanges(this.onEnrollmentChanged);
this.intuneWipeSubscription = IntuneManager.subscribeToWipeRequests(this.onWipeRequested);
this.intuneAuthSubscription = IntuneManager.subscribeToAuthRequired(this.onAuthRequired);
this.intuneBlockedSubscription = IntuneManager.subscribeToConditionalLaunchBlocked(this.onConditionalLaunchBlocked);
this.intuneIdentitySwitchSubscription = IntuneManager.subscribeToIdentitySwitchRequired(this.onIdentitySwitchRequired);
}
/**
* Initializes SecurityManager by loading server configs and Intune policies
* Should be called during app startup in app/init/app.ts
*/
async init() {
if (this.initialized) {
return;
}
logDebug('SecurityManager: Initializing');
const loadServerConfig = async (serverUrl: string) => {
try {
// Get security config from server database
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const config = await getSecurityConfig(database);
// Get Intune policy if available
const intunePolicy = await IntuneManager.getPolicy(serverUrl);
// Add server to config map
this.addServer(serverUrl, config, false, intunePolicy);
} catch (error) {
logError('SecurityManager: Failed to load config for server', {error});
}
};
try {
// Query all active servers from app database
const servers = await queryAllActiveServers()?.fetch();
if (!servers || servers.length === 0) {
logDebug('SecurityManager: No active servers found');
this.initialized = true;
return;
}
// Fetch configs and policies for all servers in parallel
await Promise.all(servers.map((server) => loadServerConfig(server.url)));
// Retry any pending wipes that failed previously
await this.retryPendingWipes();
} catch (error) {
logError('SecurityManager: Failed to initialize', error);
}
this.initialized = true;
}
/**
* Start method applies security policies to the active server
* Should be called from Home screen after rendering
*/
async start() {
if (this.started) {
return;
}
this.started = true;
const serverUrl = await DatabaseManager.getActiveServerUrl();
if (!serverUrl) {
logDebug('SecurityManager: No active server to start');
return;
}
// Delegate all logic to setActiveServer
this.setActiveServer({serverUrl});
}
/**
* Cleanup event listeners (for testing or shutdown)
*/
cleanup() {
logDebug('SecurityManager: Cleaning up');
this.intunePolicySubscription?.remove();
this.intuneEnrollmentSubscription?.remove();
this.intuneWipeSubscription?.remove();
this.intuneAuthSubscription?.remove();
this.intuneBlockedSubscription?.remove();
this.intuneIdentitySwitchSubscription?.remove();
this.initialized = false;
this.serverConfig = {};
this.activeServer = undefined;
this.started = false;
}
/**
* Handles app state changes to prompt authentication when resuming from background.
*/
onAppStateChange = async (appState: AppStateStatus) => {
if (this.isAuthenticationHandledByEmm()) {
return;
}
const isActive = appState === 'active';
const isBackground = appState === 'background';
if (isActive && this.previousAppState === 'background') {
if (this.activeServer && !this.serverConfig[this.activeServer].intunePolicy?.isPINRequired) {
const config = this.getServerConfig(this.activeServer);
if (config && config.Biometrics && isMainActivity()) {
const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= toMilliseconds({minutes: 5});
if (authExpired) {
const isJailbroken = await this.isDeviceJailbroken(this.activeServer);
if (!isJailbroken) {
await this.authenticateWithBiometrics(this.activeServer);
}
}
this.backgroundSince = 0;
}
}
} else if (isBackground) {
this.backgroundSince = Date.now();
}
this.previousAppState = appState;
};
/**
* Handles config changes from app
*/
onConfigChanged = async (event: {serverUrl: string; config: SecurityClientConfig}) => {
const {serverUrl, config} = event;
logDebug('SecurityManager: Config changed');
// Update server config
const existingConfig = this.serverConfig[serverUrl] || {};
const intunePolicy = await IntuneManager.getPolicy(serverUrl);
this.serverConfig[serverUrl] = {
...existingConfig,
siteName: config.SiteName,
Biometrics: config.MobileEnableBiometrics === 'true',
JailbreakProtection: config.MobileJailbreakProtection === 'true',
PreventScreenCapture: config.MobilePreventScreenCapture === 'true',
intunePolicy,
};
if (serverUrl === this.activeServer) {
// If biometric auth is in progress, defer enrollment check
if (this.isCheckingBiometrics) {
logDebug('SecurityManager: Biometric auth in progress, deferring enrollment check');
this.needsEnrollmentCheck = true;
return;
}
// Check if MAM enrollment needed (method handles all checks internally)
const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(serverUrl);
// Only set screen capture policy if enrollment wasn't needed/succeeded
if (enrollmentOk && !this.isEnrolling) {
await IntuneManager.setCurrentIdentity(serverUrl);
this.setScreenCapturePolicy(serverUrl);
}
}
};
onLicenseChanged = async (event: {serverUrl: string; license: ClientLicense}) => {
const {serverUrl, license} = event;
logDebug('SecurityManager: License changed', {isLicensed: license.IsLicensed, sku: license.SkuShortName});
this.serverConfig[serverUrl] = this.serverConfig[serverUrl] || {};
const intunePolicy = await IntuneManager.getPolicy(serverUrl);
this.serverConfig[serverUrl].intunePolicy = intunePolicy;
if (serverUrl === this.activeServer) {
// Check if MAM enrollment needed (method handles all checks internally)
const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(serverUrl);
if (enrollmentOk && !this.isEnrolling) {
await IntuneManager.setCurrentIdentity(serverUrl);
this.setScreenCapturePolicy(serverUrl);
}
}
};
/**
* Handles Intune policy changes from IntuneManager
*/
onIntunePolicyChanged = (event: IntunePolicyChangedEvent) => {
if (!this.initialized) {
return;
}
const {changed, removed, policy, serverUrls} = event;
// Update cached Intune policies for affected servers
for (const serverUrl of serverUrls) {
if (!this.serverConfig[serverUrl]) {
this.serverConfig[serverUrl] = {};
}
if (removed) {
this.serverConfig[serverUrl].intunePolicy = null;
} else if (changed && policy) {
this.serverConfig[serverUrl].intunePolicy = policy;
}
}
// Only re-apply policies if the active server is affected
if (this.activeServer && serverUrls.includes(this.activeServer)) {
this.setScreenCapturePolicy(this.activeServer);
}
};
/**
* Handles enrollment status changes from Intune SDK
*/
onEnrollmentChanged = (event: IntuneEnrollmentChangedEvent) => {
const {enrolled, reason, serverUrls} = event;
logDebug('SecurityManager: Enrollment changed', {enrolled, reason});
if (enrolled) {
// Successful enrollment
this.handleEnrollmentSuccess(serverUrls);
} else {
// Unenrollment - remove intunePolicy from affected servers
this.handleUnenrollment(serverUrls, reason);
}
};
/**
* Handles selective wipe requests from Intune SDK
*/
onWipeRequested = async (event: IntuneWipeRequestedEvent) => {
const {oid, serverUrls} = event;
logDebug('SecurityManager: Wipe requested', {serverCount: serverUrls.length});
let success = true;
// Wipe non-active servers
for await (const serverUrl of serverUrls) {
if (serverUrl === this.activeServer) {
// We do this one last to avoid issues with active server changes
continue;
}
const wipeSuccess = await this.performSelectiveWipe(serverUrl, true);
if (!wipeSuccess) {
success = false;
}
this.removeServer(serverUrl);
}
// Finally wipe active server if needed
if (this.activeServer && serverUrls.includes(this.activeServer)) {
const wipeSuccess = await this.performSelectiveWipe(this.activeServer, false);
if (!wipeSuccess) {
success = false;
}
this.removeServer(this.activeServer);
}
// Cleanup storage and MSAL account after all wipes complete
await IntuneManager.cleanupAfterWipe(oid);
// Report completion status to native (clears pending state if successful)
await IntuneManager.reportWipeComplete(oid, success);
};
/**
* Handles authentication required events from Intune SDK
*/
onAuthRequired = async (event: IntuneAuthRequiredEvent) => {
const {oid, serverUrls, reason} = event;
logDebug('SecurityManager: Auth required', {serverCount: serverUrls.length, reason});
Emm.enableBlurScreen(true);
Emm.applyBlurEffect(20);
this.onWipeRequested({oid, serverUrls});
const locale = await getCurrentUserLocale(serverUrls[0]);
showAuthenticationRequiredAlert(reason, locale, () => {
Emm.removeBlurEffect();
Emm.enableBlurScreen(false);
});
};
/**
* Handles conditional launch blocked events from Intune SDK
*/
onConditionalLaunchBlocked = async (event: IntuneConditionalLaunchBlockedEvent) => {
const {oid, reason, serverUrls} = event;
logDebug('SecurityManager: Conditional launch blocked', {reason, serverCount: serverUrls.length});
if (reason === IntuneConditionalLaunchBlockedReasons.LAUNCH_BLOCKED) {
// Conditional launch policy blocked (OS version, jailbreak, threat level)
// Trigger selective wipe of managed data
Emm.enableBlurScreen(true);
Emm.applyBlurEffect(20);
this.onWipeRequested({oid, serverUrls});
const locale = await getCurrentUserLocale(serverUrls[0]);
showConditionalAccessAlert(locale, () => {
Emm.removeBlurEffect();
Emm.enableBlurScreen(false);
});
} else if (reason === IntuneConditionalLaunchBlockedReasons.LAUNCH_CANCELED) {
// User canceled conditional launch (dismissed PIN/auth prompt)
// Allow retry with biometric prompt
await new Promise((resolve) => setTimeout(resolve, 250));
Emm.enableBlurScreen(true);
Emm.applyBlurEffect(20);
const locale = await getCurrentUserLocale(serverUrls[0]);
await showBiometricFailureAlertForOrganization(serverUrls[0], locale, async () => {
Emm.removeBlurEffect();
// Retry by setting current identity again
await IntuneManager.setCurrentIdentity(serverUrls[0]);
});
Emm.enableBlurScreen(false);
}
};
/**
* Handles identity switch required events from Intune SDK
*/
onIdentitySwitchRequired = async (event: IntuneIdentitySwitchRequiredEvent) => {
const {oid, reason, serverUrls} = event;
logDebug('SecurityManager: Identity switch required', {reason, serverCount: serverUrls.length});
const locale = await getCurrentUserLocale(serverUrls[0]);
this.onWipeRequested({oid, serverUrls});
showIdentitySwitchRequiredAlert(locale);
};
/**
* Checks if EMM is already enabled and setup
* to handle biometric / passcode authentication.
*/
isAuthenticationHandledByEmm = () => {
return ManagedApp.enabled && ManagedApp.inAppPinCode;
};
/**
* Checks if EMM is already enabled and setup
* to handle jailbreak protection.
*/
isJalbreakProtectionHandledByEmm = () => {
return ManagedApp.enabled && ManagedApp.cacheConfig?.jailbreakProtection === 'true';
};
/**
* Checks if EMM is already enabled and setup
* to handle screenshot protection.
*/
isScreenshotProtectionHandledByEmm = () => {
return ManagedApp.enabled && ManagedApp.cacheConfig?.blurApplicationScreen === 'true';
};
/**
* Get the configuration of a server to prevent screenshots.
* MAM policy takes precedence over server config.
*/
isScreenCapturePrevented = (server: string) => {
const config = this.getServerConfig(server);
if (!config) {
return false;
}
// Check Intune MAM policy first - MAM always wins
if (config.intunePolicy?.isScreenCaptureAllowed === false) {
// MAM explicitly disallows screen capture, so server policy doesn't apply
return false;
}
// Fall back to server config if no MAM policy applies
return config.PreventScreenCapture == null ? false : config.PreventScreenCapture;
};
/**
* Checks if the device is Jailbroken or Rooted.
* Skips check if MAM controls jailbreak detection.
*/
isDeviceJailbroken = async (server: string, siteName?: string) => {
if (this.isJalbreakProtectionHandledByEmm()) {
return false;
}
const config = this.getServerConfig(server);
if (!config && !siteName) {
return false;
}
// Skip check if MAM policy is active - MAM handles jailbreak detection
if (config?.intunePolicy != null) {
return false;
}
// Fall back to server config if MAM doesn't control jailbreak detection
if (config?.JailbreakProtection || siteName) {
const isRooted = await isRootedExperimentalAsync();
if (isRooted) {
showDeviceNotTrustedAlert(server, siteName, DEFAULT_LOCALE);
return true;
}
}
return false;
};
/**
* Add the config for a server.
*/
addServer = async (server: string, config?: SecurityClientConfig, authenticated = false, intunePolicy: IntunePolicy | null = null) => {
const mobileConfig: SecurityManagerServerConfig = {
siteName: config?.SiteName,
Biometrics: config?.MobileEnableBiometrics === 'true',
JailbreakProtection: config?.MobileJailbreakProtection === 'true',
PreventScreenCapture: config?.MobilePreventScreenCapture === 'true',
authenticated,
intunePolicy,
};
this.serverConfig[server] = mobileConfig;
};
/**
* Removes a configured server, to be called on logout.
*/
removeServer = async (server: string) => {
delete this.serverConfig[server];
if (server === this.activeServer) {
this.initialized = false;
this.activeServer = undefined;
}
};
/**
* Get the configuration of a server.
*/
getServerConfig = (server: string): SecurityManagerServerConfig| undefined => {
return this.serverConfig[server];
};
/**
* Ensures MAM enrollment for a server that requires it.
* Shows alert with blur screen and performs enrollment via direct IntuneManager calls.
*
* @param serverUrl - Server URL to check and enroll
* @returns Promise<boolean> - true if enrollment OK, false if failed/declined
*/
async ensureMAMEnrollmentForActiveServer(serverUrl: string): Promise<boolean> {
// Check if already enrolling to prevent race conditions
if (this.isEnrolling) {
logDebug('ensureMAMEnrollment: Already enrolling, skipping');
return true;
}
// Check if Intune MAM is enabled
const isIntuneEnabled = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
if (!isIntuneEnabled) {
return true;
}
// Check if already enrolled
const isManaged = await IntuneManager.isManagedServer(serverUrl);
if (isManaged) {
return true;
}
// Get server config and current user
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUser = await getCurrentUser(database);
const authService = await getConfigValue(database, 'IntuneAuthService');
// Check if current user is not using SSO entra login, skip enrollment
if (currentUser && currentUser.authService.toLocaleLowerCase() !== authService?.toLocaleLowerCase()) {
return true;
}
const config = await getConfig(database);
const intuneScope = config.IntuneScope;
if (!intuneScope) {
logError('ensureMAMEnrollment: IntuneScope not configured');
return false;
}
// Set enrolling flag and apply blur
this.isEnrolling = true;
// Get site name and locale for alerts
const siteName = this.serverConfig[serverUrl]?.siteName;
const locale = await getCurrentUserLocale(serverUrl);
// Show alert and handle enrollment
return new Promise(async (resolve) => {
logDebug('ensureMAMEnrollment: Enrollment required', {serverUrl});
Emm.enableBlurScreen(true);
Emm.applyBlurEffect(20);
// Give time for blur effect to apply
await new Promise((resolveEffect) => setTimeout(resolveEffect, 250));
const beforeExit = () => {
// Always clear enrolling flag and remove blur
this.isEnrolling = false;
Emm.removeBlurEffect();
this.setScreenCapturePolicy(serverUrl);
};
const handleEnrollment = async () => {
try {
// Step 1: Acquire MSAL tokens via native login
const tokens = await IntuneManager.login(serverUrl, [intuneScope]);
// Step 2: Enroll in MAM (NO token sent to server - session exists)
// If enrollServer doesn't throw, consider it successful
// Policy and enrollment status will be updated via events
await IntuneManager.enrollServer(serverUrl, tokens.identity);
logDebug('ensureMAMEnrollment: Enrollment successful');
// Always clear enrolling flag and remove blur
beforeExit();
resolve(true);
} catch (error) {
logError('ensureMAMEnrollment: Failed', error);
await showMAMEnrollmentFailedAlert(locale, () => {
// Always clear enrolling flag and remove blur
beforeExit();
logout(serverUrl, undefined, {removeServer: true});
resolve(false);
});
}
};
const handleCancel = () => {
logDebug('ensureMAMEnrollment: User declined enrollment');
// Show declined alert with retry option
showMAMDeclinedAlert(serverUrl, siteName, locale, () => {
beforeExit();
resolve(false);
}, handleEnrollment);
};
// Show enrollment required alert
showMAMEnrollmentRequiredAlert(siteName, locale, handleEnrollment, handleCancel);
});
}
/**
* Switches the active server and applies security policies.
* Called via ACTIVE_SERVER_CHANGED event or directly.
*
* @param serverUrl - Server URL to activate
* @param options - Optional skip flags for certain checks
*/
setActiveServer = async ({serverUrl, options}: {serverUrl: string; options?: ActiveServerOptions}) => {
const opts = options || {};
// Set Intune identity
await IntuneManager.setCurrentIdentity(serverUrl);
if (this.activeServer === serverUrl && !opts.forceSwitch) {
// active server is not changing, so no need to do anything here
return;
}
// Update active server tracking
if (this.activeServer && this.serverConfig[this.activeServer]) {
this.serverConfig[this.activeServer].lastAccessed = Date.now();
}
if (!this.serverConfig[serverUrl]) {
return;
}
this.activeServer = serverUrl;
this.serverConfig[serverUrl].lastAccessed = Date.now();
this.setScreenCapturePolicy(serverUrl);
// Security checks (moved from start() method)
// Order matters: MAM enrollment first, then jailbreak, then biometrics
// This way we only check jailbreak/biometrics if user doesn't need to enroll
// 1. Check MAM enrollment requirement (IMPORTANT: do not skip without explicit flag)
if (!opts.skipMAMEnrollmentCheck) {
const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(serverUrl);
if (!enrollmentOk || this.isEnrolling) {
return;
}
}
// 2. Check jailbreak protection
if (!opts.skipJailbreakCheck) {
const isJailbroken = await this.isDeviceJailbroken(serverUrl);
if (isJailbroken) {
return;
}
}
// 3. Check biometric authentication
if (!opts.skipBiometricCheck) {
await this.authenticateWithBiometricsIfNeeded(serverUrl);
}
};
/**
* Determines if biometric authentication should be prompted.
* MAM policy takes precedence over server config.
*/
authenticateWithBiometricsIfNeeded = async (server: string) => {
if (this.isAuthenticationHandledByEmm()) {
return true;
}
const config = this.getServerConfig(server);
if (!config) {
return true;
}
// Check Intune MAM policy first - if MAM requires PIN, skip server config
if (config.intunePolicy?.isPINRequired === true) {
return true;
}
// Fall back to server config if MAM doesn't require PIN
if (config?.Biometrics) {
const lastAccessed = config?.lastAccessed ?? 0;
const timeSinceLastAccessed = Date.now() - lastAccessed;
if (timeSinceLastAccessed > toMilliseconds({minutes: 5}) || config.authenticated === false) {
return this.authenticateWithBiometrics(server);
}
}
return true;
};
/**
* Handles biometric authentication.
*/
authenticateWithBiometrics = async (server: string, siteName?: string) => {
// Prevent concurrent biometric checks
if (this.isCheckingBiometrics) {
logDebug('SecurityManager: Biometric check already in progress, skipping');
return true;
}
this.isCheckingBiometrics = true;
try {
if (this.isAuthenticationHandledByEmm()) {
return true;
}
const config = this.getServerConfig(server);
if (!config && !siteName) {
return true;
}
// Check Intune MAM policy first - if MAM requires PIN, skip server config
if (config?.intunePolicy?.isPINRequired === true) {
return true;
}
const locale = DEFAULT_LOCALE;
const translations = getTranslations(locale);
const isSecured = await Emm.isDeviceSecured();
if (!isSecured) {
await showNotSecuredAlert(server, siteName, locale);
return false;
}
const shouldBlurOnAuthenticate = server === this.activeServer && this.isScreenCapturePrevented(server);
try {
const auth = await Emm.authenticate({
reason: translations[messages.securedBy.id].replace('{vendor}', siteName || config?.siteName || 'Mattermost'),
fallback: true,
supressEnterPassword: true,
blurOnAuthenticate: shouldBlurOnAuthenticate,
});
if (config) {
config.authenticated = auth;
}
if (!auth) {
throw new Error('Authorization cancelled');
}
} catch (err) {
logError('Failed to authenticate with biometrics', err);
showBiometricFailureAlert(server, shouldBlurOnAuthenticate, siteName, locale);
return false;
}
// After successful authentication, check if enrollment was deferred
if (this.needsEnrollmentCheck) {
this.needsEnrollmentCheck = false;
logDebug('SecurityManager: Triggering deferred enrollment check');
const enrollmentOk = await this.ensureMAMEnrollmentForActiveServer(server);
// Apply screen capture policy if enrollment succeeded
if (enrollmentOk && !this.isEnrolling) {
await IntuneManager.setCurrentIdentity(server);
this.setScreenCapturePolicy(server);
}
}
return true;
} finally {
this.isCheckingBiometrics = false;
}
};
/**
* Sets the screen capture policy for the given server.
*/
setScreenCapturePolicy = (server: string) => {
if (this.isScreenshotProtectionHandledByEmm()) {
return;
}
Emm.enableBlurScreen(this.isScreenCapturePrevented(server));
};
/**
* Gets the shielded screen ID for the screen.
*/
getShieldScreenId = (screen: AvailableScreens, force = false, skip = false) => {
if ((this.activeServer && this.isScreenCapturePrevented(this.activeServer)) || force) {
const name = `${screen}.screen`;
return skip ? `${name}.skip.shielded` : `${name}.shielded`;
}
return `${screen}.screen`;
};
/**
* Checks if saving to a location is allowed by Intune policy.
*/
canSaveToLocation = (serverUrl: string, location: keyof IntunePolicy['allowedSaveLocations']) => {
const policy = this.serverConfig[serverUrl]?.intunePolicy;
if (!policy) {
return true;
}
return policy.allowedSaveLocations[location];
};
// ============================================================================
// Helper Methods for Intune Event Handling
// ============================================================================
/**
* Handle successful enrollment
*/
private handleEnrollmentSuccess = async (serverUrls: string[]) => {
logDebug('SecurityManager: Handling enrollment success', {serverCount: serverUrls.length});
// Fetch policy for first server (all servers for same identity share policy)
const policy = await IntuneManager.getPolicy(serverUrls[0]);
// Update policy for all affected servers
for (const serverUrl of serverUrls) {
if (!this.serverConfig[serverUrl]) {
this.serverConfig[serverUrl] = {};
}
this.serverConfig[serverUrl].intunePolicy = policy;
}
// Set current identity if any affected server is active
const currentServer = await DatabaseManager.getActiveServerUrl();
if (currentServer && serverUrls.includes(currentServer)) {
await IntuneManager.setCurrentIdentity(currentServer);
this.setScreenCapturePolicy(currentServer);
}
};
/**
* Handle unenrollment - remove intunePolicy from affected servers
*/
private handleUnenrollment = async (serverUrls: string[], reason?: string) => {
logDebug('SecurityManager: Handling unenrollment', {serverCount: serverUrls.length, reason});
// Remove intunePolicy from all affected servers
for (const serverUrl of serverUrls) {
if (this.serverConfig[serverUrl]) {
this.serverConfig[serverUrl].intunePolicy = null;
}
}
// Re-apply policies if the active server is affected
const currentServer = await DatabaseManager.getActiveServerUrl();
if (currentServer && serverUrls.includes(currentServer)) {
this.setScreenCapturePolicy(currentServer);
}
};
/**
* Perform selective wipe for affected servers
* @returns true if wipe succeeded, false if it failed
*/
private performSelectiveWipe = async (serverUrl: string, skipEvents: boolean): Promise<boolean> => {
// Try to logout from server (skip alert dialog for automated wipes)
await logout(serverUrl, undefined, {skipServerLogout: false, skipEvents, skipAlert: true});
// Always call terminateSession to clean up local data (even if server logout failed)
if (skipEvents) {
const result = await terminateSession(serverUrl, false);
if (result.error) {
logError('SecurityManager: terminateSession failed', {errors: result.error});
return false;
}
}
logDebug('SecurityManager: Server wiped successfully');
return true;
};
/**
* Retry any pending wipes that failed in a previous app session
*/
private retryPendingWipes = async () => {
const pendingWipes = await IntuneManager.getPendingWipes();
if (pendingWipes.length === 0) {
return;
}
logDebug('SecurityManager: Retrying pending wipes', {count: pendingWipes.length});
pendingWipes.forEach(async (wipe) => {
const {oid, serverUrls} = wipe;
// Retry wipe for each server
const results = await Promise.all(
serverUrls.map((serverUrl) => this.performSelectiveWipe(serverUrl, true)),
);
// Check if all wipes succeeded
const success = results.every((result) => result === true);
// Report completion status (clears pending state if successful)
await IntuneManager.reportWipeComplete(oid, success);
});
};
}
const SecurityManager = new SecurityManagerSingleton();
export default SecurityManager;