@@ -1878,6 +1878,245 @@ public class SecureCredentialsManagerTest {
18781878 )
18791879 }
18801880
1881+ // ========== MFA Required During Token Renewal Tests ==========
1882+
1883+ @Test
1884+ public fun shouldFailWithMfaRequiredWhenRenewingExpiredCredentials () {
1885+ // Arrange: Set up local authentication to pass
1886+ Mockito .`when `(localAuthenticationManager.authenticate()).then {
1887+ localAuthenticationManager.resultCallback.onSuccess(true )
1888+ }
1889+ // Arrange: Set up expired credentials that need renewal
1890+ val expiresAt = Date (CredentialsMock .CURRENT_TIME_MS )
1891+ insertTestCredentials(false , true , true , expiresAt, " scope" )
1892+ Mockito .`when `(client.renewAuth(" refreshToken" )).thenReturn(request)
1893+
1894+ // Create an AuthenticationException that simulates MFA required response
1895+ val mfaRequiredValues = mapOf (
1896+ " error" to " mfa_required" ,
1897+ " error_description" to " Multifactor authentication required" ,
1898+ " mfa_token" to " test-mfa-token-12345" ,
1899+ " mfa_requirements" to mapOf (
1900+ " challenge" to listOf (
1901+ mapOf (" type" to " otp" ),
1902+ mapOf (" type" to " oob" )
1903+ )
1904+ )
1905+ )
1906+ val mfaRequiredException = AuthenticationException (mfaRequiredValues, 403 )
1907+ Mockito .`when `(request.execute()).thenThrow(mfaRequiredException)
1908+
1909+ // Act: Try to get credentials, which triggers renewal
1910+ manager.getCredentials(callback)
1911+
1912+ // Assert: Verify the callback receives MFA_REQUIRED exception with payload
1913+ verify(callback).onFailure(exceptionCaptor.capture())
1914+ val exception = exceptionCaptor.firstValue
1915+ MatcherAssert .assertThat(exception, Is .`is `(Matchers .notNullValue()))
1916+ // Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException
1917+ MatcherAssert .assertThat(exception.message, Matchers .containsString(" authenticate" ))
1918+ MatcherAssert .assertThat(exception.cause, Is .`is `(mfaRequiredException))
1919+
1920+ // Verify MFA payload is properly passed through
1921+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload, Is .`is `(Matchers .notNullValue()))
1922+ MatcherAssert .assertThat(exception.mfaToken, Is .`is `(" test-mfa-token-12345" ))
1923+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is .`is `(Matchers .notNullValue()))
1924+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is .`is `(2 ))
1925+ }
1926+
1927+ @Test
1928+ public fun shouldFailWithMfaRequiredWithEnrollmentOptionsWhenRenewingCredentials () {
1929+ // Arrange: Set up local authentication to pass
1930+ Mockito .`when `(localAuthenticationManager.authenticate()).then {
1931+ localAuthenticationManager.resultCallback.onSuccess(true )
1932+ }
1933+ // Arrange: Set up expired credentials that need renewal
1934+ val expiresAt = Date (CredentialsMock .CURRENT_TIME_MS )
1935+ insertTestCredentials(false , true , true , expiresAt, " scope" )
1936+ Mockito .`when `(client.renewAuth(" refreshToken" )).thenReturn(request)
1937+
1938+ // Create an AuthenticationException with enrollment options (user needs to enroll MFA)
1939+ val mfaRequiredValues = mapOf (
1940+ " error" to " mfa_required" ,
1941+ " error_description" to " Multifactor authentication required" ,
1942+ " mfa_token" to " enroll-mfa-token" ,
1943+ " mfa_requirements" to mapOf (
1944+ " enroll" to listOf (
1945+ mapOf (" type" to " otp" ),
1946+ mapOf (" type" to " sms" ),
1947+ mapOf (" type" to " push-notification" )
1948+ )
1949+ )
1950+ )
1951+ val mfaRequiredException = AuthenticationException (mfaRequiredValues, 403 )
1952+ Mockito .`when `(request.execute()).thenThrow(mfaRequiredException)
1953+
1954+ // Act: Try to get credentials
1955+ manager.getCredentials(callback)
1956+
1957+ // Assert: Verify MFA required with enrollment options
1958+ verify(callback).onFailure(exceptionCaptor.capture())
1959+ val exception = exceptionCaptor.firstValue
1960+ // Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException
1961+ MatcherAssert .assertThat(exception.message, Matchers .containsString(" authenticate" ))
1962+ MatcherAssert .assertThat(exception.mfaToken, Is .`is `(" enroll-mfa-token" ))
1963+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is .`is `(Matchers .notNullValue()))
1964+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is .`is `(3 ))
1965+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is .`is `(Matchers .nullValue()))
1966+ }
1967+
1968+ @Test
1969+ public fun shouldNotStoreMfaPayloadWhenNonMfaApiErrorOccurs () {
1970+ // Arrange: Set up local authentication to pass
1971+ Mockito .`when `(localAuthenticationManager.authenticate()).then {
1972+ localAuthenticationManager.resultCallback.onSuccess(true )
1973+ }
1974+ // Arrange: Set up expired credentials that need renewal
1975+ val expiresAt = Date (CredentialsMock .CURRENT_TIME_MS )
1976+ insertTestCredentials(false , true , true , expiresAt, " scope" )
1977+ Mockito .`when `(client.renewAuth(" refreshToken" )).thenReturn(request)
1978+
1979+ // Create a regular API error (not MFA required)
1980+ val regularApiError = AuthenticationException (
1981+ mapOf (
1982+ " error" to " invalid_grant" ,
1983+ " error_description" to " Invalid refresh token"
1984+ ),
1985+ 400
1986+ )
1987+ Mockito .`when `(request.execute()).thenThrow(regularApiError)
1988+
1989+ // Act: Try to get credentials
1990+ manager.getCredentials(callback)
1991+
1992+ // Assert: Verify no MFA payload is present for non-MFA errors
1993+ verify(callback).onFailure(exceptionCaptor.capture())
1994+ val exception = exceptionCaptor.firstValue
1995+ // For non-MFA API errors, message is "An error occurred while processing the request."
1996+ MatcherAssert .assertThat(exception.message, Matchers .containsString(" processing the request" ))
1997+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload, Is .`is `(Matchers .nullValue()))
1998+ MatcherAssert .assertThat(exception.mfaToken, Is .`is `(Matchers .nullValue()))
1999+ }
2000+
2001+ @Test
2002+ @ExperimentalCoroutinesApi
2003+ public fun shouldThrowMfaRequiredExceptionWhenAwaitingExpiredCredentials (): Unit = runTest {
2004+ // Arrange: Set up local authentication to pass
2005+ Mockito .`when `(localAuthenticationManager.authenticate()).then {
2006+ localAuthenticationManager.resultCallback.onSuccess(true )
2007+ }
2008+ // Arrange: Set up expired credentials that need renewal
2009+ val expiresAt = Date (CredentialsMock .CURRENT_TIME_MS )
2010+ insertTestCredentials(false , true , true , expiresAt, " scope" )
2011+ Mockito .`when `(client.renewAuth(" refreshToken" )).thenReturn(request)
2012+
2013+ // Create an AuthenticationException that simulates MFA required response
2014+ val mfaRequiredValues = mapOf (
2015+ " error" to " mfa_required" ,
2016+ " error_description" to " Multifactor authentication required" ,
2017+ " mfa_token" to " await-mfa-token-12345" ,
2018+ " mfa_requirements" to mapOf (
2019+ " challenge" to listOf (
2020+ mapOf (" type" to " otp" )
2021+ )
2022+ )
2023+ )
2024+ val mfaRequiredException = AuthenticationException (mfaRequiredValues, 403 )
2025+ Mockito .`when `(request.execute()).thenThrow(mfaRequiredException)
2026+
2027+ // Act & Assert: Verify awaitCredentials throws CredentialsManagerException with MFA payload
2028+ val exception = assertThrows(CredentialsManagerException ::class .java) {
2029+ runBlocking { manager.awaitCredentials() }
2030+ }
2031+ MatcherAssert .assertThat(exception, Is .`is `(Matchers .notNullValue()))
2032+ MatcherAssert .assertThat(exception.cause, Is .`is `(mfaRequiredException))
2033+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload, Is .`is `(Matchers .notNullValue()))
2034+ MatcherAssert .assertThat(exception.mfaToken, Is .`is `(" await-mfa-token-12345" ))
2035+ }
2036+
2037+ @Test
2038+ public fun shouldFailWithMfaRequiredContainingBothChallengeAndEnrollOptions () {
2039+ // Arrange: Set up local authentication to pass
2040+ Mockito .`when `(localAuthenticationManager.authenticate()).then {
2041+ localAuthenticationManager.resultCallback.onSuccess(true )
2042+ }
2043+ // Arrange: Set up expired credentials that need renewal
2044+ val expiresAt = Date (CredentialsMock .CURRENT_TIME_MS )
2045+ insertTestCredentials(false , true , true , expiresAt, " scope" )
2046+ Mockito .`when `(client.renewAuth(" refreshToken" )).thenReturn(request)
2047+
2048+ // Create MFA required with BOTH challenge (existing factors) AND enroll (can add more factors)
2049+ val mfaRequiredValues = mapOf (
2050+ " error" to " mfa_required" ,
2051+ " error_description" to " Multifactor authentication required" ,
2052+ " mfa_token" to " combined-mfa-token" ,
2053+ " mfa_requirements" to mapOf (
2054+ " challenge" to listOf (
2055+ mapOf (" type" to " otp" )
2056+ ),
2057+ " enroll" to listOf (
2058+ mapOf (" type" to " sms" ),
2059+ mapOf (" type" to " push-notification" )
2060+ )
2061+ )
2062+ )
2063+ val mfaRequiredException = AuthenticationException (mfaRequiredValues, 403 )
2064+ Mockito .`when `(request.execute()).thenThrow(mfaRequiredException)
2065+
2066+ // Act: Try to get credentials
2067+ manager.getCredentials(callback)
2068+
2069+ // Assert: Verify both challenge and enroll are present in the payload
2070+ verify(callback).onFailure(exceptionCaptor.capture())
2071+ val exception = exceptionCaptor.firstValue
2072+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload, Is .`is `(Matchers .notNullValue()))
2073+ MatcherAssert .assertThat(exception.mfaToken, Is .`is `(" combined-mfa-token" ))
2074+
2075+ // Verify challenge factors
2076+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is .`is `(Matchers .notNullValue()))
2077+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is .`is `(1 ))
2078+
2079+ // Verify enroll factors
2080+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is .`is `(Matchers .notNullValue()))
2081+ MatcherAssert .assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is .`is `(2 ))
2082+ }
2083+
2084+ @Test
2085+ public fun shouldPreserveOriginalAuthenticationExceptionAsCauseForMfaRequired () {
2086+ // Arrange: Set up local authentication to pass
2087+ Mockito .`when `(localAuthenticationManager.authenticate()).then {
2088+ localAuthenticationManager.resultCallback.onSuccess(true )
2089+ }
2090+ // Arrange: Set up expired credentials that need renewal
2091+ val expiresAt = Date (CredentialsMock .CURRENT_TIME_MS )
2092+ insertTestCredentials(false , true , true , expiresAt, " scope" )
2093+ Mockito .`when `(client.renewAuth(" refreshToken" )).thenReturn(request)
2094+
2095+ val mfaRequiredValues = mapOf (
2096+ " error" to " mfa_required" ,
2097+ " error_description" to " MFA is required for this action" ,
2098+ " mfa_token" to " cause-test-token"
2099+ )
2100+ val originalException = AuthenticationException (mfaRequiredValues, 403 )
2101+ Mockito .`when `(request.execute()).thenThrow(originalException)
2102+
2103+ // Act
2104+ manager.getCredentials(callback)
2105+
2106+ // Assert: Verify the original exception is preserved as cause
2107+ verify(callback).onFailure(exceptionCaptor.capture())
2108+ val exception = exceptionCaptor.firstValue
2109+
2110+ // The cause should be the original AuthenticationException
2111+ MatcherAssert .assertThat(exception.cause, Is .`is `(Matchers .notNullValue()))
2112+ MatcherAssert .assertThat(exception.cause, IsInstanceOf .instanceOf(AuthenticationException ::class .java))
2113+
2114+ val causeException = exception.cause as AuthenticationException
2115+ MatcherAssert .assertThat(causeException.getCode(), Is .`is `(" mfa_required" ))
2116+ MatcherAssert .assertThat(causeException.isMultifactorRequired, Is .`is `(true ))
2117+ MatcherAssert .assertThat(causeException.getDescription(), Is .`is `(" MFA is required for this action" ))
2118+ }
2119+
18812120 /* *
18822121 * Testing that getCredentials execution from multiple threads via multiple instances of SecureCredentialsManager should trigger only one network request.
18832122 */
0 commit comments