diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a9324ec5b..d21b96de4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '17' @@ -45,15 +45,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/autobuild@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..7b9a99e46 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,80 @@ +name: Scorecard analysis + +# Runs OpenSSF Scorecard supply-chain analysis directly via ossf/scorecard-action +# (no composite wrapper, no cross-org dependency). All action refs are SHA-pinned. +# +# Scorecard requirements +# ────────────────────── +# • Supported triggers (per OSSF) are push and schedule on the default branch. +# pull_request is added below ONLY as a smoke test on a real runner from the PR. +# workflow_dispatch provides manual smoke tests after this workflow exists on the default branch. +# OSSF officially documents both triggers as EXPERIMENTAL ("The pull_request and +# workflow_dispatch triggers are experimental" — scorecard-action README); the docs do not +# detail how results differ on them. Because they are experimental, treat a PR run as a +# smoke signal only — this workflow is fully validated only after merge to the default branch. +# • The checkout step MUST set persist-credentials: false (required by Scorecard's +# security model to avoid leaking the GITHUB_TOKEN into the analysis environment). +# • publish_results: true publishes to the public Scorecard API and enables the +# Scorecard badge. It requires id-token: write on the job AND a push/schedule +# event — it must stay false on pull_request/workflow_dispatch. Set to 'false' to +# keep results private (id-token can then be omitted from the job permissions). + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] # EXPERIMENTAL per OSSF: smoke test only, not an authoritative result + workflow_dispatch: {} # EXPERIMENTAL per OSSF: manual re-run, available only after merge (default-branch copy) + schedule: + - cron: '30 1 * * 6' # weekly, Saturday 01:30 UTC + +permissions: read-all # default: restrict everything; the job overrides what it needs + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # No event guard: pull_request and workflow_dispatch are allowed to run as an + # experimental smoke test (see the header). Keep publish_results: false on those + # events — publishing requires a push/schedule event and id-token: write. + permissions: + security-events: write # upload SARIF to the Security tab / Code Scanning dashboard + # id-token: write # OIDC token for Scorecard's public API publishing (publish_results: true) + # Uncomment this line only when you set publish_results: true + contents: read # checkout the repository + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # required by Scorecard — do not remove + + - name: Run analysis + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + # publish_results: true publishes to the Scorecard public dataset and enables the badge. + # Requires id-token: write above and a push/schedule event. + # Set to 'true' (and enable id-token: write) to publish results to the Scorecard public dataset. + publish_results: false + + # Upload SARIF as a workflow artifact (optional — comment out to disable). + # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts + - name: Upload artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: SARIF file + path: results.sarif + retention-days: 5 # adjust as needed + + # Upload SARIF to GitHub Code Scanning dashboard (optional — comment out to disable). + # Skipped only for fork PRs: there the GITHUB_TOKEN is read-only (no security-events: write) + # and the upload fails. The guard still runs on push/schedule and on same-repo PRs, where the + # token can write. (github.event.pull_request is null on push/schedule, so !…fork is true.) + - name: Upload to code-scanning + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + sarif_file: results.sarif + category: scorecard diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7f194f8..780bfa3eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ **Added** - feat(embedded): add /e/authorize email OTP flow [\#1075](https://github.com/auth0/Auth0.Android/pull/1075) ([pmathew92](https://github.com/pmathew92)) +## [4.3.1](https://github.com/auth0/Auth0.Android/tree/4.3.1) (2026-11-12) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.3.0...4.3.1) + +**Changed** +- docs(myaccount): mark the My Account Profile API as Beta [\#1124](https://github.com/auth0/Auth0.Android/pull/1124) ([utkrishtsahu](https://github.com/utkrishtsahu)) + +**Fixed** +- fix(webauth): retry code exchange while network is still blocked, make it configurable [\#1125](https://github.com/auth0/Auth0.Android/pull/1125) ([subhankarmaiti](https://github.com/subhankarmaiti)) + ## [4.3.0](https://github.com/auth0/Auth0.Android/tree/4.3.0) (2026-10-30) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.2.1...4.3.0) diff --git a/EXAMPLES.md b/EXAMPLES.md index fdf2131f4..03578c4b5 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/README.md b/README.md index 5924a7012..9f6f21ef3 100644 --- a/README.md +++ b/README.md @@ -316,10 +316,10 @@ WebAuthProvider.login(account) .await(this) ``` -### My Account API: user profile +### My Account API: user profile [Beta] > [!NOTE] -> The My Account profile API is currently in Early Access. To request access to this feature, contact your Auth0 representative. +> The My Account profile API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). It might change before General Availability, and Auth0 does not support beta functionality for production use. The feature must be enabled for your tenant. After obtaining an access token for the My Account API, use `MyAccountAPIClient` to read the signed-in user's profile and update the fields they are allowed to edit. Each field in `profilePolicy` tells you whether it is editable and why: @@ -332,7 +332,7 @@ val canEditGivenName = profile.profilePolicy["/given_name"]?.access == ProfileFi client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).await() ``` -See the [full usage example](examples/my-account-api.md#get-the-user-profile) for callback, coroutine and Java samples. +See the [full usage example](examples/my-account-api.md#get-the-user-profile-beta) for callback, coroutine and Java samples. ## Credentials Manager diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 1a0c58a86..f1e732951 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -852,9 +852,10 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * * ## Availability * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. + * This feature is currently in + * [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). + * It might change before General Availability, and Auth0 does not support beta functionality for + * production use. The feature must be enabled for your tenant. * * ## Scopes Required * `read:me:profile` @@ -912,9 +913,10 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * * ## Availability * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. + * This feature is currently in + * [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). + * It might change before General Availability, and Auth0 does not support beta functionality for + * production use. The feature must be enabled for your tenant. * * ## Scopes Required * `update:me:profile` diff --git a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt index c2924efdc..7f91efd70 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt @@ -4,6 +4,8 @@ package com.auth0.android.myaccount.request * Options to control which fields are returned by * [com.auth0.android.myaccount.MyAccountAPIClient.getUserProfile]. * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. + * * @param fields the fields to include in, or exclude from, the response, e.g. * `listOf("given_name", "picture", "profile_policy")`. When empty, the default set of fields is * returned. diff --git a/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt index 95281c852..f74cdbe13 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt @@ -3,6 +3,8 @@ package com.auth0.android.myaccount.request /** * The changes to apply with [com.auth0.android.myaccount.MyAccountAPIClient.updateUserProfile]. * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. + * * Properties left as `null` are not sent and remain unchanged. A request with every property * `null` is valid and returns the current profile. * 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..aae029a86 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 tokenExchangeRetryDelay: Int? = 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(seconds: Int?) { + tokenExchangeRetryDelay = seconds + } + fun setIdTokenVerificationIssuer(issuer: String?) { idTokenVerificationIssuer = if (TextUtils.isEmpty(issuer)) apiClient.baseURL else issuer } @@ -304,7 +314,7 @@ internal class OAuthManager( dPoP?.let { apiClient.useDPoP(context) } - pkce = PKCE(apiClient, redirectUri, headers) + pkce = PKCE(apiClient, redirectUri, headers, tokenExchangeMaxRetries, tokenExchangeRetryDelay) } } 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..aa1f12653 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -36,7 +36,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 tokenExchangeRetryDelay: Int? ) fun serializeToJson( @@ -61,7 +64,9 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoPEnabled = this.dPoPEnabled + dPoPEnabled = this.dPoPEnabled, + tokenExchangeMaxRetries = pkce?.maxNetworkRetries, + tokenExchangeRetryDelay = pkce?.networkRetryDelaySeconds ) return gson.toJson(json) } finally { @@ -107,6 +112,8 @@ internal data class OAuthManagerState( oauthManagerJson.redirectUri, oauthManagerJson.codeChallenge, oauthManagerJson.headers, + oauthManagerJson.tokenExchangeMaxRetries, + oauthManagerJson.tokenExchangeRetryDelay, ), idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, 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..ca8b1fdc8 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,21 @@ package com.auth0.android.provider; +import android.os.Handler; +import android.os.Looper; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; 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 +23,18 @@ */ class PKCE { static final String TAG = PKCE.class.getSimpleName(); + private static final int DEFAULT_MAX_NETWORK_RETRIES = 2; + // Android caches failed DNS lookups for 2 seconds, so a shorter delay retries against the cached failure. + private static final int DEFAULT_NETWORK_RETRY_DELAY_SECONDS = 2; final AuthenticationAPIClient apiClient; private final String codeVerifier; private final String redirectUri; private final String codeChallenge; private final Map headers; + private final int maxNetworkRetries; + private final int networkRetryDelaySeconds; + private final Handler retryHandler = new Handler(Looper.getMainLooper()); /** * Creates a new instance of this class with the given AuthenticationAPIClient. @@ -34,29 +47,53 @@ class PKCE { * @see #isAvailable() */ public PKCE(@NonNull AuthenticationAPIClient apiClient, String redirectUri, @NonNull Map headers) { - this(apiClient, new AlgorithmHelper(), redirectUri, headers); + this(apiClient, redirectUri, headers, null, null); + } + + /** + * Same as {@link #PKCE(AuthenticationAPIClient, String, Map)}, with the code exchange retry settings. + * + * @param maxNetworkRetries retries when the code exchange fails before it was sent, or null for the default. + * @param networkRetryDelaySeconds delay between those retries, or null for the default. + */ + PKCE(@NonNull AuthenticationAPIClient apiClient, String redirectUri, @NonNull Map headers, + @Nullable Integer maxNetworkRetries, @Nullable Integer networkRetryDelaySeconds) { + this(apiClient, new AlgorithmHelper(), redirectUri, headers, maxNetworkRetries, networkRetryDelaySeconds); } @VisibleForTesting PKCE(@NonNull AuthenticationAPIClient apiClient, @NonNull AlgorithmHelper algorithmHelper, @NonNull String redirectUri, @NonNull Map headers) { + this(apiClient, algorithmHelper, redirectUri, headers, null, null); + } + + @VisibleForTesting + PKCE(@NonNull AuthenticationAPIClient apiClient, @NonNull AlgorithmHelper algorithmHelper, + @NonNull String redirectUri, @NonNull Map headers, + @Nullable Integer maxNetworkRetries, @Nullable Integer networkRetryDelaySeconds) { this.apiClient = apiClient; this.redirectUri = redirectUri; this.codeVerifier = algorithmHelper.generateCodeVerifier(); this.codeChallenge = algorithmHelper.generateCodeChallenge(codeVerifier); this.headers = headers; + this.maxNetworkRetries = maxNetworkRetries != null ? maxNetworkRetries : DEFAULT_MAX_NETWORK_RETRIES; + this.networkRetryDelaySeconds = networkRetryDelaySeconds != null ? networkRetryDelaySeconds : DEFAULT_NETWORK_RETRY_DELAY_SECONDS; } PKCE(@NonNull AuthenticationAPIClient apiClient, @NonNull String codeVerifier, @NonNull String redirectUri, @NonNull String codeChallenge, - @NonNull Map headers) { + @NonNull Map headers, + @Nullable Integer maxNetworkRetries, + @Nullable Integer networkRetryDelaySeconds) { this.apiClient = apiClient; this.codeVerifier = codeVerifier; this.redirectUri = redirectUri; this.codeChallenge = codeChallenge; this.headers = headers; + this.maxNetworkRetries = maxNetworkRetries != null ? maxNetworkRetries : DEFAULT_MAX_NETWORK_RETRIES; + this.networkRetryDelaySeconds = networkRetryDelaySeconds != null ? networkRetryDelaySeconds : DEFAULT_NETWORK_RETRY_DELAY_SECONDS; } /** @@ -76,6 +113,14 @@ public String getRedirectUri() { return redirectUri; } + int getMaxNetworkRetries() { + return maxNetworkRetries; + } + + int getNetworkRetryDelaySeconds() { + return networkRetryDelaySeconds; + } + /** * 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 +133,52 @@ public void getToken(String authorizationCode, @NonNull final Callback entry : headers.entrySet()) { tokenRequest.addHeader(entry.getKey(), entry.getValue()); } - tokenRequest.start(callback); + startTokenRequest(tokenRequest, callback, 0); + } + + private void startTokenRequest(@NonNull Request tokenRequest, + @NonNull Callback callback, + int attempt) { + tokenRequest.start(new Callback() { + @Override + public void onSuccess(@NonNull Credentials result) { + callback.onSuccess(result); + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + if (shouldRetry(error, attempt)) { + retryHandler.postDelayed(() -> startTokenRequest(tokenRequest, callback, attempt + 1), + networkRetryDelaySeconds * 1000L); + } else { + callback.onFailure(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. + private boolean shouldRetry(@NonNull AuthenticationException error, int attempt) { + Throwable cause = error.getCause(); + return attempt < maxNetworkRetries + && cause instanceof NetworkErrorException + && isUnsentRequestError(cause.getCause()); + } + + // OkHttp attaches the failures it already recovered from as suppressed exceptions, and any of + // those that isn't a DNS or connect failure means the request may have been sent. + private boolean isUnsentRequestError(@Nullable Throwable error) { + if (!(error instanceof UnknownHostException || error instanceof ConnectException)) { + return false; + } + for (Throwable suppressed : error.getSuppressed()) { + if (!isUnsentRequestError(suppressed)) { + return false; + } + } + return true; } /** 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..87e48e8e5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -37,6 +37,7 @@ public object WebAuthProvider { private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" private const val KEY_BUNDLE_PAR_MANAGER_STATE = "par_manager_state" private const val AUTH_REQUEST_CODE = 110 + private const val TOKEN_EXCHANGE_MAX_RETRIES_LIMIT = 4 private val callbacks = CopyOnWriteArraySet>() private val parCallbacks = @@ -526,6 +527,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 tokenExchangeRetryDelay: Int? = null private var launchAsTwa: Boolean = false private var ephemeralBrowsing: Boolean = false private var authTab: Boolean = false @@ -580,6 +583,36 @@ 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 2. Pass 0 to disable retries. + * + * @param maxRetries the maximum number of retries, from 0 to 4. + * @return the current builder instance + */ + public fun withTokenExchangeMaxRetries(maxRetries: Int): Builder { + require(maxRetries in 0..TOKEN_EXCHANGE_MAX_RETRIES_LIMIT) { + "maxRetries must be between 0 and $TOKEN_EXCHANGE_MAX_RETRIES_LIMIT" + } + this.tokenExchangeMaxRetries = maxRetries + return this + } + + /** + * Set the delay between code exchange retries. See [withTokenExchangeMaxRetries]. + * Defaults to 2 seconds. + * + * @param delay the delay between retries, in seconds, must not be negative. + * @return the current builder instance + */ + public fun withTokenExchangeRetryDelay(delay: Int): Builder { + require(delay >= 0) { "delay must not be negative" } + this.tokenExchangeRetryDelay = delay + return this + } + /** * Set the expected issuer to be used for ID Token verification. * Defaults to the value returned by [Auth0.getDomainUrl]. @@ -898,6 +931,8 @@ public object WebAuthProvider { manager.setPKCE(pkce) manager.setIdTokenVerificationLeeway(leeway) manager.setIdTokenVerificationIssuer(issuer) + manager.setTokenExchangeMaxRetries(tokenExchangeMaxRetries) + manager.setTokenExchangeRetryDelay(tokenExchangeRetryDelay) managerInstance = manager if (redirectUri == null) { redirectUri = CallbackHelper.getCallbackUri( diff --git a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt index 5e205720b..c192726f2 100644 --- a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt @@ -11,6 +11,8 @@ import java.lang.reflect.Type /** * The current user's profile, as returned by the My Account API `/me/v1/profile` endpoint. * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. + * * Every property is nullable: the server omits fields that are not set for the user, and * any field can be trimmed from the response by using * [com.auth0.android.myaccount.request.GetUserProfileOptions.fields]. @@ -57,6 +59,8 @@ public data class MyAccountProfile( /** * Describes whether a profile field can be edited by the user and why. + * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. */ @JsonAdapter(ProfileFieldPolicy.Deserializer::class) public data class ProfileFieldPolicy( @@ -100,6 +104,8 @@ public data class ProfileFieldPolicy( /** * Whether a profile field can be edited by the user. + * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. */ public enum class ProfileFieldAccess(public val value: String) { READ_ONLY("read_only"), @@ -112,6 +118,8 @@ public enum class ProfileFieldAccess(public val value: String) { /** * Where a profile field's value comes from. + * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. */ public enum class ProfileFieldSource(public val value: String) { USER("user"), 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..c562a99e6 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,45 @@ 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(), 5, 3), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + + val deserializedState = OAuthManagerState.deserializeState(state.serializeToJson()) + + Assert.assertEquals(5, deserializedState.pkce?.maxNetworkRetries) + Assert.assertEquals(3, deserializedState.pkce?.networkRetryDelaySeconds) + } + + @Test + fun `deserialize should use default token exchange retry settings 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(), 5, 3), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + val json = org.json.JSONObject(state.serializeToJson()) + json.remove("tokenExchangeMaxRetries") + json.remove("tokenExchangeRetryDelay") + + val deserializedState = OAuthManagerState.deserializeState(json.toString()) + + Assert.assertEquals(2, deserializedState.pkce?.maxNetworkRetries) + Assert.assertEquals(2, deserializedState.pkce?.networkRetryDelaySeconds) + } + @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..5dbdd1e8e 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,16 @@ import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLooper; +import java.net.ConnectException; +import java.net.SocketException; +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 +39,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 +129,123 @@ public void shouldFailToGetToken() { verify(callback).onFailure(any(AuthenticationException.class)); } + @Test + public void shouldRetryTokenExchangeWhenHostIsUnreachable() { + assertRetriedThenSucceeds(networkError(new UnknownHostException())); + } + + @Test + public void shouldRetryTokenExchangeWhenConnectionFails() { + assertRetriedThenSucceeds(networkError(new ConnectException())); + } + + @Test + public void shouldRetryTokenExchangeWhenEveryRouteFailedToConnect() { + ConnectException error = new ConnectException(); + error.addSuppressed(new ConnectException()); + assertRetriedThenSucceeds(networkError(error)); + } + + @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 <= 3; attempt++) { + verify(tokenRequest, times(attempt)).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(2, TimeUnit.SECONDS); + } + + verify(tokenRequest, times(3)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + @Test + public void shouldNotRetryTokenExchangeWhenRequestTimedOut() { + assertNotRetried(networkError(new SocketTimeoutException())); + } + + @Test + public void shouldNotRetryTokenExchangeWhenConnectionWasReset() { + assertNotRetried(networkError(new SocketException("Connection reset"))); + } + + @Test + public void shouldNotRetryTokenExchangeWhenOkHttpAlreadyRetriedASentRequest() { + UnknownHostException error = new UnknownHostException(); + error.addSuppressed(new SocketException("Connection reset")); + assertNotRetried(networkError(error)); + } + + @Test + public void shouldNotRetryTokenExchangeOnServerError() { + assertNotRetried(new AuthenticationException("invalid_grant", "Invalid authorization code")); + } + + @Test + public void shouldUseConfiguredTokenExchangeRetries() { + pkce = new PKCE(apiClient, new AlgorithmHelperMock(CODE_VERIFIER), REDIRECT_URI, Collections.emptyMap(), 1, 5); + 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()); + + 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() { + pkce = new PKCE(apiClient, new AlgorithmHelperMock(CODE_VERIFIER), REDIRECT_URI, Collections.emptyMap(), 0, 1); + assertNotRetried(networkError(new UnknownHostException())); + } + + private void assertRetriedThenSucceeds(AuthenticationException error) { + 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(error); + ShadowLooper.idleMainLooper(2, 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)); + } + + private void assertNotRetried(AuthenticationException error) { + 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(error); + ShadowLooper.idleMainLooper(10, 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..4670dff36 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,47 @@ public class WebAuthProviderTest { ) } + @Test + public fun shouldUseDefaultTokenExchangeRetry() { + login(account).start(activity, callback) + + val pkce = restoredManagerState().pkce!! + assertThat(pkce.maxNetworkRetries, `is`(2)) + assertThat(pkce.networkRetryDelaySeconds, `is`(2)) + } + + @Test + public fun shouldKeepCustomTokenExchangeRetryAcrossProcessRestore() { + login(account) + .withTokenExchangeMaxRetries(4) + .withTokenExchangeRetryDelay(3) + .start(activity, callback) + + val pkce = restoredManagerState().pkce!! + assertThat(pkce.maxNetworkRetries, `is`(4)) + assertThat(pkce.networkRetryDelaySeconds, `is`(3)) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectNegativeTokenExchangeMaxRetries() { + login(account).withTokenExchangeMaxRetries(-1) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectTokenExchangeMaxRetriesAboveFour() { + login(account).withTokenExchangeMaxRetries(5) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectNegativeTokenExchangeRetryDelay() { + login(account).withTokenExchangeRetryDelay(-1) + } + + private fun restoredManagerState(): OAuthManagerState { + val json = (WebAuthProvider.managerInstance as OAuthManager).toState().serializeToJson() + return OAuthManagerState.deserializeState(json) + } + @Test public fun shouldStartLoginWithValidRequestCode() { val credentials = Mockito.mock( diff --git a/examples/my-account-api.md b/examples/my-account-api.md index 8e38d601e..22c6dab83 100644 --- a/examples/my-account-api.md +++ b/examples/my-account-api.md @@ -708,13 +708,13 @@ myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") ``` -### Get the User Profile +### Get the User Profile [Beta] **Scopes required:** `read:me:profile` Retrieves the current user's profile. Each entry in `profilePolicy` describes whether the user can edit a field (`access`), where its value comes from (`source`), and why it is read-only (`reason`), so you can render an editable or read-only UI without checking the connection type. Entries are keyed by a JSON Pointer to the field, e.g. `/given_name`. `GetUserProfileOptions` and `UpdateUserProfileRequest` are in the `com.auth0.android.myaccount.request` package. > [!NOTE] -> The My Account profile endpoints are currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). To request access to this feature, contact your Auth0 representative. +> The My Account profile API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). It might change before General Availability, and Auth0 does not support beta functionality for production use. The feature must be enabled for your tenant. **Prerequisites:** @@ -773,13 +773,13 @@ myAccountClient.getUserProfile(options).start(callback); ``` -### Update the User Profile +### Update the User Profile [Beta] **Scopes required:** `update:me:profile` Updates the user's editable profile fields, and returns the full updated profile. > [!NOTE] -> The My Account profile endpoints are currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). To request access to this feature, contact your Auth0 representative. +> The My Account profile API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). It might change before General Availability, and Auth0 does not support beta functionality for production use. The feature must be enabled for your tenant. - Properties left as `null` in `UpdateUserProfileRequest` are not sent and remain unchanged. - An empty `UpdateUserProfileRequest()` is valid and returns the current profile. @@ -787,7 +787,7 @@ Updates the user's editable profile fields, and returns the full updated profile **Prerequisites:** -The same as [Get the User Profile](#get-the-user-profile), with the `update:me:profile` scope. +The same as [Get the User Profile](#get-the-user-profile-beta), with the `update:me:profile` scope. ```kotlin val request = UpdateUserProfileRequest(givenName = "Jane", nickname = "jane") diff --git a/examples/web-auth-configuration.md b/examples/web-auth-configuration.md index 1e5c2b7da..0c075f940 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 2 times, 2 seconds 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 (up to 4) and the delay between them in seconds, or pass `0` retries to disable them: + +```kotlin +WebAuthProvider.login(account) + .withTokenExchangeMaxRetries(4) + .withTokenExchangeRetryDelay(3) + .start(this, callback) +``` + +
+ Using Java + +```java +WebAuthProvider.login(account) + .withTokenExchangeMaxRetries(4) + .withTokenExchangeRetryDelay(3) + .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).