diff --git a/EXAMPLES.md b/EXAMPLES.md index f584f7e2d..523d3c7ab 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -5,7 +5,7 @@ Each topic lives in its own file under [`examples/`](examples). ## Web Auth - [Authenticate with any Auth0 connection](examples/authenticate-with-connection.md) -- [Web Auth configuration](examples/web-auth-configuration.md) — audience, scope, connection scope, parameters, custom authorize URL, return-to URL scheme, custom logout URL +- [Web Auth configuration](examples/web-auth-configuration.md) — audience, scope, connection scope, parameters, custom authorize URL, token exchange retry, return-to URL scheme, custom logout URL - [Customize the Custom Tabs UI](examples/custom-tabs-ui.md) - [Trusted Web Activity](examples/trusted-web-activity.md) - [Ephemeral Browsing](examples/ephemeral-browsing.md) diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index 718d3a788..3682645e6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -48,6 +48,8 @@ internal class OAuthManager( private var idTokenVerificationLeeway: Int? = null private var idTokenVerificationIssuer: String? = null + private var tokenExchangeMaxRetries: Int? = null + private var tokenExchangeRetryDelayMillis: Long? = null @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun setPKCE(pkce: PKCE?) { @@ -58,6 +60,14 @@ internal class OAuthManager( idTokenVerificationLeeway = leeway } + fun setTokenExchangeMaxRetries(maxRetries: Int?) { + tokenExchangeMaxRetries = maxRetries + } + + fun setTokenExchangeRetryDelay(delayMillis: Long?) { + tokenExchangeRetryDelayMillis = delayMillis + } + fun setIdTokenVerificationIssuer(issuer: String?) { idTokenVerificationIssuer = if (TextUtils.isEmpty(issuer)) apiClient.baseURL else issuer } @@ -116,6 +126,10 @@ internal class OAuthManager( } // response_type=code + pkce!!.setNetworkRetry( + tokenExchangeMaxRetries ?: PKCE.DEFAULT_MAX_NETWORK_RETRIES, + tokenExchangeRetryDelayMillis ?: PKCE.DEFAULT_NETWORK_RETRY_DELAY_MS + ) pkce!!.getToken( values[KEY_CODE], object : Callback { @@ -212,6 +226,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, + tokenExchangeMaxRetries = tokenExchangeMaxRetries, + tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis, customAuthorizeUrl = this.customAuthorizeUrl, dPoPEnabled = dPoP != null ) @@ -411,5 +427,7 @@ internal fun OAuthManager.Companion.fromState( setPKCE(state.pkce) setIdTokenVerificationIssuer(state.idTokenVerificationIssuer) setIdTokenVerificationLeeway(state.idTokenVerificationLeeway) + setTokenExchangeMaxRetries(state.tokenExchangeMaxRetries) + setTokenExchangeRetryDelay(state.tokenExchangeRetryDelayMillis) } } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index 06d0c6b0b..1f14bdcef 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -19,7 +19,9 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean = false + val dPoPEnabled: Boolean = false, + val tokenExchangeMaxRetries: Int? = null, + val tokenExchangeRetryDelayMillis: Long? = null ) { private class OAuthManagerJson( @@ -36,7 +38,10 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean + val dPoPEnabled: Boolean, + // nullable so state saved by older versions deserializes to "not set" + val tokenExchangeMaxRetries: Int?, + val tokenExchangeRetryDelayMillis: Long? ) fun serializeToJson( @@ -61,7 +66,9 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoPEnabled = this.dPoPEnabled + dPoPEnabled = this.dPoPEnabled, + tokenExchangeMaxRetries = tokenExchangeMaxRetries, + tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis ) return gson.toJson(json) } finally { @@ -111,7 +118,9 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoPEnabled = oauthManagerJson.dPoPEnabled + dPoPEnabled = oauthManagerJson.dPoPEnabled, + tokenExchangeMaxRetries = oauthManagerJson.tokenExchangeMaxRetries, + tokenExchangeRetryDelayMillis = oauthManagerJson.tokenExchangeRetryDelayMillis ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/PKCE.java b/auth0/src/main/java/com/auth0/android/provider/PKCE.java index 515943916..8afc7bb90 100644 --- a/auth0/src/main/java/com/auth0/android/provider/PKCE.java +++ b/auth0/src/main/java/com/auth0/android/provider/PKCE.java @@ -1,14 +1,20 @@ package com.auth0.android.provider; +import android.os.Handler; +import android.os.Looper; + import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import com.auth0.android.NetworkErrorException; import com.auth0.android.authentication.AuthenticationAPIClient; import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.Callback; import com.auth0.android.request.Request; import com.auth0.android.result.Credentials; +import java.net.ConnectException; +import java.net.UnknownHostException; import java.util.Map; /** @@ -16,12 +22,16 @@ */ class PKCE { static final String TAG = PKCE.class.getSimpleName(); + static final int DEFAULT_MAX_NETWORK_RETRIES = 3; + static final long DEFAULT_NETWORK_RETRY_DELAY_MS = 1000; final AuthenticationAPIClient apiClient; private final String codeVerifier; private final String redirectUri; private final String codeChallenge; private final Map headers; + private int maxNetworkRetries = DEFAULT_MAX_NETWORK_RETRIES; + private long networkRetryDelayMillis = DEFAULT_NETWORK_RETRY_DELAY_MS; /** * Creates a new instance of this class with the given AuthenticationAPIClient. @@ -76,6 +86,11 @@ public String getRedirectUri() { return redirectUri; } + void setNetworkRetry(int maxRetries, long delayMillis) { + this.maxNetworkRetries = maxRetries; + this.networkRetryDelayMillis = delayMillis; + } + /** * Performs a request to the Auth0 API to get the OAuth Token and end the PKCE flow. * The instance of this class must be disposed after this method is called. @@ -88,7 +103,37 @@ public void getToken(String authorizationCode, @NonNull final Callback entry : headers.entrySet()) { tokenRequest.addHeader(entry.getKey(), entry.getValue()); } - tokenRequest.start(callback); + tokenRequest.start(new Callback() { + private int retries = 0; + + @Override + public void onSuccess(@NonNull Credentials result) { + callback.onSuccess(result); + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + // Android 15+ blocks network for cached apps and lifts it asynchronously when the + // redirect brings the app back, so the exchange can race it. Only failures before + // the request was sent are retried, as the authorization code is single-use. + if (retries < maxNetworkRetries && isUnsentRequestError(error)) { + retries++; + Callback retry = this; + new Handler(Looper.getMainLooper()).postDelayed(() -> tokenRequest.start(retry), networkRetryDelayMillis); + return; + } + callback.onFailure(error); + } + }); + } + + private static boolean isUnsentRequestError(AuthenticationException error) { + Throwable cause = error.getCause(); + if (!(cause instanceof NetworkErrorException)) { + return false; + } + Throwable networkCause = cause.getCause(); + return networkCause instanceof UnknownHostException || networkCause instanceof ConnectException; } /** diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 7c59c8257..408ceb1ee 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -526,6 +526,8 @@ public object WebAuthProvider { private var dPoP: DPoP? = null private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() private var leeway: Int? = null + private var tokenExchangeMaxRetries: Int? = null + private var tokenExchangeRetryDelayMillis: Long? = null private var launchAsTwa: Boolean = false private var ephemeralBrowsing: Boolean = false private var authTab: Boolean = false @@ -580,6 +582,34 @@ public object WebAuthProvider { return this } + /** + * Set how many times the code exchange that follows the browser redirect is retried when + * it fails before reaching Auth0, for example because the OS hasn't restored the app's + * network access yet after it was in the background. Requests that may have reached Auth0 + * are never retried. Defaults to 3. Pass 0 to disable retries. + * + * @param maxRetries the maximum number of retries, must not be negative. + * @return the current builder instance + */ + public fun withTokenExchangeMaxRetries(maxRetries: Int): Builder { + require(maxRetries >= 0) { "maxRetries must not be negative" } + this.tokenExchangeMaxRetries = maxRetries + return this + } + + /** + * Set the delay between code exchange retries. See [withTokenExchangeMaxRetries]. + * Defaults to 1000 milliseconds. + * + * @param delayMillis the delay between retries, in milliseconds, must not be negative. + * @return the current builder instance + */ + public fun withTokenExchangeRetryDelay(delayMillis: Long): Builder { + require(delayMillis >= 0) { "delayMillis must not be negative" } + this.tokenExchangeRetryDelayMillis = delayMillis + return this + } + /** * Set the expected issuer to be used for ID Token verification. * Defaults to the value returned by [Auth0.getDomainUrl]. @@ -898,6 +928,8 @@ public object WebAuthProvider { manager.setPKCE(pkce) manager.setIdTokenVerificationLeeway(leeway) manager.setIdTokenVerificationIssuer(issuer) + manager.setTokenExchangeMaxRetries(tokenExchangeMaxRetries) + manager.setTokenExchangeRetryDelay(tokenExchangeRetryDelayMillis) managerInstance = manager if (redirectUri == null) { redirectUri = CallbackHelper.getCallbackUri( diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index 2a32f745f..da111637d 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -45,6 +45,49 @@ internal class OAuthManagerStateTest { Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + @Test + fun `serialize should persist token exchange retry settings`() { + val state = OAuthManagerState( + auth0 = Auth0.getInstance("clientId", "domain"), + parameters = emptyMap(), + headers = emptyMap(), + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + tokenExchangeMaxRetries = 5, + tokenExchangeRetryDelayMillis = 200 + ) + + val deserializedState = OAuthManagerState.deserializeState(state.serializeToJson()) + + Assert.assertEquals(5, deserializedState.tokenExchangeMaxRetries) + Assert.assertEquals(200L, deserializedState.tokenExchangeRetryDelayMillis) + } + + @Test + fun `deserialize should leave token exchange retry settings unset for state saved without them`() { + val state = OAuthManagerState( + auth0 = Auth0.getInstance("clientId", "domain"), + parameters = emptyMap(), + headers = emptyMap(), + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + tokenExchangeMaxRetries = 5, + tokenExchangeRetryDelayMillis = 200 + ) + val json = org.json.JSONObject(state.serializeToJson()) + json.remove("tokenExchangeMaxRetries") + json.remove("tokenExchangeRetryDelayMillis") + + val deserializedState = OAuthManagerState.deserializeState(json.toString()) + + Assert.assertNull(deserializedState.tokenExchangeMaxRetries) + Assert.assertNull(deserializedState.tokenExchangeRetryDelayMillis) + } + @Test fun `serialize should persist dPoPEnabled flag as true`() { val auth0 = Auth0.getInstance("clientId", "domain") diff --git a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java index d6d1ad58c..dc4e35fa6 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java +++ b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java @@ -1,5 +1,6 @@ package com.auth0.android.provider; +import com.auth0.android.NetworkErrorException; import com.auth0.android.authentication.AuthenticationAPIClient; import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.Callback; @@ -16,10 +17,14 @@ import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLooper; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; @@ -32,6 +37,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -120,6 +127,98 @@ public void shouldFailToGetToken() { verify(callback).onFailure(any(AuthenticationException.class)); } + @Test + public void shouldRetryTokenExchangeWhenHostIsUnreachable() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + callbackCaptor.getValue().onFailure(networkError(new UnknownHostException())); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + verify(tokenRequest, times(2)).start(callbackCaptor.capture()); + Credentials credentials = mock(Credentials.class); + callbackCaptor.getValue().onSuccess(credentials); + + verify(callback).onSuccess(credentials); + verify(callback, never()).onFailure(any(AuthenticationException.class)); + } + + @Test + public void shouldFailTokenExchangeWhenHostStaysUnreachable() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + AuthenticationException error = networkError(new UnknownHostException()); + for (int attempt = 1; attempt <= 4; attempt++) { + verify(tokenRequest, times(attempt)).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + } + + verify(tokenRequest, times(4)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + @Test + public void shouldNotRetryTokenExchangeWhenRequestMayHaveBeenSent() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + AuthenticationException error = networkError(new SocketTimeoutException()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + + verify(tokenRequest, times(1)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + @Test + public void shouldUseConfiguredTokenExchangeRetries() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.setNetworkRetry(1, 5000); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + AuthenticationException error = networkError(new UnknownHostException()); + + verify(tokenRequest).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(4999, TimeUnit.MILLISECONDS); + verify(tokenRequest, times(1)).start(any(Callback.class)); + ShadowLooper.idleMainLooper(1, TimeUnit.MILLISECONDS); + verify(tokenRequest, times(2)).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + + verify(callback).onFailure(error); + } + + @Test + public void shouldNotRetryTokenExchangeWhenRetriesAreDisabled() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.setNetworkRetry(0, 1000); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + AuthenticationException error = networkError(new UnknownHostException()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + + verify(tokenRequest, times(1)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + private static AuthenticationException networkError(Exception cause) { + return new AuthenticationException("Failed to execute the network request", new NetworkErrorException(cause)); + } + @Test public void shouldNotHavePKCEAvailableIfSHA256IsNotAvailable() { AlgorithmHelper algorithmHelper = Mockito.mock(AlgorithmHelper.class); diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index 196830c44..78dfe3e2b 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -1066,6 +1066,44 @@ public class WebAuthProviderTest { ) } + @Test + public fun shouldUseDefaultTokenExchangeRetryOnResume() { + val pkce = Mockito.mock(PKCE::class.java) + `when`(pkce.codeChallenge).thenReturn("challenge") + login(account) + .withPKCE(pkce) + .withState("1234567890") + .start(activity, callback) + Assert.assertTrue(resume(createAuthIntent(createHash(null, null, null, null, null, "1234567890", null, null, "1234")))) + verify(pkce).setNetworkRetry(3, 1000L) + verify(pkce).getToken(eq("1234"), any()) + } + + @Test + public fun shouldUseCustomTokenExchangeRetryOnResume() { + val pkce = Mockito.mock(PKCE::class.java) + `when`(pkce.codeChallenge).thenReturn("challenge") + login(account) + .withPKCE(pkce) + .withState("1234567890") + .withTokenExchangeMaxRetries(5) + .withTokenExchangeRetryDelay(200) + .start(activity, callback) + Assert.assertTrue(resume(createAuthIntent(createHash(null, null, null, null, null, "1234567890", null, null, "1234")))) + verify(pkce).setNetworkRetry(5, 200L) + verify(pkce).getToken(eq("1234"), any()) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectNegativeTokenExchangeMaxRetries() { + login(account).withTokenExchangeMaxRetries(-1) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectNegativeTokenExchangeRetryDelay() { + login(account).withTokenExchangeRetryDelay(-1) + } + @Test public fun shouldStartLoginWithValidRequestCode() { val credentials = Mockito.mock( diff --git a/examples/web-auth-configuration.md b/examples/web-auth-configuration.md index 1e5c2b7da..59a4911f0 100644 --- a/examples/web-auth-configuration.md +++ b/examples/web-auth-configuration.md @@ -62,6 +62,30 @@ WebAuthProvider The URL provided to `.withAuthorizeUrl()` must be a complete and valid HTTPS URL for an OAuth 2.0 authorize endpoint. The SDK will append standard OAuth parameters to this custom base URL. +## Configure the token exchange retry + +After the browser redirects back, the SDK exchanges the authorization code for tokens. On some devices, such as Android 15+ or devices with aggressive battery optimization, the OS blocks network access for apps in the background and restores it a moment after the app returns to the foreground. If the exchange fails before reaching Auth0 for this reason, the SDK retries it 3 times, 1000 milliseconds apart. Requests that may have reached Auth0 are never retried, because the authorization code can only be used once. + +You can change the number of retries and the delay between them, or pass `0` retries to disable them: + +```kotlin +WebAuthProvider.login(account) + .withTokenExchangeMaxRetries(5) + .withTokenExchangeRetryDelay(500) + .start(this, callback) +``` + +
+ Using Java + +```java +WebAuthProvider.login(account) + .withTokenExchangeMaxRetries(5) + .withTokenExchangeRetryDelay(500) + .start(this, callback); +``` +
+ ## Changing the Return To URL scheme This configuration will probably match what you've done for the [authentication setup](#a-note-about-app-deep-linking).