diff --git a/changelog.txt b/changelog.txt index e9e81c3f4d..998bd82527 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [MINOR] Implement TenantUtil (#2761) - [MAJOR] Update proguard rules in common (#2756) - [MINOR] Add query parameter for Android Release OS Version (#2754) - [MINOR] Add client scenario to JwtRequestBody (#2755) diff --git a/common4j/src/main/com/microsoft/identity/common/java/util/TenantUtil.kt b/common4j/src/main/com/microsoft/identity/common/java/util/TenantUtil.kt new file mode 100644 index 0000000000..3880b3ad50 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/util/TenantUtil.kt @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.util + +import com.microsoft.identity.common.java.authorities.AzureActiveDirectoryAudience +import com.microsoft.identity.common.java.logging.Logger +import com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectory + +/** + * Utility object for tenant-related operations. + * + * Provides methods to extract tenant information from various identifier formats + * such as email addresses, UPNs (User Principal Names), and tenant GUIDs. + */ +object TenantUtil { + private const val TAG: String = "TenantUtil" + private val EMAIL_REGEX = Regex("""^[^@]+@[^@]+\.[^@]+$""") + private val UUID_REGEX = Regex("""^[0-9A-Fa-f\-]{36}$""") + + + /** + * Extracts tenant information from an identifier. + * + * This method can handle two types of identifiers: + * - Email addresses/UPNs: Returns the domain part after the "@" symbol + * - Tenant GUIDs: Returns the GUID as-is + * + * @param identifier The identifier string which could be: + * - An email address or UPN (e.g., "user@contoso.com") + * - A GUID representing a tenant ID (e.g., "12345678-1234-1234-1234-123456789012") + * - Can be null or blank + * @return The extracted tenant (hostname for UPNs or tenant ID for GUIDs), + * or null if the identifier is invalid, null, or blank + */ + fun getTenantFromIdentifier(identifier: String?): String? { + val methodTag = "$TAG:getTenantFromIdentifier" + if (identifier.isNullOrBlank()) { + return null + } + + if (UUID_REGEX.matches(identifier)) { + return identifier + } + + if (EMAIL_REGEX.matches(identifier)) { + return identifier.substringAfter("@").trim() + } + + Logger.warn(methodTag, "Identifier is neither a valid email/UPN nor a GUID.") + return null + } + + + /** + * Extracts tenant ID from a login hint by resolving the tenant information. + * + * This method first extracts the tenant name from the login hint, then attempts to + * resolve it to a tenant ID by loading the OpenID provider configuration metadata + * for the specified tenant. + * + * @param loginHint The login hint string (e.g., "user@contoso.com") + * @param correlationId Correlation ID for the request, u + * @return The resolved tenant ID if successful, null if the login hint is invalid, + * the tenant cannot be resolved, or if an error occurs during resolution + */ + fun getTenantIdFromLoginHint(loginHint: String?, correlationId: String?): String? { + val methodTag = "$TAG:getTenantIdFromLoginHint" + val tenantName = getTenantFromIdentifier(loginHint) ?: run { + Logger.warn(methodTag, correlationId, "Login hint is invalid or empty.") + return null + } + try { + val configuration = + AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant(tenantName) + val tenantId = + AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(configuration) + Logger.info(methodTag, correlationId, "Successfully got tenant ID from login hint.") + return tenantId + } catch (e: Exception) { + Logger.error(methodTag, correlationId, "Failed to get tenant ID from login hint.", e) + return null + } + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/util/TenantUtilTest.kt b/common4j/src/test/com/microsoft/identity/common/java/util/TenantUtilTest.kt new file mode 100644 index 0000000000..084993bfac --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/util/TenantUtilTest.kt @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.util + +import com.microsoft.identity.common.java.authorities.AzureActiveDirectoryAudience +import com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectory +import com.microsoft.identity.common.java.providers.oauth2.OpenIdProviderConfiguration +import io.mockk.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [TenantUtil] + */ +class TenantUtilTest { + + @Before + fun setUp() { + // Clear all mocks before each test + clearAllMocks() + } + + @After + fun tearDown() { + // Clear all mocks after each test + clearAllMocks() + } + + // ===== Tests for getTenantFromIdentifier ===== + + @Test + fun `getTenantFromIdentifier returns null for null identifier`() { + val result = TenantUtil.getTenantFromIdentifier(null) + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for blank identifier`() { + val result = TenantUtil.getTenantFromIdentifier("") + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for whitespace only identifier`() { + val result = TenantUtil.getTenantFromIdentifier(" ") + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns tenant ID for valid GUID`() { + val tenantId = "12345678-1234-1234-1234-123456789012" + val result = TenantUtil.getTenantFromIdentifier(tenantId) + assertEquals(tenantId, result) + } + + @Test + fun `getTenantFromIdentifier returns tenant ID for valid GUID with uppercase letters`() { + val tenantId = "12345678-ABCD-EFAB-CDEF-123456789012" + val result = TenantUtil.getTenantFromIdentifier(tenantId) + assertEquals(tenantId, result) + } + + @Test + fun `getTenantFromIdentifier returns tenant ID for valid GUID with mixed case`() { + val tenantId = "12345678-AbCd-EfAb-CdEf-123456789012" + val result = TenantUtil.getTenantFromIdentifier(tenantId) + assertEquals(tenantId, result) + } + + @Test + fun `getTenantFromIdentifier returns domain for valid email address`() { + val email = "user@contoso.com" + val expectedDomain = "contoso.com" + val result = TenantUtil.getTenantFromIdentifier(email) + assertEquals(expectedDomain, result) + } + + @Test + fun `getTenantFromIdentifier returns domain for valid UPN with subdomain`() { + val upn = "john.doe@sub.contoso.com" + val expectedDomain = "sub.contoso.com" + val result = TenantUtil.getTenantFromIdentifier(upn) + assertEquals(expectedDomain, result) + } + + @Test + fun `getTenantFromIdentifier trims whitespace from extracted domain`() { + val upn = "user@contoso.com " + val expectedDomain = "contoso.com" + val result = TenantUtil.getTenantFromIdentifier(upn) + assertEquals(expectedDomain, result) + } + + @Test + fun `getTenantFromIdentifier returns domain for email with multiple dots`() { + val email = "user.name@mail.contoso.com" + val expectedDomain = "mail.contoso.com" + val result = TenantUtil.getTenantFromIdentifier(email) + assertEquals(expectedDomain, result) + } + + @Test + fun `getTenantFromIdentifier returns null for invalid GUID format`() { + val invalidGuid = "12345678-1234-1234-1234-12345678901" // Too short + val result = TenantUtil.getTenantFromIdentifier(invalidGuid) + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for GUID with invalid characters`() { + val invalidGuid = "12345678-1234-1234-1234-12345678901G" // Contains 'G' + val result = TenantUtil.getTenantFromIdentifier(invalidGuid) + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for invalid email format missing domain`() { + val invalidEmail = "user@" + val result = TenantUtil.getTenantFromIdentifier(invalidEmail) + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for invalid email format missing at symbol`() { + val invalidEmail = "usercontoso.com" + val result = TenantUtil.getTenantFromIdentifier(invalidEmail) + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for invalid email format missing TLD`() { + val invalidEmail = "user@contoso" + val result = TenantUtil.getTenantFromIdentifier(invalidEmail) + assertNull(result) + } + + @Test + fun `getTenantFromIdentifier returns null for malformed identifier`() { + val malformedIdentifier = "not-an-email-or-guid" + val result = TenantUtil.getTenantFromIdentifier(malformedIdentifier) + assertNull(result) + } + + // ===== Tests for getTenantIdFromLoginHint ===== + + @Test + fun `getTenantIdFromLoginHint returns null for null login hint`() { + val result = TenantUtil.getTenantIdFromLoginHint(null, "correlation-id") + assertNull(result) + } + + @Test + fun `getTenantIdFromLoginHint returns null for blank login hint`() { + val result = TenantUtil.getTenantIdFromLoginHint("", "correlation-id") + assertNull(result) + } + + @Test + fun `getTenantIdFromLoginHint returns null for invalid login hint`() { + val result = TenantUtil.getTenantIdFromLoginHint("invalid-hint", "correlation-id") + assertNull(result) + } + + @Test + fun `getTenantIdFromLoginHint successfully resolves tenant ID from email`() { + val loginHint = "user@contoso.com" + val correlationId = "correlation-id" + val expectedTenantId = "12345678-1234-1234-1234-123456789012" + val mockConfiguration = mockk() + + mockkStatic(AzureActiveDirectory::class) + mockkStatic(AzureActiveDirectoryAudience::class) + + every { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } returns mockConfiguration + every { AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(mockConfiguration) } returns expectedTenantId + + val result = TenantUtil.getTenantIdFromLoginHint(loginHint, correlationId) + + assertEquals(expectedTenantId, result) + verify { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } + verify { AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(mockConfiguration) } + } + + @Test + fun `getTenantIdFromLoginHint returns null when configuration loading fails`() { + val loginHint = "user@contoso.com" + val correlationId = "correlation-id" + val exception = RuntimeException("Failed to load configuration") + + mockkStatic(AzureActiveDirectory::class) + + every { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } throws exception + + val result = TenantUtil.getTenantIdFromLoginHint(loginHint, correlationId) + + assertNull(result) + verify { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } + } + + @Test + fun `getTenantIdFromLoginHint returns null when tenant ID extraction fails`() { + val loginHint = "user@contoso.com" + val correlationId = "correlation-id" + val mockConfiguration = mockk() + val exception = RuntimeException("Failed to extract tenant ID") + + mockkStatic(AzureActiveDirectory::class) + mockkStatic(AzureActiveDirectoryAudience::class) + + every { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } returns mockConfiguration + every { AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(mockConfiguration) } throws exception + + val result = TenantUtil.getTenantIdFromLoginHint(loginHint, correlationId) + + assertNull(result) + verify { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } + verify { AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(mockConfiguration) } + } + + @Test + fun `getTenantIdFromLoginHint works with null correlation ID`() { + val loginHint = "user@contoso.com" + val expectedTenantId = "12345678-1234-1234-1234-123456789012" + val mockConfiguration = mockk() + + mockkStatic(AzureActiveDirectory::class) + mockkStatic(AzureActiveDirectoryAudience::class) + + every { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("contoso.com") } returns mockConfiguration + every { AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(mockConfiguration) } returns expectedTenantId + + val result = TenantUtil.getTenantIdFromLoginHint(loginHint, null) + + assertEquals(expectedTenantId, result) + } + + @Test + fun `getTenantIdFromLoginHint handles complex email domains correctly`() { + val loginHint = "user.name@sub.domain.contoso.com" + val correlationId = "correlation-id" + val expectedTenantId = "12345678-1234-1234-1234-123456789012" + val mockConfiguration = mockk() + + mockkStatic(AzureActiveDirectory::class) + mockkStatic(AzureActiveDirectoryAudience::class) + + every { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("sub.domain.contoso.com") } returns mockConfiguration + every { AzureActiveDirectoryAudience.getTenantIdFromOpenIdProviderConfiguration(mockConfiguration) } returns expectedTenantId + + val result = TenantUtil.getTenantIdFromLoginHint(loginHint, correlationId) + + assertEquals(expectedTenantId, result) + verify { AzureActiveDirectory.loadOpenIdProviderConfigurationMetadataForTenant("sub.domain.contoso.com") } + } +}