Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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<Credentials, AuthenticationException> {
Expand Down Expand Up @@ -212,6 +226,8 @@ internal class OAuthManager(
auth0 = account,
idTokenVerificationIssuer = idTokenVerificationIssuer,
idTokenVerificationLeeway = idTokenVerificationLeeway,
tokenExchangeMaxRetries = tokenExchangeMaxRetries,
tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis,
customAuthorizeUrl = this.customAuthorizeUrl,
dPoPEnabled = dPoP != null
)
Expand Down Expand Up @@ -411,5 +427,7 @@ internal fun OAuthManager.Companion.fromState(
setPKCE(state.pkce)
setIdTokenVerificationIssuer(state.idTokenVerificationIssuer)
setIdTokenVerificationLeeway(state.idTokenVerificationLeeway)
setTokenExchangeMaxRetries(state.tokenExchangeMaxRetries)
setTokenExchangeRetryDelay(state.tokenExchangeRetryDelayMillis)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
47 changes: 46 additions & 1 deletion auth0/src/main/java/com/auth0/android/provider/PKCE.java
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
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;

/**
* Performs code exchange according to Proof Key for Code Exchange (PKCE) spec.
*/
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<String, String> 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.
Expand Down Expand Up @@ -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.
Expand All @@ -88,7 +103,37 @@ public void getToken(String authorizationCode, @NonNull final Callback<Credentia
for (Map.Entry<String, String> entry : headers.entrySet()) {
tokenRequest.addHeader(entry.getKey(), entry.getValue());
}
tokenRequest.start(callback);
tokenRequest.start(new Callback<Credentials, AuthenticationException>() {
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<Credentials, AuthenticationException> 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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading