mattermost-mobile/app/managers/intune_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

357 lines
12 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {Platform, type EventSubscription} from 'react-native';
import {getCurrentUserLocale} from '@actions/local/user';
import {License} from '@constants';
import DatabaseManager from '@database/manager';
import {getConfig, getLicense} from '@queries/servers/system';
import {showBiometricFailureAlertForOrganization} from '@utils/alerts';
import {isMinimumLicenseTier} from '@utils/helpers';
import {logDebug, logError, logWarning} from '@utils/log';
import type {
IntuneAuthRequiredEvent,
IntuneConditionalLaunchBlockedEvent,
IntuneEnrollmentChangedEvent,
IntuneIdentitySwitchRequiredEvent,
IntunePolicyChangedEvent,
IntuneWipeRequestedEvent,
MSALIdentity,
MSALTokens,
IntuneSpec,
IntunePolicy,
} from './types';
let Intune: IntuneSpec | null = null;
if (Platform.OS === 'ios') {
try {
Intune = require('@mattermost/intune').default;
} catch {
// Intune library not available
logWarning('Intune library not available - MAM features disabled');
}
}
/**
* IntuneManager - Thin wrapper for Microsoft Intune MAM integration
*
* Provides helper methods for enrollment, policy access, and identity management.
* Event handling is delegated to SecurityManager via subscription methods.
*/
export class IntuneManagerSingleton {
isIntuneEnabledForConfigAndLicense(config: ClientConfig, license?: ClientLicense): boolean {
return Boolean(Intune) && isMinimumLicenseTier(license, License.SKU_SHORT_NAME.EnterpriseAdvanced) &&
config.IntuneMAMEnabled === 'true' && Boolean(config.IntuneScope) && Boolean(config.IntuneAuthService);
}
async isIntuneMAMEnabledForServer(serverUrl: string): Promise<boolean> {
if (!Intune) {
return false;
}
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const config = await getConfig(database);
const license = await getLicense(database);
return this.isIntuneEnabledForConfigAndLicense(config, license);
} catch {
return false;
}
}
/**
* Native OIDC login: acquire MSAL tokens with custom scopes
* @param serverUrl - The Mattermost server URL (used to store scope)
* @param scopes - Array of OAuth scopes (e.g., IntuneScope from server)
* @returns MSALTokens containing idToken, accessToken, and identity
*/
async login(serverUrl: string, scopes: string[]): Promise<MSALTokens> {
if (!Intune) {
throw new Error('IntuneManager: Intune library not available');
}
try {
logDebug('IntuneManager: Starting native OIDC login');
const tokens = await Intune.login(serverUrl, scopes);
return tokens;
} catch (error) {
logError('IntuneManager: Native OIDC login failed', error);
throw error;
}
}
/**
* Enroll a server in MAM
* Automatically acquires MAM consent before enrolling to prevent duplicate prompts
* @param serverUrl - The Mattermost server URL
* @param identity - The MSAL identity (upn, tid, oid)
*/
async enrollServer(serverUrl: string, identity: MSALIdentity): Promise<void> {
if (!Intune) {
logWarning('IntuneManager: Cannot enroll in MAM - Intune library not available');
return;
}
try {
logDebug('IntuneManager: Enrolling in MAM');
await Intune.enrollInMAM(serverUrl, identity);
} catch (error) {
logError('IntuneManager: MAM enrollment failed', error);
throw error;
}
}
/**
* Unenroll a server from Intune MAM
* @param serverUrl - The Mattermost server URL
* @param doWipe - Whether to perform selective wipe
*/
async unenrollServer(serverUrl: string, doWipe: boolean): Promise<void> {
if (!Intune) {
logWarning('IntuneManager: Cannot unenroll - Intune library not available');
return;
}
try {
const currentServer = await DatabaseManager.getActiveServerUrl();
const isManaged = await Intune.isManagedServer(serverUrl);
if (!isManaged) {
logDebug('IntuneManager: Server not enrolled in MAM, skipping unenrollment');
return;
}
logDebug('IntuneManager: Starting unenrollment', {doWipe});
if (currentServer === serverUrl) {
await Intune.setCurrentIdentity(null);
}
await Intune.deregisterAndUnenroll(serverUrl, doWipe);
} catch (error) {
logError('IntuneManager: Unenrollment failed', error);
}
}
/**
* Cleanup storage and MSAL account after selective wipe completes
* This is called by SecurityManager after wipe operations complete successfully
* to remove the OID-to-serverUrl mappings from keychain and delete the MSAL account.
* @param oid - The Object ID (OID) to cleanup
*/
async cleanupAfterWipe(oid: string): Promise<void> {
if (!Intune) {
return;
}
try {
await Intune.cleanupAfterWipe(oid);
} catch (error) {
logError('IntuneManager: Cleanup after wipe failed', error);
}
}
/**
* Check if a server is managed by Intune
* @param serverUrl - The Mattermost server URL
* @returns true if server is Intune-managed
*/
async isManagedServer(serverUrl: string): Promise<boolean> {
if (!Intune) {
return false;
}
try {
return await Intune.isManagedServer(serverUrl);
} catch (error) {
logError('IntuneManager: Failed to check managed status', error);
return false;
}
}
/**
* Set the current identity for Intune SDK context
* @param serverUrl - The server URL to set as current, or null to clear
*/
async setCurrentIdentity(serverUrl: string | null): Promise<void> {
if (!Intune) {
return;
}
try {
if (serverUrl) {
const isEnabled = await this.isIntuneMAMEnabledForServer(serverUrl);
if (!isEnabled) {
logDebug('IntuneManager: Server is not licensed or configured, clearing current identity');
await Intune.setCurrentIdentity(null);
logDebug('IntuneManager: Current identity cleared');
return;
}
}
await Intune.setCurrentIdentity(serverUrl);
const identity = serverUrl ? 'set' : 'cleared';
logDebug(`IntuneManager: Current identity ${identity}`);
} catch (error) {
logError('IntuneManager: Failed to set current identity', error);
if (serverUrl) {
await new Promise((resolve) => setTimeout(resolve, 250));
Emm.enableBlurScreen(true);
Emm.applyBlurEffect(20);
const locale = await getCurrentUserLocale(serverUrl);
await showBiometricFailureAlertForOrganization(serverUrl, locale, () => {
Emm.removeBlurEffect();
this.setCurrentIdentity(serverUrl);
});
Emm.enableBlurScreen(false);
}
}
}
/**
* Get the Intune policy for a server
* @param serverUrl - The Mattermost server URL
* @returns IntunePolicy if server is managed, null otherwise
*/
async getPolicy(serverUrl: string): Promise<IntunePolicy | null> {
if (!Intune) {
return null;
}
try {
const isIntuneEnabled = await this.isIntuneMAMEnabledForServer(serverUrl);
if (!isIntuneEnabled) {
return null;
}
const isManaged = await Intune.isManagedServer(serverUrl);
if (!isManaged) {
return null;
}
return await Intune.getPolicy(serverUrl);
} catch (error) {
logError('IntuneManager: Failed to get policy', error);
return null;
}
}
/**
* Subscribe to Intune policy change events
* @param handler - The callback to handle policy changes
* @returns EventSubscription that can be removed
*/
subscribeToPolicyChanges(handler: (event: IntunePolicyChangedEvent) => void): EventSubscription | undefined {
if (!Intune) {
return undefined;
}
return Intune.onIntunePolicyChanged(handler);
}
/**
* Subscribe to Intune enrollment change events
* @param handler - The callback to handle enrollment changes
* @returns EventSubscription that can be removed
*/
subscribeToEnrollmentChanges(handler: (event: IntuneEnrollmentChangedEvent) => void): EventSubscription | undefined {
if (!Intune) {
return undefined;
}
return Intune.onIntuneEnrollmentChanged(handler);
}
/**
* Subscribe to Intune wipe requested events
* @param handler - The callback to handle wipe requests
* @returns EventSubscription that can be removed
*/
subscribeToWipeRequests(handler: (event: IntuneWipeRequestedEvent) => void): EventSubscription | undefined {
if (!Intune) {
return undefined;
}
return Intune.onIntuneWipeRequested(handler);
}
/**
* Subscribe to Intune auth required events
* @param handler - The callback to handle auth requirements
* @returns EventSubscription that can be removed
*/
subscribeToAuthRequired(handler: (event: IntuneAuthRequiredEvent) => void): EventSubscription | undefined {
if (!Intune) {
return undefined;
}
return Intune.onIntuneAuthRequired(handler);
}
/**
* Subscribe to Intune conditional launch blocked events
* @param handler - The callback to handle conditional launch blocks
* @returns EventSubscription that can be removed
*/
subscribeToConditionalLaunchBlocked(handler: (event: IntuneConditionalLaunchBlockedEvent) => void): EventSubscription | undefined {
if (!Intune) {
return undefined;
}
return Intune.onIntuneConditionalLaunchBlocked(handler);
}
/**
* Subscribe to Intune identity switch required events
* @param handler - The callback to handle identity switch requirements
* @returns EventSubscription that can be removed
*/
subscribeToIdentitySwitchRequired(handler: (event: IntuneIdentitySwitchRequiredEvent) => void): EventSubscription | undefined {
if (!Intune) {
return undefined;
}
return Intune.onIntuneIdentitySwitchRequired(handler);
}
/**
* Report wipe completion status to native layer
* Clears pending state if successful, retains for retry if failed
* @param oid - The OID to report completion for
* @param success - Whether the wipe was successful
*/
async reportWipeComplete(oid: string, success: boolean): Promise<void> {
if (!Intune) {
return;
}
try {
await Intune.reportWipeComplete(oid, success);
logDebug(`IntuneManager: Wipe completion reported (success: ${success})`);
} catch (error) {
logError('IntuneManager: Failed to report wipe completion', error);
}
}
/**
* Get pending wipes that need to be retried
* Filters out stale wipes (> 7 days old) automatically
* @returns Array of pending wipes (empty array if none)
*/
async getPendingWipes(): Promise<Array<{oid: string; serverUrls: string[]; timestamp: number}>> {
if (!Intune) {
return [];
}
try {
const pendingWipes = await Intune.getPendingWipes();
logDebug(`IntuneManager: Retrieved ${pendingWipes.length} pending wipe(s)`);
return pendingWipes;
} catch (error) {
logError('IntuneManager: Failed to get pending wipes', error);
return [];
}
}
}
const IntuneManager = new IntuneManagerSingleton();
export default IntuneManager;