From 4589005a54bcae00fa3acfaa5d0487056cf37897 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 10 Dec 2025 08:31:53 +0200 Subject: [PATCH] feat: Add Microsoft Intune MAM authentication support (#34577) * Add Entra ID token authentication and Intune MAM config exposure * Add Intune MAM toggle to Mobile Security admin console * Add IntuneSettings with the AuthService to use and its own TenantID andClientID for the Entra App registration Include Admin console changes switch from /oauth/entra to /oauth/intune endpoint * openAPI documentation --------- Co-authored-by: Mattermost Build Co-authored-by: yasser khan --- api/v4/source/definitions.yaml | 12 + api/v4/source/users.yaml | 139 ++++ .../lib/src/server/default_config.ts | 7 + .../sections/system_users/mobile_security.ts | 69 +- .../lib/src/ui/pages/system_console.ts | 1 + .../system_console/mobile_security.spec.ts | 307 ++++++++ server/channels/app/app.go | 3 + server/channels/app/channels.go | 5 + server/channels/app/enterprise.go | 6 + server/channels/app/oauth.go | 56 ++ server/channels/app/oauth_test.go | 233 ++++++ server/channels/web/oauth.go | 52 ++ server/channels/web/web.go | 3 +- server/config/client.go | 18 + server/config/client_test.go | 151 ++++ server/einterfaces/intune.go | 34 + server/einterfaces/mocks/IntuneInterface.go | 80 ++ server/enterprise/external_imports.go | 2 + server/i18n/en.json | 80 ++ server/public/model/config.go | 91 +++ server/public/model/oauth.go | 9 + .../admin_console/admin_definition.tsx | 171 +++-- .../features/images/intune_mam_svg.tsx | 694 ++++++++++++++++++ .../features/images/mobile_security_svg.tsx | 401 +++++----- .../admin_console/schema_admin_settings.tsx | 2 +- .../src/components/admin_console/types.ts | 2 +- webapp/channels/src/i18n/en.json | 17 + .../src/utils/admin_console_index.test.tsx | 1 + webapp/platform/types/src/config.ts | 9 + 29 files changed, 2389 insertions(+), 266 deletions(-) create mode 100644 server/einterfaces/intune.go create mode 100644 server/einterfaces/mocks/IntuneInterface.go create mode 100644 webapp/channels/src/components/admin_console/feature_discovery/features/images/intune_mam_svg.tsx diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index d62cc296c43..9419724ca7e 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -2375,6 +2375,18 @@ components: private_key_file: description: Status is good when `true` type: boolean + IntuneLoginRequest: + type: object + description: Request body for Microsoft Intune MAM authentication using Azure AD/Entra ID access token + required: + - access_token + properties: + access_token: + type: string + description: Microsoft Entra ID access token obtained via MSAL (Microsoft Authentication Library). This token must be scoped to the Intune MAM app registration and will be validated against the configured tenant. + device_id: + type: string + description: Optional mobile device identifier used for push notifications. If provided, the device will be registered for receiving push notifications. Compliance: type: object properties: diff --git a/api/v4/source/users.yaml b/api/v4/source/users.yaml index bcefb4746d3..3f74721083e 100644 --- a/api/v4/source/users.yaml +++ b/api/v4/source/users.yaml @@ -127,6 +127,145 @@ $ref: "#/components/responses/BadRequest" "403": $ref: "#/components/responses/Forbidden" + /oauth/intune: + post: + tags: + - users + summary: Login with Microsoft Intune MAM + description: > + Authenticate a mobile user using a Microsoft Entra ID (Azure AD) access token + for Intune Mobile Application Management (MAM) protected apps. + + + This endpoint enables authentication for mobile apps protected by Microsoft Intune MAM + policies. The access token is obtained via the Microsoft Authentication Library (MSAL) + and validated against the configured Azure AD tenant and Intune MAM app registration. + + + **Authentication Flow:** + + 1. Mobile app acquires an Entra ID access token via MSAL with the Intune MAM scope + + 2. Token is sent to this endpoint for validation + + 3. Server validates the token signature, claims, and tenant configuration + + 4. User is authenticated or created based on the token claims + + 5. Session token is returned for subsequent API requests + + + **User Provisioning:** + + - **Office365 AuthService**: Users are automatically created on first login using + the `oid` (Azure AD object ID) claim as the unique identifier + + - **SAML AuthService**: Users must first login via web/desktop to establish their + account with the `oid` (Azure AD object ID) as AuthData. Intune MAM + always uses objectId for SAML users. For Entra ID Domain Services LDAP sync, + configure LdapSettings.IdAttribute to `msDS-aadObjectId` to ensure consistency. + + + **Error Handling:** + + This endpoint returns specific HTTP status codes to help mobile apps handle different + error scenarios: + + - `428 Precondition Required`: SAML user needs to login via web/desktop first + + - `403 Forbidden`: Configuration issues or bot accounts + + - `409 Conflict`: User account is deactivated + + - `401 Unauthorized`: Token has expired + + - `400 Bad Request`: Invalid token format, claims, or configuration + + + ##### Permissions + + + No permission required. Authentication is performed via the Entra ID access token. + + + ##### Enterprise Feature + + + Requires Mattermost Enterprise Advanced license and proper Intune MAM configuration + (tenant ID, client ID, and auth service). + operationId: LoginIntune + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IntuneLoginRequest" + description: Intune login credentials containing the Entra ID access token + required: true + responses: + "200": + description: User authentication successful + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: > + Bad request - Invalid token format, signature, claims, or configuration. + Common causes include: invalid JSON body, missing access_token, malformed JWT, + invalid token issuer/audience/tenant, missing required claims (oid, email), + or empty auth data after extraction. + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" + "401": + description: Unauthorized - The Entra ID access token has expired + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" + "403": + description: > + Forbidden - Access denied. Common causes include: Intune MAM not properly + configured or enabled, or user is a bot account (bots cannot use Intune login). + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" + "409": + description: Conflict - User account has been deactivated (DeleteAt != 0) + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" + "428": + description: > + Precondition Required - SAML user account not found. The user must first + login via web or desktop application to establish their Mattermost account + with objectId as AuthData before using mobile Intune MAM authentication. + For Entra ID Domain Services LDAP sync, ensure SamlSettings.IdAttribute references + the objectidentifier claim and LdapSettings.IdAttribute is set to 'msDS-aadObjectId'. + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" + "500": + description: > + Internal Server Error - Server-side error. Common causes include: failed to + initialize JWKS (JSON Web Key Set) from Microsoft's OpenID configuration, + or failed to create user session. + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" + "501": + description: > + Not Implemented - Intune MAM feature is not available. This occurs when + running Mattermost Team Edition or when enterprise features are not loaded. + content: + application/json: + schema: + $ref: "#/components/schemas/AppError" /api/v4/users/logout: post: tags: diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 961e54b6874..3724f942315 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -561,6 +561,13 @@ const defaultServerConfig: AdminConfig = { MobileJailbreakProtection: false, MobileEnableSecureFilePreview: false, MobileAllowPdfLinkNavigation: false, + EnableIntuneMAM: false, + }, + IntuneSettings: { + Enable: false, + TenantId: '', + ClientId: '', + AuthService: '', }, CacheSettings: { CacheType: 'lru', diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_users/mobile_security.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_users/mobile_security.ts index e5017348c19..a3acd91116b 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_users/mobile_security.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_users/mobile_security.ts @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {expect, Locator} from '@playwright/test'; +import {expect, Locator, Page} from '@playwright/test'; /** * System Console -> Environment -> Mobile Security */ export default class MobileSecurity { + readonly page: Page; readonly container: Locator; readonly enableBiometricAuthenticationToggleTrue: Locator; @@ -19,11 +20,22 @@ export default class MobileSecurity { readonly enableSecureFilePreviewToggleFalse: Locator; readonly allowPdfLinkNavigationToggleTrue: Locator; readonly allowPdfLinkNavigationToggleFalse: Locator; + readonly enableIntuneMAMToggleTrue: Locator; + readonly enableIntuneMAMToggleFalse: Locator; + + // New IntuneSettings fields + readonly enableIntuneToggleTrue: Locator; + readonly enableIntuneToggleFalse: Locator; + readonly intuneAuthServiceDropdown: Locator; + readonly intuneTenantIdInput: Locator; + readonly intuneClientIdInput: Locator; + readonly intuneTenantIdRequiredError: Locator; readonly saveButton: Locator; - constructor(container: Locator) { + constructor(container: Locator, page: Page) { this.container = container; + this.page = page; this.enableBiometricAuthenticationToggleTrue = this.container.getByTestId( 'NativeAppSettings.MobileEnableBiometricstrue', @@ -67,9 +79,29 @@ export default class MobileSecurity { 'NativeAppSettings.MobileAllowPdfLinkNavigationfalse', ); + // Legacy Intune toggle (will be removed in Phase 6) + this.enableIntuneMAMToggleTrue = this.container.getByTestId('IntuneSettings.Enabletrue'); + this.enableIntuneMAMToggleFalse = this.container.getByTestId('IntuneSettings.Enablefalse'); + + // New IntuneSettings fields + this.enableIntuneToggleTrue = this.container.getByTestId('IntuneSettings.Enabletrue'); + this.enableIntuneToggleFalse = this.container.getByTestId('IntuneSettings.Enablefalse'); + this.intuneAuthServiceDropdown = this.container.getByTestId('IntuneSettings.AuthServicedropdown'); + this.intuneTenantIdInput = this.container.getByTestId('IntuneSettings.TenantIdinput'); + this.intuneClientIdInput = this.container.getByTestId('IntuneSettings.ClientIdinput'); + this.intuneTenantIdRequiredError = this.container.getByTestId('errorMessage'); + this.saveButton = this.container.getByRole('button', {name: 'Save'}); } + async discardChanges() { + this.page.getByRole('button', {name: 'Yes, Discard'}).click(); + } + + async intuneTenantIdRequiredErrorToBeVisible() { + await expect(this.intuneTenantIdRequiredError).toBeVisible(); + } + async toBeVisible() { await expect(this.container).toBeVisible(); } @@ -114,6 +146,39 @@ export default class MobileSecurity { await this.allowPdfLinkNavigationToggleFalse.click(); } + async clickEnableIntuneMAMToggleTrue() { + await this.enableIntuneMAMToggleTrue.click(); + } + + async selectAuthProvider(value: 'office365' | 'saml') { + await this.intuneAuthServiceDropdown.selectOption(value); + } + + async clickEnableIntuneMAMToggleFalse() { + await this.enableIntuneMAMToggleFalse.click(); + } + + // New IntuneSettings methods + async clickEnableIntuneToggleTrue() { + await this.enableIntuneToggleTrue.click(); + } + + async clickEnableIntuneToggleFalse() { + await this.enableIntuneToggleFalse.click(); + } + + async selectIntuneAuthService(value: 'office365' | 'saml') { + await this.intuneAuthServiceDropdown.selectOption(value); + } + + async fillIntuneTenantId(value: string) { + await this.intuneTenantIdInput.fill(value); + } + + async fillIntuneClientId(value: string) { + await this.intuneClientIdInput.fill(value); + } + async clickSaveButton() { await this.saveButton.click(); } diff --git a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts index daf5cbd31af..1ef638e2872 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts @@ -42,6 +42,7 @@ export default class SystemConsolePage { this.systemUsers = new components.SystemUsers(page.getByTestId('systemUsersSection')); this.mobileSecurity = new components.SystemConsoleMobileSecurity( page.getByTestId('sysconsole_section_MobileSecuritySettings'), + this.page, ); this.featureDiscovery = new components.SystemConsoleFeatureDiscovery(page.getByTestId('featureDiscovery')); diff --git a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts index bee973b3113..b6aab4251b9 100644 --- a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts @@ -185,3 +185,310 @@ test('should show mobile security upsell when not licensed', async ({pw}) => { // * Verify title is correct await systemConsolePage.featureDiscovery.toHaveTitle('Enhance mobile app security with Mattermost Enterprise'); }); + +test('should show and enable Intune MAM when Enterprise Advanced licensed and Office365 configured', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license'); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Configure Office365 settings + const config = await adminClient.getConfig(); + config.Office365Settings.Enable = true; + config.Office365Settings.Id = 'test-client-id'; + config.Office365Settings.Secret = 'test-client-secret'; + config.Office365Settings.DirectoryId = 'test-directory-id'; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify Intune MAM toggle is visible + await expect(systemConsolePage.mobileSecurity.enableIntuneMAMToggleTrue).toBeVisible(); + await expect(systemConsolePage.mobileSecurity.enableIntuneMAMToggleFalse).toBeVisible(); + + // # Enable Intune MAM + await systemConsolePage.mobileSecurity.clickEnableIntuneMAMToggleTrue(); + + // * Verify Intune MAM is enabled + await expect(await systemConsolePage.mobileSecurity.enableIntuneMAMToggleTrue.isChecked()).toBe(true); + + await systemConsolePage.mobileSecurity.selectIntuneAuthService('office365'); + + // # Fill in Intune configuration + await systemConsolePage.mobileSecurity.fillIntuneTenantId('12345678-1234-1234-1234-123456789012'); + await systemConsolePage.mobileSecurity.fillIntuneClientId('87654321-4321-4321-4321-210987654321'); + + // # Save settings + await systemConsolePage.mobileSecurity.clickSaveButton(); + + // # Wait until the save button has settled + await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save'); + + // # Go to any other section and come back to Mobile Security + await systemConsolePage.sidebar.goToItem('Users'); + await systemConsolePage.systemUsers.toBeVisible(); + + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify Intune MAM is still enabled + await expect(await systemConsolePage.mobileSecurity.enableIntuneMAMToggleTrue.isChecked()).toBe(true); +}); + +test('should hide Intune MAM when Office365 is not configured', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license'); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Ensure Office365 is disabled + const config = await adminClient.getConfig(); + config.Office365Settings.Enable = false; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify Intune MAM toggle is visible + await expect(systemConsolePage.mobileSecurity.enableIntuneMAMToggleTrue).toBeVisible(); + await expect(systemConsolePage.mobileSecurity.enableIntuneMAMToggleFalse).toBeVisible(); + await expect(systemConsolePage.mobileSecurity.intuneAuthServiceDropdown).toBeDisabled(); +}); + +test('should configure new IntuneSettings with Office365 auth provider', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license'); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Configure Office365 settings + const config = await adminClient.getConfig(); + config.Office365Settings.Enable = true; + config.Office365Settings.Id = 'test-office365-client-id'; + config.Office365Settings.Secret = 'test-office365-secret'; + config.Office365Settings.DirectoryId = 'test-office365-directory-id'; + config.SamlSettings.EmailAttribute = 'useremail'; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify new Intune toggle is visible + await expect(systemConsolePage.mobileSecurity.enableIntuneToggleTrue).toBeVisible(); + await expect(systemConsolePage.mobileSecurity.enableIntuneToggleFalse).toBeVisible(); + + // # Enable Intune + await systemConsolePage.mobileSecurity.clickEnableIntuneToggleTrue(); + + // * Verify Intune is enabled + expect(await systemConsolePage.mobileSecurity.enableIntuneToggleTrue.isChecked()).toBe(true); + + // # Select Office365 as auth provider + await systemConsolePage.mobileSecurity.selectIntuneAuthService('office365'); + + // # Fill in Intune configuration + await systemConsolePage.mobileSecurity.fillIntuneTenantId('12345678-1234-1234-1234-123456789012'); + await systemConsolePage.mobileSecurity.fillIntuneClientId('87654321-4321-4321-4321-210987654321'); + + // # Save settings + await systemConsolePage.mobileSecurity.clickSaveButton(); + + // # Wait until the save button has settled + await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save'); + + // # Go to any other section and come back to Mobile Security + await systemConsolePage.sidebar.goToItem('Users'); + await systemConsolePage.systemUsers.toBeVisible(); + + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify Intune is still enabled and configured + expect(await systemConsolePage.mobileSecurity.enableIntuneToggleTrue.isChecked()).toBe(true); + expect(await systemConsolePage.mobileSecurity.intuneTenantIdInput.inputValue()).toBe( + '12345678-1234-1234-1234-123456789012', + ); + expect(await systemConsolePage.mobileSecurity.intuneClientIdInput.inputValue()).toBe( + '87654321-4321-4321-4321-210987654321', + ); +}); + +test('should configure new IntuneSettings with SAML auth provider', async ({pw}) => { + // # Configure SAML settings + const {adminUser, adminClient} = await pw.initSetup(); + const config = await adminClient.getConfig(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license'); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Set server URL for fetch calls + const serverUrl = process.env.MM_SERVER_URL || 'http://localhost:8065'; + + // # Upload a valid SAML IdP certificate using fetch + const idpCert = `-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAKC1r6Qw3v6OMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAlVTMRYwFAYDVQQIDA1Tb21lLVN0YXRlMRYwFAYDVQQKDA1FeGFtcGxlIEluYy4wHhcNMTkwMTAxMDAwMDAwWhcNMjkwMTAxMDAwMDAwWjBFMQswCQYDVQQGEwJVUzEWMBQGA1UECAwNU29tZS1TdGF0ZTEWMBQGA1UECgwNRXhhbXBsZSBJbmMuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6QwIDAQABo1AwTjAdBgNVHQ4EFgQU6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMAwGA1UdEwQFMAMBAf8wHwYDVR0jBBgwFoAU6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAKQw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw=\n-----END CERTIFICATE-----\n`; + const idpFormData = new FormData(); + idpFormData.append( + 'certificate', + new Blob([idpCert], {type: 'application/x-x509-ca-cert'}), + 'Intune SAML Test.cer', + ); + await fetch(`${serverUrl}/api/v4/saml/certificate/idp`, { + method: 'POST', + body: idpFormData, + credentials: 'include', + }); + + // # Upload a minimal, valid SP public certificate (PEM-encoded) + const spCert = `-----BEGIN CERTIFICATE-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArv1Qw4v7OMC2r7Qw4v7OMC2r7Qw4v7OMC2r7QwIDAQAB\n-----END CERTIFICATE-----\n`; + const spFormData = new FormData(); + spFormData.append('certificate', new Blob([spCert], {type: 'application/x-x509-ca-cert'}), 'saml-public-cert.pem'); + await fetch(`${serverUrl}/api/v4/saml/certificate/public`, { + method: 'POST', + body: spFormData, + credentials: 'include', + }); + + // # Configure SAML settings + config.SamlSettings.Enable = true; + config.SamlSettings.IdpURL = 'https://example.com/saml'; + config.SamlSettings.IdpDescriptorURL = 'https://example.com/saml/metadata'; + config.SamlSettings.IdpCertificateFile = 'test-cert.pem'; + config.SamlSettings.EmailAttribute = 'useremail'; + config.SamlSettings.UsernameAttribute = 'username'; + config.SamlSettings.ServiceProviderIdentifier = 'sp-entity-id'; + config.SamlSettings.AssertionConsumerServiceURL = 'https://sp.example.com/login'; + config.SamlSettings.IdpCertificateFile = 'saml-idp.crt'; + config.SamlSettings.PrivateKeyFile = 'saml-idp.crt'; + + if ('PublicCertificateFile' in config.SamlSettings) { + config.SamlSettings.PublicCertificateFile = 'saml-public-cert.pem'; + } + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify new Intune toggle is visible + await expect(systemConsolePage.mobileSecurity.enableIntuneToggleTrue).toBeVisible(); + + // # Enable Intune + await systemConsolePage.mobileSecurity.clickEnableIntuneToggleTrue(); + + // # Select SAML as auth provider + await systemConsolePage.mobileSecurity.selectIntuneAuthService('saml'); + + // # Fill in Intune configuration + await systemConsolePage.mobileSecurity.fillIntuneTenantId('abcdef01-2345-6789-abcd-ef0123456789'); + await systemConsolePage.mobileSecurity.fillIntuneClientId('fedcba98-7654-3210-fedc-ba9876543210'); + + // # Save settings + await systemConsolePage.mobileSecurity.clickSaveButton(); + + // # Wait until the save button has settled + await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save'); + + // # Go to any other section and come back to Mobile Security + await systemConsolePage.sidebar.goToItem('Users'); + await systemConsolePage.systemUsers.toBeVisible(); + + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify Intune is still enabled and configured with SAML + expect(await systemConsolePage.mobileSecurity.enableIntuneToggleTrue.isChecked()).toBe(true); + expect(await systemConsolePage.mobileSecurity.intuneTenantIdInput.inputValue()).toBe( + 'abcdef01-2345-6789-abcd-ef0123456789', + ); + expect(await systemConsolePage.mobileSecurity.intuneClientIdInput.inputValue()).toBe( + 'fedcba98-7654-3210-fedc-ba9876543210', + ); +}); + +test('should disable Intune inputs when toggle is off', async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license'); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Configure Office365 settings + const config = await adminClient.getConfig(); + config.Office365Settings.Enable = true; + config.Office365Settings.Id = 'test-client-id'; + config.Office365Settings.Secret = 'test-secret'; + config.Office365Settings.DirectoryId = 'test-directory-id'; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.goToItem('Mobile Security'); + + // * Verify Intune inputs are disabled when toggle is off + expect(await systemConsolePage.mobileSecurity.intuneAuthServiceDropdown.isDisabled()).toBe(true); + expect(await systemConsolePage.mobileSecurity.intuneTenantIdInput.isDisabled()).toBe(true); + expect(await systemConsolePage.mobileSecurity.intuneClientIdInput.isDisabled()).toBe(true); + + // # Enable Intune + await systemConsolePage.mobileSecurity.clickEnableIntuneToggleTrue(); + + // * Verify Intune inputs are now enabled + expect(await systemConsolePage.mobileSecurity.intuneAuthServiceDropdown.isDisabled()).toBe(false); + expect(await systemConsolePage.mobileSecurity.intuneTenantIdInput.isDisabled()).toBe(false); + expect(await systemConsolePage.mobileSecurity.intuneClientIdInput.isDisabled()).toBe(false); +}); diff --git a/server/channels/app/app.go b/server/channels/app/app.go index 46dde490d0c..20cb99fbe11 100644 --- a/server/channels/app/app.go +++ b/server/channels/app/app.go @@ -100,6 +100,9 @@ func (a *App) Notification() einterfaces.NotificationInterface { func (a *App) Saml() einterfaces.SamlInterface { return a.ch.Saml } +func (a *App) Intune() einterfaces.IntuneInterface { + return a.ch.Intune +} func (a *App) Cloud() einterfaces.CloudInterface { return a.ch.srv.Cloud } diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index 330821324a8..2c12c363bbc 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -66,6 +66,7 @@ type Channels struct { Notification einterfaces.NotificationInterface Ldap einterfaces.LdapInterface AccessControl einterfaces.AccessControlServiceInterface + Intune einterfaces.IntuneInterface // These are used to prevent concurrent upload requests // for a given upload session which could cause inconsistencies @@ -135,6 +136,10 @@ func NewChannels(s *Server) (*Channels, error) { }) } + if intuneInterface != nil { + ch.Intune = intuneInterface(New(ServerConnector(ch))) + } + if pushProxyInterface != nil { app := New(ServerConnector(ch)) s.PushProxy = pushProxyInterface(app) diff --git a/server/channels/app/enterprise.go b/server/channels/app/enterprise.go index ae8f0b5d563..fee18740c62 100644 --- a/server/channels/app/enterprise.go +++ b/server/channels/app/enterprise.go @@ -116,6 +116,12 @@ func RegisterPushProxyInterface(f func(*App) einterfaces.PushProxyInterface) { pushProxyInterface = f } +var intuneInterface func(*App) einterfaces.IntuneInterface + +func RegisterIntuneInterface(f func(*App) einterfaces.IntuneInterface) { + intuneInterface = f +} + func (s *Server) initEnterprise() { if cloudInterface != nil { s.Cloud = cloudInterface(s) diff --git a/server/channels/app/oauth.go b/server/channels/app/oauth.go index 310259b95a0..700e883af30 100644 --- a/server/channels/app/oauth.go +++ b/server/channels/app/oauth.go @@ -794,6 +794,62 @@ func (a *App) LoginByOAuth(rctx request.CTX, service string, userData io.Reader, return user, nil } +// LoginByIntune authenticates a user using a Microsoft Entra ID access_token from MSAL +// This is used by mobile clients with Microsoft Intune MAM enabled +func (a *App) LoginByIntune(rctx request.CTX, accessToken string) (*model.User, *model.AppError) { + // Check if Intune interface is available (enterprise feature) + if a.Intune() == nil { + return nil, model.NewAppError( + "App.LoginByIntune", + "api.user.login_by_intune.not_available.app_error", + nil, + "", + http.StatusNotImplemented, + ) + } + + // Check if Intune is configured + if !a.Intune().IsConfigured() { + return nil, model.NewAppError( + "App.LoginByIntune", + "api.user.login_by_intune.not_configured.app_error", + nil, + "", + http.StatusBadRequest, + ) + } + + // Perform Intune login via enterprise interface + user, appErr := a.Intune().Login(rctx, accessToken) + if appErr != nil { + return nil, appErr + } + + // Prevent bot login + if user.IsBot { + return nil, model.NewAppError( + "App.LoginByIntune", + "api.user.login_by_intune.bot_login_forbidden.app_error", + nil, + "", + http.StatusForbidden, + ) + } + + // Check if account is locked/disabled + if user.DeleteAt != 0 { + return nil, model.NewAppError( + "App.LoginByIntune", + "api.user.login_by_intune.account_locked.app_error", + nil, + "user_id="+user.Id, + http.StatusConflict, + ) + } + + return user, nil +} + func (a *App) CompleteSwitchWithOAuth(rctx request.CTX, service string, userData io.Reader, email string, tokenUser *model.User) (*model.User, *model.AppError) { provider, e := a.getSSOProvider(service) if e != nil { diff --git a/server/channels/app/oauth_test.go b/server/channels/app/oauth_test.go index 680268e6370..913b737c3d6 100644 --- a/server/channels/app/oauth_test.go +++ b/server/channels/app/oauth_test.go @@ -1586,3 +1586,236 @@ func TestAuthorizeOAuthUser_InvalidToken(t *testing.T) { assert.Equal(t, "api.user.authorize_oauth_user.invalid_state.app_error", appErr.Id) }) } + +// TestLoginByIntune_InterfaceNotAvailable tests that LoginByIntune returns proper error when enterprise not compiled +func TestLoginByIntune_InterfaceNotAvailable(t *testing.T) { + th := Setup(t).InitBasic(t) + + // Intune interface should be nil in non-enterprise setup + require.Nil(t, th.App.Intune()) + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "fake-token") + + // Should return error + require.Nil(t, user) + require.NotNil(t, appErr) + assert.Equal(t, "api.user.login_by_intune.not_available.app_error", appErr.Id) + assert.Equal(t, http.StatusNotImplemented, appErr.StatusCode) +} + +// TestLoginByIntune_NotConfigured tests that LoginByIntune returns proper error when Intune not configured +func TestLoginByIntune_NotConfigured(t *testing.T) { + th := SetupEnterprise(t).InitBasic(t) + + // Create mock Intune interface + mockIntune := &mocks.IntuneInterface{} + mockIntune.On("IsConfigured").Return(false) + + // Replace Intune interface with mock + originalIntune := th.App.ch.Intune + th.App.ch.Intune = mockIntune + defer func() { + th.App.ch.Intune = originalIntune + }() + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "fake-token") + + // Should return error + require.Nil(t, user) + require.NotNil(t, appErr) + assert.Equal(t, "api.user.login_by_intune.not_configured.app_error", appErr.Id) + assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + mockIntune.AssertExpectations(t) +} + +// TestLoginByIntune_Success_Office365 tests successful login with Office365 auth service +func TestLoginByIntune_Success_Office365(t *testing.T) { + th := SetupEnterprise(t).InitBasic(t) + + // Create test user with Office365 auth + testUser, appErr := th.App.CreateUser(th.Context, &model.User{ + Email: "office365user@example.com", + Username: "office365user", + AuthService: model.ServiceOffice365, + AuthData: model.NewPointer("test-oid-123"), + EmailVerified: true, + }) + require.Nil(t, appErr) + + // Create mock Intune interface + mockIntune := &mocks.IntuneInterface{} + mockIntune.On("IsConfigured").Return(true) + mockIntune.On("Login", mock.Anything, "valid-token").Return(testUser, nil) + + // Replace Intune interface with mock + originalIntune := th.App.ch.Intune + th.App.ch.Intune = mockIntune + defer func() { + th.App.ch.Intune = originalIntune + }() + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "valid-token") + + // Should succeed + require.Nil(t, appErr) + require.NotNil(t, user) + assert.Equal(t, testUser.Id, user.Id) + assert.Equal(t, model.ServiceOffice365, user.AuthService) + + mockIntune.AssertExpectations(t) +} + +// TestLoginByIntune_Success_SAML tests successful login with SAML auth service +func TestLoginByIntune_Success_SAML(t *testing.T) { + th := SetupEnterprise(t).InitBasic(t) + + // Create test user with SAML auth + testUser, appErr := th.App.CreateUser(th.Context, &model.User{ + Email: "samluser@example.com", + Username: "samluser", + AuthService: model.UserAuthServiceSaml, + AuthData: model.NewPointer("test@example.com"), + EmailVerified: true, + }) + require.Nil(t, appErr) + + // Create mock Intune interface + mockIntune := &mocks.IntuneInterface{} + mockIntune.On("IsConfigured").Return(true) + mockIntune.On("Login", mock.Anything, "valid-token").Return(testUser, nil) + + // Replace Intune interface with mock + originalIntune := th.App.ch.Intune + th.App.ch.Intune = mockIntune + defer func() { + th.App.ch.Intune = originalIntune + }() + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "valid-token") + + // Should succeed + require.Nil(t, appErr) + require.NotNil(t, user) + assert.Equal(t, testUser.Id, user.Id) + assert.Equal(t, model.UserAuthServiceSaml, user.AuthService) + + mockIntune.AssertExpectations(t) +} + +// TestLoginByIntune_BotAccountBlocked tests that bot accounts cannot login via Intune +func TestLoginByIntune_BotAccountBlocked(t *testing.T) { + th := SetupEnterprise(t).InitBasic(t) + + // Create bot account + bot := th.CreateBot(t) + botUser, appErr := th.App.GetUser(bot.UserId) + require.Nil(t, appErr) + + // Create mock Intune interface that returns bot user + mockIntune := &mocks.IntuneInterface{} + mockIntune.On("IsConfigured").Return(true) + mockIntune.On("Login", mock.Anything, "bot-token").Return(botUser, nil) + + // Replace Intune interface with mock + originalIntune := th.App.ch.Intune + th.App.ch.Intune = mockIntune + defer func() { + th.App.ch.Intune = originalIntune + }() + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "bot-token") + + // Should be blocked + require.Nil(t, user) + require.NotNil(t, appErr) + assert.Equal(t, "api.user.login_by_intune.bot_login_forbidden.app_error", appErr.Id) + assert.Equal(t, http.StatusForbidden, appErr.StatusCode) + + mockIntune.AssertExpectations(t) +} + +// TestLoginByIntune_AccountLocked tests that deleted/locked accounts cannot login +func TestLoginByIntune_AccountLocked(t *testing.T) { + th := SetupEnterprise(t).InitBasic(t) + + // Create user and then soft delete it + deletedUser, appErr := th.App.CreateUser(th.Context, &model.User{ + Email: "deleteduser@example.com", + Username: "deleteduser", + AuthService: model.ServiceOffice365, + AuthData: model.NewPointer("deleted-oid-123"), + EmailVerified: true, + }) + require.Nil(t, appErr) + + // Soft delete the user (deactivate) + _, appErr = th.App.UpdateActive(th.Context, deletedUser, false) + require.Nil(t, appErr) + + // Reload user to get updated DeleteAt + deletedUser, appErr = th.App.GetUser(deletedUser.Id) + require.Nil(t, appErr) + + // Create mock Intune interface that returns deleted user + mockIntune := &mocks.IntuneInterface{} + mockIntune.On("IsConfigured").Return(true) + mockIntune.On("Login", mock.Anything, "deleted-token").Return(deletedUser, nil) + + // Replace Intune interface with mock + originalIntune := th.App.ch.Intune + th.App.ch.Intune = mockIntune + defer func() { + th.App.ch.Intune = originalIntune + }() + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "deleted-token") + + // Should be blocked + require.Nil(t, user) + require.NotNil(t, appErr) + assert.Equal(t, "api.user.login_by_intune.account_locked.app_error", appErr.Id) + assert.Equal(t, http.StatusConflict, appErr.StatusCode) + + mockIntune.AssertExpectations(t) +} + +// TestLoginByIntune_TokenValidationFailure tests that invalid tokens are rejected +func TestLoginByIntune_TokenValidationFailure(t *testing.T) { + th := SetupEnterprise(t).InitBasic(t) + + // Create mock Intune interface that returns validation error + mockIntune := &mocks.IntuneInterface{} + mockIntune.On("IsConfigured").Return(true) + mockIntune.On("Login", mock.Anything, "invalid-token").Return(nil, model.NewAppError( + "IntuneInterface.Login", + "ent.intune.validate_token.invalid_token.app_error", + nil, + "token validation failed", + http.StatusBadRequest, + )) + + // Replace Intune interface with mock + originalIntune := th.App.ch.Intune + th.App.ch.Intune = mockIntune + defer func() { + th.App.ch.Intune = originalIntune + }() + + // Attempt login + user, appErr := th.App.LoginByIntune(th.Context, "invalid-token") + + // Should return validation error + require.Nil(t, user) + require.NotNil(t, appErr) + assert.Equal(t, "ent.intune.validate_token.invalid_token.app_error", appErr.Id) + assert.Equal(t, http.StatusBadRequest, appErr.StatusCode) + + mockIntune.AssertExpectations(t) +} diff --git a/server/channels/web/oauth.go b/server/channels/web/oauth.go index fedc3c7a4eb..5f217fc3c71 100644 --- a/server/channels/web/oauth.go +++ b/server/channels/web/oauth.go @@ -44,6 +44,9 @@ func (w *Web) InitOAuth() { w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/mobile_login", w.APIHandler(mobileLoginWithOAuth)).Methods(http.MethodGet) w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/signup", w.APIHandler(signupWithOAuth)).Methods(http.MethodGet) + // Intune MAM authentication endpoint + w.MainRouter.Handle("/oauth/intune", w.APIHandler(loginByIntune)).Methods(http.MethodPost) + // Old endpoints for backwards compatibility, needed to not break SSO for any old setups w.MainRouter.Handle("/api/v3/oauth/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet) w.MainRouter.Handle("/signup/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet) @@ -625,3 +628,52 @@ func getAuthorizationServerMetadata(c *Context, w http.ResponseWriter, r *http.R c.Logger.Warn("Error writing authorization server metadata response", mlog.Err(err)) } } + +// loginByIntune handles authentication using Microsoft Intune MAM with Entra ID tokens +func loginByIntune(c *Context, w http.ResponseWriter, r *http.Request) { + var req model.IntuneLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.SetInvalidParamWithErr("request_body", err) + return + } + + if req.AccessToken == "" { + c.SetInvalidParam("access_token") + return + } + + auditRec := c.MakeAuditRecord("login_by_intune", model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + c.LogAudit("attempt") + + // Authenticate user via Intune MAM + user, err := c.App.LoginByIntune(c.AppContext, req.AccessToken) + if err != nil { + c.Err = err + return + } + + auditRec.AddMeta("obtained_user_id", user.Id) + c.LogAuditWithUserId(user.Id, "obtained user") + + isMobile := req.DeviceId != "" + session, err := c.App.DoLogin(c.AppContext, w, r, user, req.DeviceId, isMobile, true, false) + if err != nil { + c.Err = err + return + } + + c.AppContext = c.AppContext.WithSession(session) + + c.App.AttachSessionCookies(c.AppContext, w, r) + + auditRec.Success() + c.LogAuditWithUserId(user.Id, "success") + + user.Sanitize(map[string]bool{}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(user); err != nil { + c.Logger.Warn("Failed to encode user response", mlog.Err(err)) + } +} diff --git a/server/channels/web/web.go b/server/channels/web/web.go index a61bf263aa3..3d04e51f3e5 100644 --- a/server/channels/web/web.go +++ b/server/channels/web/web.go @@ -97,7 +97,8 @@ func IsOAuthAPICall(a *app.App, r *http.Request) bool { if r.URL.Path == path.Join(subpath, "oauth", "apps", "authorized") || r.URL.Path == path.Join(subpath, "oauth", "deauthorize") || - r.URL.Path == path.Join(subpath, "oauth", "access_token") { + r.URL.Path == path.Join(subpath, "oauth", "access_token") || + r.URL.Path == path.Join(subpath, "oauth", "intune") { return true } return false diff --git a/server/config/client.go b/server/config/client.go index de027400d64..33b0e180e4a 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -4,6 +4,7 @@ package config import ( + "fmt" "strconv" "strings" @@ -417,6 +418,23 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["MobilePreventScreenCapture"] = strconv.FormatBool(*c.NativeAppSettings.MobilePreventScreenCapture) props["MobileJailbreakProtection"] = strconv.FormatBool(*c.NativeAppSettings.MobileJailbreakProtection) } + + if model.MinimumEnterpriseAdvancedLicense(license) { + // Check IntuneSettings configuration + intuneEnabled := (c.IntuneSettings.Enable != nil && *c.IntuneSettings.Enable && c.IntuneSettings.IsValid() == nil) + + props["IntuneMAMEnabled"] = strconv.FormatBool(intuneEnabled) + + if intuneEnabled { + // Use IntuneSettings for scope + props["IntuneScope"] = fmt.Sprintf("api://%s/login.mattermost", *c.IntuneSettings.ClientId) + + // Expose AuthService if set + if c.IntuneSettings.AuthService != nil && *c.IntuneSettings.AuthService != "" { + props["IntuneAuthService"] = *c.IntuneSettings.AuthService + } + } + } } for key, value := range c.FeatureFlags.ToMap() { diff --git a/server/config/client_test.go b/server/config/client_test.go index 32205c44ce4..9db5111d581 100644 --- a/server/config/client_test.go +++ b/server/config/client_test.go @@ -423,6 +423,157 @@ func TestGetClientConfig(t *testing.T) { "BurnOnReadDurationMinutes": "10", }, }, + { + "Intune MAM enabled with Enterprise Advanced license and Office365 AuthService", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(model.ServiceOffice365), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "IntuneMAMEnabled": "true", + "IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost", + }, + }, + { + "Intune MAM disabled when not enabled", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(false), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(model.ServiceOffice365), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "IntuneMAMEnabled": "false", + }, + }, + { + "Intune MAM disabled when TenantId is missing", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer(""), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(model.ServiceOffice365), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "IntuneMAMEnabled": "false", + }, + }, + { + "Intune MAM disabled when ClientId is missing", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer(""), + AuthService: model.NewPointer(model.ServiceOffice365), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "IntuneMAMEnabled": "false", + }, + }, + { + "Intune MAM not exposed with lower license tier", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(model.ServiceOffice365), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuProfessional, + }, + map[string]string{}, + }, + { + "Intune MAM not exposed without license", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(model.ServiceOffice365), + }, + }, + "", + nil, + map[string]string{}, + }, + { + "Intune MAM enabled with Enterprise Advanced license and SAML AuthService", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(model.UserAuthServiceSaml), + }, + SamlSettings: model.SamlSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "IntuneMAMEnabled": "true", + "IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost", + "IntuneAuthService": "saml", + }, + }, + { + "Intune MAM disabled when AuthService is missing", + &model.Config{ + IntuneSettings: model.IntuneSettings{ + Enable: model.NewPointer(true), + TenantId: model.NewPointer("12345678-1234-1234-1234-123456789012"), + ClientId: model.NewPointer("87654321-4321-4321-4321-210987654321"), + AuthService: model.NewPointer(""), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "IntuneMAMEnabled": "false", + }, + }, } for _, testCase := range testCases { diff --git a/server/einterfaces/intune.go b/server/einterfaces/intune.go new file mode 100644 index 00000000000..0cd39e80f0d --- /dev/null +++ b/server/einterfaces/intune.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +// IntuneInterface provides methods for Microsoft Intune MAM authentication. +// This allows mobile users to authenticate via Microsoft Entra ID (Azure AD) MSAL tokens +// and map to existing users who login via Office 365 or SAML on other clients. +type IntuneInterface interface { + // IsConfigured checks if Intune MAM is properly configured and enabled. + // Returns true if IntuneSettings.Enable is true and all required configuration is present. + IsConfigured() bool + + // Login authenticates a user using a Microsoft Entra ID access_token from MSAL. + // The token is validated against Microsoft's JWKS endpoint with proper key rollover support. + // The access_token's audience claim is validated against the tenant-specific IntuneScope + // to ensure proper tenant isolation. + // The user is then matched to an existing user based on the configured AuthService + // (either 'office365' or 'saml'), or a new user is created if allowed. + // + // Parameters: + // - rctx: Request context for logging and tracing + // - accessToken: The access_token from MSAL authentication + // + // Returns: + // - user: The authenticated user (matched or newly created) + // - appError: Error if authentication fails + Login(rctx request.CTX, accessToken string) (*model.User, *model.AppError) +} diff --git a/server/einterfaces/mocks/IntuneInterface.go b/server/einterfaces/mocks/IntuneInterface.go new file mode 100644 index 00000000000..f4f9f6bf501 --- /dev/null +++ b/server/einterfaces/mocks/IntuneInterface.go @@ -0,0 +1,80 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// IntuneInterface is an autogenerated mock type for the IntuneInterface type +type IntuneInterface struct { + mock.Mock +} + +// IsConfigured provides a mock function with no fields +func (_m *IntuneInterface) IsConfigured() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for IsConfigured") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Login provides a mock function with given fields: rctx, accessToken +func (_m *IntuneInterface) Login(rctx request.CTX, accessToken string) (*model.User, *model.AppError) { + ret := _m.Called(rctx, accessToken) + + if len(ret) == 0 { + panic("no return value specified for Login") + } + + var r0 *model.User + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.User, *model.AppError)); ok { + return rf(rctx, accessToken) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.User); ok { + r0 = rf(rctx, accessToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.User) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, accessToken) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// NewIntuneInterface creates a new instance of IntuneInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewIntuneInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *IntuneInterface { + mock := &IntuneInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/enterprise/external_imports.go b/server/enterprise/external_imports.go index c7d49938858..724441dbf45 100644 --- a/server/enterprise/external_imports.go +++ b/server/enterprise/external_imports.go @@ -46,4 +46,6 @@ import ( _ "github.com/mattermost/enterprise/message_export/csv_export" // Needed to ensure the init() method in the EE gets run _ "github.com/mattermost/enterprise/message_export/global_relay_export" + // Needed to ensure the init() method in the EE gets run + _ "github.com/mattermost/enterprise/intune" ) diff --git a/server/i18n/en.json b/server/i18n/en.json index 7e6a6eb3c37..bc3bad2f695 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -4410,6 +4410,22 @@ "id": "api.user.login_by_cws.invalid_token.app_error", "translation": "CWS token is not valid" }, + { + "id": "api.user.login_by_intune.account_locked.app_error", + "translation": "Your account has been deactivated. Please contact your system administrator." + }, + { + "id": "api.user.login_by_intune.bot_login_forbidden.app_error", + "translation": "Bot accounts cannot sign in using Microsoft authentication." + }, + { + "id": "api.user.login_by_intune.not_available.app_error", + "translation": "Microsoft Intune authentication is not available." + }, + { + "id": "api.user.login_by_intune.not_configured.app_error", + "translation": "Microsoft Intune authentication is not configured." + }, { "id": "api.user.login_by_oauth.bot_login_forbidden.app_error", "translation": "Bot login is forbidden." @@ -8584,6 +8600,38 @@ "id": "ent.id_loaded.license_disable.app_error", "translation": "Your license does not support ID Loaded Push Notifications." }, + { + "id": "ent.intune.login.account_not_found.app_error", + "translation": "Your account isn't fully set up yet. Please sign in to Mattermost via the web or desktop app first." + }, + { + "id": "ent.intune.login.extract_auth_data.app_error", + "translation": "Authentication failed. Please try again or contact your system administrator." + }, + { + "id": "ent.intune.login.not_configured.app_error", + "translation": "Microsoft Intune authentication is not configured." + }, + { + "id": "ent.intune.validate_token.invalid_tenant_id.app_error", + "translation": "Authentication failed. Invalid configuration." + }, + { + "id": "ent.intune.validate_token.invalid_token.app_error", + "translation": "Authentication failed. Please try again." + }, + { + "id": "ent.intune.validate_token.jwks_init.app_error", + "translation": "Authentication service initialization failed. Please contact your system administrator." + }, + { + "id": "ent.intune.validate_token.missing_claims.app_error", + "translation": "Authentication failed. Required user information is missing." + }, + { + "id": "ent.intune.validate_token.token_expired.app_error", + "translation": "Authentication session has expired. Please try signing in again." + }, { "id": "ent.jobs.start_synchronize_job.timeout", "translation": "Reached AD/LDAP synchronization job timeout." @@ -9812,6 +9860,38 @@ "id": "model.config.is_valid.import.retention_days_too_low.app_error", "translation": "Invalid value for RetentionDays. Value is too low." }, + { + "id": "model.config.is_valid.intune_auth_service.app_error", + "translation": "Intune MAM Auth Service is required when Intune is enabled." + }, + { + "id": "model.config.is_valid.intune_auth_service_invalid.app_error", + "translation": "Intune MAM Auth Service must be either 'office365' or 'saml'." + }, + { + "id": "model.config.is_valid.intune_client_id.app_error", + "translation": "Intune MAM Client ID is required when Intune is enabled." + }, + { + "id": "model.config.is_valid.intune_client_id_format.app_error", + "translation": "Intune MAM Client ID must be a valid UUID." + }, + { + "id": "model.config.is_valid.intune_requires_office365.app_error", + "translation": "Intune MAM requires OpenID Connect (Office 365) to be enabled when Auth Service is set to 'office365'." + }, + { + "id": "model.config.is_valid.intune_requires_saml.app_error", + "translation": "Intune MAM requires SAML to be enabled when Auth Service is set to 'saml'." + }, + { + "id": "model.config.is_valid.intune_tenant_id.app_error", + "translation": "Intune MAM Tenant ID is required when Intune is enabled." + }, + { + "id": "model.config.is_valid.intune_tenant_id_format.app_error", + "translation": "Intune MAM Tenant ID must be a valid UUID." + }, { "id": "model.config.is_valid.invalid_redis_db.app_error", "translation": "Redis DB must have a value greater or equal to zero." diff --git a/server/public/model/config.go b/server/public/model/config.go index 34c7b68c3ff..4434c7492fe 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -1373,6 +1373,69 @@ func (s *Office365Settings) SSOSettings() *SSOSettings { return &ssoSettings } +type IntuneSettings struct { + Enable *bool `access:"mobile_intune"` + TenantId *string `access:"mobile_intune"` // telemetry: none + ClientId *string `access:"mobile_intune"` // telemetry: none + AuthService *string `access:"mobile_intune"` // "office365" or "saml" +} + +func (s *IntuneSettings) SetDefaults() { + if s.Enable == nil { + s.Enable = NewPointer(false) + } + + if s.TenantId == nil { + s.TenantId = NewPointer("") + } + + if s.ClientId == nil { + s.ClientId = NewPointer("") + } + + // AuthService has no default - must be explicitly set + if s.AuthService == nil { + s.AuthService = NewPointer("") + } +} + +func (s *IntuneSettings) IsValid() *AppError { + if s.Enable == nil || !*s.Enable { + return nil // Disabled, no validation needed + } + + // Must have TenantId + if s.TenantId == nil || *s.TenantId == "" { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_tenant_id.app_error", nil, "", http.StatusBadRequest) + } + + // Validate TenantId format (UUID) + uuidRegex := regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + if !uuidRegex.MatchString(*s.TenantId) { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_tenant_id_format.app_error", nil, "", http.StatusBadRequest) + } + + // Must have ClientId + if s.ClientId == nil || *s.ClientId == "" { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_client_id.app_error", nil, "", http.StatusBadRequest) + } + + // Validate ClientId format (UUID) + if !uuidRegex.MatchString(*s.ClientId) { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_client_id_format.app_error", nil, "", http.StatusBadRequest) + } + + // Must have valid AuthService + if s.AuthService == nil || *s.AuthService == "" { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_auth_service.app_error", nil, "AuthService is required", http.StatusBadRequest) + } + if *s.AuthService != UserAuthServiceSaml && *s.AuthService != ServiceOffice365 { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_auth_service_invalid.app_error", nil, "AuthService must be 'office365' or 'saml'", http.StatusBadRequest) + } + + return nil +} + type ReplicaLagSettings struct { DataSource *string `access:"environment,write_restrictable,cloud_restrictable"` // telemetry: none QueryAbsoluteLag *string `access:"environment,write_restrictable,cloud_restrictable"` // telemetry: none @@ -2990,6 +3053,7 @@ type NativeAppSettings struct { MobileJailbreakProtection *bool `access:"site_customization,write_restrictable"` MobileEnableSecureFilePreview *bool `access:"site_customization,write_restrictable"` MobileAllowPdfLinkNavigation *bool `access:"site_customization,write_restrictable"` + EnableIntuneMAM *bool `access:"site_customization,write_restrictable"` // telemetry: none } func (s *NativeAppSettings) SetDefaults() { @@ -3032,6 +3096,10 @@ func (s *NativeAppSettings) SetDefaults() { if s.MobileAllowPdfLinkNavigation == nil { s.MobileAllowPdfLinkNavigation = NewPointer(false) } + + if s.EnableIntuneMAM == nil { + s.EnableIntuneMAM = NewPointer(false) + } } type ElasticsearchSettings struct { @@ -3883,6 +3951,7 @@ type Config struct { LocalizationSettings LocalizationSettings SamlSettings SamlSettings NativeAppSettings NativeAppSettings + IntuneSettings IntuneSettings CacheSettings CacheSettings ClusterSettings ClusterSettings MetricsSettings MetricsSettings @@ -4003,6 +4072,7 @@ func (o *Config) SetDefaults() { o.AutoTranslationSettings.SetDefaults() o.ElasticsearchSettings.SetDefaults() o.NativeAppSettings.SetDefaults() + o.IntuneSettings.SetDefaults() o.DataRetentionSettings.SetDefaults() o.RateLimitSettings.SetDefaults() o.LogSettings.SetDefaults() @@ -4074,6 +4144,27 @@ func (o *Config) IsValid() *AppError { return appErr } + // Validate IntuneSettings + if appErr := o.IntuneSettings.IsValid(); appErr != nil { + return appErr + } + + // Cross-reference validation: IntuneSettings requires either Office365 or SAML to be enabled + if o.IntuneSettings.Enable != nil && *o.IntuneSettings.Enable { + if o.IntuneSettings.AuthService != nil && *o.IntuneSettings.AuthService != "" { + switch *o.IntuneSettings.AuthService { + case ServiceOffice365: + if o.Office365Settings.Enable == nil || !*o.Office365Settings.Enable { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_requires_office365.app_error", nil, "", http.StatusBadRequest) + } + case UserAuthServiceSaml: + if o.SamlSettings.Enable == nil || !*o.SamlSettings.Enable { + return NewAppError("Config.IsValid", "model.config.is_valid.intune_requires_saml.app_error", nil, "", http.StatusBadRequest) + } + } + } + } + if *o.PasswordSettings.MinimumLength < PasswordMinimumLength || *o.PasswordSettings.MinimumLength > PasswordMaximumLength { return NewAppError("Config.IsValid", "model.config.is_valid.password_length.app_error", map[string]any{"MinLength": PasswordMinimumLength, "MaxLength": PasswordMaximumLength}, "", http.StatusBadRequest) } diff --git a/server/public/model/oauth.go b/server/public/model/oauth.go index e5000ac849e..b40bff860ec 100644 --- a/server/public/model/oauth.go +++ b/server/public/model/oauth.go @@ -19,6 +19,15 @@ const ( OAuthActionMobile = "mobile" ) +// IntuneLoginRequest represents a login request using an MSAL access_token from Azure AD/Entra +// for Intune MAM authentication. The access_token is used instead of id_token to validate +// the audience claim against the customer's tenant-specific IntuneScope, ensuring proper +// tenant isolation. +type IntuneLoginRequest struct { + AccessToken string `json:"access_token"` + DeviceId string `json:"device_id"` +} + type OAuthApp struct { Id string `json:"id"` CreatorId string `json:"creator_id"` diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 7cc0986b495..0a05c5052b7 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -86,6 +86,7 @@ import { import AttributeBasedAccessControlFeatureDiscovery from './feature_discovery/features/attribute_based_access_control'; import AutoTranslationFeatureDiscovery from './feature_discovery/features/auto_translation'; import BurnOnReadSVG from './feature_discovery/features/images/burn_on_read_svg'; +import IntuneMAMSvg from './feature_discovery/features/images/intune_mam_svg'; import UserAttributesFeatureDiscovery from './feature_discovery/features/user_attributes'; import FeatureFlags, {messages as featureFlagsMessages} from './feature_flags'; import GroupDetails from './group_settings/group_details'; @@ -2132,51 +2133,139 @@ const AdminDefinition: AdminDefinitionType = { schema: { id: 'MobileSecuritySettings', name: defineMessage({id: 'admin.mobileSecurity.title', defaultMessage: 'Mobile Security'}), - settings: [ + sections: [ { - type: 'bool', - key: 'NativeAppSettings.MobileEnableBiometrics', - label: defineMessage({id: 'admin.mobileSecurity.biometricsTitle', defaultMessage: 'Enable Biometric Authentication:'}), - help_text: defineMessage({id: 'admin.mobileSecurity.biometricsDescription', defaultMessage: 'Enforces biometric authentication (with PIN/passcode fallback) before accessing the app. Users will be prompted based on session activity and server switching rules.'}), + key: 'MobileSecuritySettings.General', + title: 'General Mobile Security', + description: defineMessage({id: 'admin.mobileSecurity.sections.general.description', defaultMessage: 'Configure device security features for the mobile app.'}), + settings: [ + { + type: 'bool', + key: 'NativeAppSettings.MobileEnableBiometrics', + label: defineMessage({id: 'admin.mobileSecurity.biometricsTitle', defaultMessage: 'Enable Biometric Authentication:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.biometricsDescription', defaultMessage: 'Enforces biometric authentication (with PIN/passcode fallback) before accessing the app. Users will be prompted based on session activity and server switching rules.'}), + }, + { + type: 'bool', + key: 'NativeAppSettings.MobilePreventScreenCapture', + label: defineMessage({id: 'admin.mobileSecurity.screenCaptureTitle', defaultMessage: 'Prevent Screen Capture:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.screenCaptureDescription', defaultMessage: 'Blocks screenshots and screen recordings when using the mobile app. Screenshots will appear blank, and screen recordings will blur (iOS) or show a black screen (Android). Also applies when switching apps.'}), + }, + { + type: 'bool', + key: 'NativeAppSettings.MobileJailbreakProtection', + label: defineMessage({id: 'admin.mobileSecurity.jailbreakTitle', defaultMessage: 'Enable Jailbreak/Root Protection:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.jailbreakDescription', defaultMessage: 'Prevents access to the app on devices detected as jailbroken or rooted. If a device fails the security check, users will be denied access or prompted to switch to a compliant server.'}), + }, + { + type: 'bool', + key: 'NativeAppSettings.MobileEnableSecureFilePreview', + label: defineMessage({id: 'admin.mobileSecurity.secureFilePreviewTitle', defaultMessage: 'Enable Secure File Preview Mode:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.secureFilePreviewDescription', defaultMessage: "Prevents file downloads, previews, and sharing for most file types, even if {mobileAllowDownloads} is enabled. Allows in-app previews for PDFs, videos, and images only. Files are stored temporarily in the app's cache and cannot be exported or shared."}), + help_text_values: { + mobileAllowDownloads: ( + + + + + + ), + }, + isHidden: it.not(it.minLicenseTier(LicenseSkus.EnterpriseAdvanced)), + }, + { + type: 'bool', + key: 'NativeAppSettings.MobileAllowPdfLinkNavigation', + label: defineMessage({id: 'admin.mobileSecurity.allowPdfLinkNavigationTitle', defaultMessage: 'Allow Link Navigation in Secure PDFs:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.allowPdfLinkNavigationDescription', defaultMessage: 'Enables tapping links inside PDFs when Secure File Preview Mode is active. Links will open in the device browser or supported app. Has no effect when Secure File Preview Mode is disabled.'}), + isDisabled: it.stateIsFalse('NativeAppSettings.MobileEnableSecureFilePreview'), + isHidden: it.not(it.minLicenseTier(LicenseSkus.EnterpriseAdvanced)), + }, + ], }, { - type: 'bool', - key: 'NativeAppSettings.MobilePreventScreenCapture', - label: defineMessage({id: 'admin.mobileSecurity.screenCaptureTitle', defaultMessage: 'Prevent Screen Capture:'}), - help_text: defineMessage({id: 'admin.mobileSecurity.screenCaptureDescription', defaultMessage: 'Blocks screenshots and screen recordings when using the mobile app. Screenshots will appear blank, and screen recordings will blur (iOS) or show a black screen (Android). Also applies when switching apps.'}), - }, - { - type: 'bool', - key: 'NativeAppSettings.MobileJailbreakProtection', - label: defineMessage({id: 'admin.mobileSecurity.jailbreakTitle', defaultMessage: 'Enable Jailbreak/Root Protection:'}), - help_text: defineMessage({id: 'admin.mobileSecurity.jailbreakDescription', defaultMessage: 'Prevents access to the app on devices detected as jailbroken or rooted. If a device fails the security check, users will be denied access or prompted to switch to a compliant server.'}), - }, - { - type: 'bool', - key: 'NativeAppSettings.MobileEnableSecureFilePreview', - label: defineMessage({id: 'admin.mobileSecurity.secureFilePreviewTitle', defaultMessage: 'Enable Secure File Preview Mode:'}), - help_text: defineMessage({id: 'admin.mobileSecurity.secureFilePreviewDescription', defaultMessage: 'Prevents file downloads, previews, and sharing for most file types, even if {mobileAllowDownloads} is enabled. Allows in-app previews for PDFs, videos, and images only. Files are stored temporarily in the app’s cache and cannot be exported or shared.'}), - help_text_values: { - mobileAllowDownloads: ( - - - - - - ), + key: 'MobileSecuritySettings.Intune', + title: 'Microsoft Intune', + description: defineMessage({id: 'admin.mobileSecurity.sections.intune.description', defaultMessage: 'Configure Microsoft Intune Mobile Application Management (MAM) for App Protection Policies.'}), + license_sku: LicenseSkus.EnterpriseAdvanced, + component: LicensedSectionContainer, + componentProps: { + requiredSku: LicenseSkus.EnterpriseAdvanced, + featureDiscoveryConfig: { + featureName: 'intune_mam', + title: defineMessage({id: 'admin.intune_feature_discovery.title', defaultMessage: 'Protect mobile data with Microsoft Intune App Protection Policies (MAM) and Entra ID authentication'}), + description: defineMessage({id: 'admin.intune_feature_discovery.description', defaultMessage: 'With Mattermost Enterprise Advanced, you can enable Microsoft Intune Mobile Application Management (MAM) to enforce App Protection Policies (APP) on Mattermost Mobile. Users sign in with Microsoft Entra ID (Azure AD), and Intune MAM applies data protection, selective wipe, and compliance policies on supported iOS devices.'}), + learnMoreURL: 'https://docs.mattermost.com/deployment/intune-mam.html', + svgImage: IntuneMAMSvg, + }, }, - isHidden: it.not(it.minLicenseTier(LicenseSkus.EnterpriseAdvanced)), - }, - { - type: 'bool', - key: 'NativeAppSettings.MobileAllowPdfLinkNavigation', - label: defineMessage({id: 'admin.mobileSecurity.allowPdfLinkNavigationTitle', defaultMessage: 'Allow Link Navigation in Secure PDFs:'}), - help_text: defineMessage({id: 'admin.mobileSecurity.allowPdfLinkNavigationDescription', defaultMessage: 'Enables tapping links inside PDFs when Secure File Preview Mode is active. Links will open in the device browser or supported app. Has no effect when Secure File Preview Mode is disabled.'}), - isDisabled: it.stateIsFalse('NativeAppSettings.MobileEnableSecureFilePreview'), - isHidden: it.not(it.minLicenseTier(LicenseSkus.EnterpriseAdvanced)), + settings: [ + { + type: 'bool', + key: 'IntuneSettings.Enable', + label: defineMessage({id: 'admin.intune.enableTitle', defaultMessage: 'Enable Microsoft Intune MAM:'}), + help_text: defineMessage({id: 'admin.intune.enableDescription', defaultMessage: 'When enabled, Mattermost Mobile uses Microsoft Entra ID (Azure AD) for app authentication and policy enforcement. Users authenticate using MSAL tokens, and Intune MAM policies (App Protection Policies) are applied to protect corporate data.'}), + }, + { + type: 'dropdown', + key: 'IntuneSettings.AuthService', + label: defineMessage({id: 'admin.intune.authServiceTitle', defaultMessage: 'Auth Provider:'}), + help_text: defineMessage({id: 'admin.intune.authServiceDescription', defaultMessage: 'Select how users authenticate into Mattermost.\n* **OpenID Connect** – Use when users sign in to Mattermost via Microsoft 365 / Entra ID using OIDC.\n* **SAML 2.0** – Use when users authenticate via a SAML provider that ultimately maps to Microsoft Entra ID.\nChoose the option that matches how your organization already authenticates users into Mattermost on other clients.'}), + help_text_markdown: true, + options: [ + { + value: '', + display_name: defineMessage({id: 'admin.intune.authServicePlaceholder', defaultMessage: 'Select an auth provider'}), + }, + { + value: 'office365', + display_name: defineMessage({id: 'admin.intune.authServiceOffice365', defaultMessage: 'OpenID Connect (Office 365)'}), + isHidden: it.configIsFalse('Office365Settings', 'Enable'), + }, + { + value: 'saml', + display_name: defineMessage({id: 'admin.intune.authServiceSaml', defaultMessage: 'SAML 2.0'}), + isHidden: it.configIsFalse('SamlSettings', 'Enable'), + }, + ], + isDisabled: it.stateIsFalse('IntuneSettings.Enable'), + }, + { + type: 'text', + key: 'IntuneSettings.TenantId', + label: defineMessage({id: 'admin.intune.tenantIdTitle', defaultMessage: 'Tenant ID:'}), + help_text: defineMessage({id: 'admin.intune.tenantIdDescription', defaultMessage: 'The Microsoft Entra ID (Azure AD) Tenant ID (also called the Directory ID).\nThis is the globally unique identifier that represents your organization in Microsoft Entra ID.\nMattermost uses this ID to validate tokens issued for Intune MAM.'}), + placeholder: defineMessage({id: 'admin.intune.tenantIdPlaceholder', defaultMessage: 'E.g.: "12345678-1234-1234-1234-123456789012"'}), + isDisabled: it.stateIsFalse('IntuneSettings.Enable'), + default: (value, config, state) => { + if (state['IntuneSettings.Enable'] && !state['IntuneSettings.TenantId']) { + if (state['IntuneSettings.AuthService'] === 'office365' && config.Office365Settings?.DirectoryId) { + return config.Office365Settings?.DirectoryId; + } + } + return ''; + }, + }, + { + type: 'text', + key: 'IntuneSettings.ClientId', + label: defineMessage({id: 'admin.intune.clientIdTitle', defaultMessage: 'Application (Client) ID:'}), + help_text: defineMessage({id: 'admin.intune.clientIdDescription', defaultMessage: 'The Application (Client) ID of your Intune MAM–enabled app registration in Microsoft Entra ID.\nThis is the client identifier that the Mattermost Mobile app uses to request MSAL tokens for Intune MAM enrollment and policy evaluation.'}), + placeholder: defineMessage({id: 'admin.intune.clientIdPlaceholder', defaultMessage: 'E.g.: "87654321-4321-4321-4321-210987654321"'}), + isDisabled: it.stateIsFalse('IntuneSettings.Enable'), + default: (value, config, state) => { + if (state['IntuneSettings.Enable'] && !state['IntuneSettings.TenantId']) { + if (state['IntuneSettings.AuthService'] === 'office365' && config.Office365Settings?.Id) { + return config.Office365Settings?.Id; + } + } + return ''; + }, + }, + ], }, ], }, diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/images/intune_mam_svg.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/images/intune_mam_svg.tsx new file mode 100644 index 00000000000..e69907f2ee6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/images/intune_mam_svg.tsx @@ -0,0 +1,694 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +type SvgProps = { + width?: number; + height?: number; +} + +const IntuneMAMSvg = (props: SvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default IntuneMAMSvg; diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/images/mobile_security_svg.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/images/mobile_security_svg.tsx index 0cb7efef50e..7408097b4f2 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/features/images/mobile_security_svg.tsx +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/images/mobile_security_svg.tsx @@ -10,207 +10,168 @@ type SvgProps = { const MobileSecuritySVG = (props: SvgProps) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + ( cy='64.831' r='5.83099' fill='#3F4350' - fillOpacity='0.32' + fillOpacity='0.56' /> ( /> - + + + + + ); diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx index c7b511da3d0..2fa467deda0 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx @@ -455,7 +455,7 @@ export class SchemaAdminSettings extends React.PureComponent, state: any) => string; max_length?: number; - default?: string; + default?: string | ((value: any, config: Partial, state: any) => string); } type AdminDefinitionSettingGenerated = AdminDefinitionSettingBase & { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 5f602b5b92d..5514e5b28a3 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1383,6 +1383,21 @@ "admin.integrations.gif": "GIF", "admin.integrations.integrationManagement": "Integration Management", "admin.integrations.integrationManagement.title": "Integration Management", + "admin.intune_feature_discovery.description": "With Mattermost Enterprise Advanced, you can enable Microsoft Intune Mobile Application Management (MAM) to enforce App Protection Policies (APP) on Mattermost Mobile. Users sign in with Microsoft Entra ID (Azure AD), and Intune MAM applies data protection, selective wipe, and compliance policies on supported iOS devices.", + "admin.intune_feature_discovery.title": "Protect mobile data with Microsoft Intune App Protection Policies (MAM) and Entra ID authentication", + "admin.intune.authServiceDescription": "Select how users authenticate into Mattermost.\n* **OpenID Connect** – Use when users sign in to Mattermost via Microsoft 365 / Entra ID using OIDC.\n* **SAML 2.0** – Use when users authenticate via a SAML provider that ultimately maps to Microsoft Entra ID.\nChoose the option that matches how your organization already authenticates users into Mattermost on other clients.", + "admin.intune.authServiceOffice365": "OpenID Connect (Office 365)", + "admin.intune.authServicePlaceholder": "Select an auth provider", + "admin.intune.authServiceSaml": "SAML 2.0", + "admin.intune.authServiceTitle": "Auth Provider:", + "admin.intune.clientIdDescription": "The Application (Client) ID of your Intune MAM–enabled app registration in Microsoft Entra ID.\nThis is the client identifier that the Mattermost Mobile app uses to request MSAL tokens for Intune MAM enrollment and policy evaluation.", + "admin.intune.clientIdPlaceholder": "E.g.: \"87654321-4321-4321-4321-210987654321\"", + "admin.intune.clientIdTitle": "Application (Client) ID:", + "admin.intune.enableDescription": "When enabled, Mattermost Mobile uses Microsoft Entra ID (Azure AD) for app authentication and policy enforcement. Users authenticate using MSAL tokens, and Intune MAM policies (App Protection Policies) are applied to protect corporate data.", + "admin.intune.enableTitle": "Enable Microsoft Intune MAM:", + "admin.intune.tenantIdDescription": "The Microsoft Entra ID (Azure AD) Tenant ID (also called the Directory ID).\nThis is the globally unique identifier that represents your organization in Microsoft Entra ID.\nMattermost uses this ID to validate tokens issued for Intune MAM.", + "admin.intune.tenantIdPlaceholder": "E.g.: \"12345678-1234-1234-1234-123456789012\"", + "admin.intune.tenantIdTitle": "Tenant ID:", "admin.ip_filtering.add_filter": "Add a filter", "admin.ip_filtering.add_ip_filter": "Add IP Filter", "admin.ip_filtering.add_your_ip": "Add your IP address", @@ -1784,6 +1799,8 @@ "admin.mobileSecurity.mobileAllowDownloads": "Site Configuration > File Sharing and Downloads > Allow File Downloads on Mobile", "admin.mobileSecurity.screenCaptureDescription": "Blocks screenshots and screen recordings when using the mobile app. Screenshots will appear blank, and screen recordings will blur (iOS) or show a black screen (Android). Also applies when switching apps.", "admin.mobileSecurity.screenCaptureTitle": "Prevent Screen Capture:", + "admin.mobileSecurity.sections.general.description": "Configure device security features for the mobile app.", + "admin.mobileSecurity.sections.intune.description": "Configure Microsoft Intune Mobile Application Management (MAM) for App Protection Policies.", "admin.mobileSecurity.secureFilePreviewDescription": "Prevents file downloads, previews, and sharing for most file types, even if {mobileAllowDownloads} is enabled. Allows in-app previews for PDFs, videos, and images only. Files are stored temporarily in the app’s cache and cannot be exported or shared.", "admin.mobileSecurity.secureFilePreviewTitle": "Enable Secure File Preview Mode:", "admin.mobileSecurity.title": "Mobile Security", diff --git a/webapp/channels/src/utils/admin_console_index.test.tsx b/webapp/channels/src/utils/admin_console_index.test.tsx index 91626936836..97f102278cb 100644 --- a/webapp/channels/src/utils/admin_console_index.test.tsx +++ b/webapp/channels/src/utils/admin_console_index.test.tsx @@ -30,6 +30,7 @@ describe('AdminConsoleIndex.generateIndex', () => { 'authentication/saml', 'environment/session_lengths', 'authentication/email', + 'environment/mobile_security', 'experimental/features', ]); expect(idx.search('nginx')).toEqual([ diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index b174bafdd0f..3de3199aa24 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -807,6 +807,14 @@ export type NativeAppSettings = { MobileJailbreakProtection: boolean; MobileEnableSecureFilePreview: boolean; MobileAllowPdfLinkNavigation: boolean; + EnableIntuneMAM: boolean; +}; + +export type IntuneSettings = { + Enable: boolean; + TenantId?: string; + ClientId?: string; + AuthService?: string; }; export type ClusterSettings = { @@ -1052,6 +1060,7 @@ export type AdminConfig = { LocalizationSettings: LocalizationSettings; SamlSettings: SamlSettings; NativeAppSettings: NativeAppSettings; + IntuneSettings: IntuneSettings; ClusterSettings: ClusterSettings; MetricsSettings: MetricsSettings; ExperimentalSettings: ExperimentalSettings;