diff --git a/EXAMPLES.md b/EXAMPLES.md index 6f6b290f..2bc4504b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -21,6 +21,9 @@ - [Using Retry with Auth0 Class](#using-retry-with-auth0-class) - [Platform Support](#platform-support) - [Error Handling](#error-handling) +- [Android Networking Configuration](#android-networking-configuration) + - [Using Networking Options with Hooks](#using-networking-options-with-hooks) + - [Using Networking Options with Auth0 Class](#using-networking-options-with-auth0-class) - [IPSIE Session Expiry](#ipsie-session-expiry) - [Biometric Authentication](#biometric-authentication) - [Biometric Policy Types](#biometric-policy-types) @@ -637,6 +640,66 @@ function MyComponent() { 2. **Configure adequate overlap period**: Ensure your Auth0 tenant has at least 180 seconds token overlap configured 3. **Test on real devices**: Simulate network instability during testing to validate retry behavior +## Android Networking Configuration + +> **Platform Support:** Android only. Accepted on iOS for API compatibility but has no effect. + +The `androidNetworkingOptions` configuration option lets you tune the native networking client (`DefaultClient` from Auth0.Android's OkHttp-based stack) used for every request the native SDK makes on your behalf — web auth token exchange, credential renewal, MFA, passkeys, and My Account API calls. + +```ts +androidNetworkingOptions?: { + connectTimeout?: number; // seconds, default 10 + readTimeout?: number; // seconds, default 10 + writeTimeout?: number; // seconds, default 10 + callTimeout?: number; // seconds, default 0 (no limit) + defaultHeaders?: Record; // sent on every request, default {} + enableLogging?: boolean; // default false +}; +``` + +Any option you omit falls back to Auth0.Android's own default. + +> [!WARNING] +> `enableLogging` is **debug-only**. When enabled, Auth0.Android logs full HTTP request and response bodies to Logcat — including access, refresh, and ID tokens returned from token-endpoint calls, in plaintext. Never enable it in a production build. + +### Using Networking Options with Hooks + +```jsx +import React from 'react'; +import { Auth0Provider } from 'react-native-auth0'; + +function App() { + return ( + + + + ); +} +``` + +### Using Networking Options with Auth0 Class + +```js +import Auth0 from 'react-native-auth0'; + +const auth0 = new Auth0({ + domain: 'YOUR_AUTH0_DOMAIN', + clientId: 'YOUR_AUTH0_CLIENT_ID', + androidNetworkingOptions: { + connectTimeout: 30, + readTimeout: 30, + }, +}); +``` + ## IPSIE Session Expiry > **Platform Support:** iOS, Android, and Web. diff --git a/android/build.gradle b/android/build.gradle index 6d4e8d75..2a7f3ab7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -79,6 +79,9 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.browser:browser:1.10.0" implementation 'com.auth0.android:auth0:4.0.1' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' } react { diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index 90acf923..d4bcd0a4 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -20,6 +20,7 @@ import com.auth0.android.dpop.DPoPException import com.auth0.android.provider.BrowserPicker import com.auth0.android.provider.CustomTabsOptions import com.auth0.android.provider.WebAuthProvider +import com.auth0.android.request.DefaultClient import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.UserData import com.auth0.android.result.APICredentials @@ -64,6 +65,24 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 private const val DPOP_INVALID_TOKEN_TYPE_CODE = "DPOP_INVALID_TOKEN_TYPE" private const val DPOP_MISSING_PARAMETER_CODE = "DPOP_MISSING_PARAMETER" private const val DPOP_CLEAR_KEY_FAILED_CODE = "DPOP_CLEAR_KEY_FAILED" + + // Builds the DefaultClient Auth0.Android uses for every request it makes (web auth + // token exchange, credential renewal, MFA, passkeys, etc.). Unset keys fall through to + // Auth0.Android's own Builder defaults. `enableLogging` is debug-only: Auth0.Android logs + // full request/response bodies (including tokens) at that level, so we never call + // `logger(...)` ourselves and never expose the raw HttpLoggingInterceptor.Logger to JS. + internal fun buildNetworkingClient(options: ReadableMap): DefaultClient { + val builder = DefaultClient.Builder() + if (options.hasKey("connectTimeout")) builder.connectTimeout(options.getInt("connectTimeout")) + if (options.hasKey("readTimeout")) builder.readTimeout(options.getInt("readTimeout")) + if (options.hasKey("writeTimeout")) builder.writeTimeout(options.getInt("writeTimeout")) + if (options.hasKey("callTimeout")) builder.callTimeout(options.getInt("callTimeout")) + options.getMap("defaultHeaders")?.let { headers -> + builder.defaultHeaders(headers.toHashMap().mapValues { it.value?.toString() ?: "" }) + } + if (options.hasKey("enableLogging")) builder.enableLogging(options.getBoolean("enableLogging")) + return builder.build() + } } private val errorCodeMap = mapOf( @@ -282,6 +301,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 useDPoP: Boolean?, maxRetries: Double, credentialsManagerStorageKey: String?, + androidNetworkingOptions: ReadableMap?, promise: Promise ) { // Note: maxRetries parameter is ignored on Android as the Auth0.Android SDK @@ -290,6 +310,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 this.useDPoP = useDPoP ?: false auth0 = Auth0.getInstance(clientId, domain) + androidNetworkingOptions?.let { auth0!!.networkingClient = buildNetworkingClient(it) } mfaClient = MfaClient(auth0!!, this.useDPoP, reactContext) myAccount = MyAccount(auth0!!, this.useDPoP, reactContext) passwordless = Passwordless(auth0!!, this.useDPoP, reactContext) diff --git a/android/src/test/java/com/auth0/react/A0Auth0ModuleNetworkingOptionsTest.kt b/android/src/test/java/com/auth0/react/A0Auth0ModuleNetworkingOptionsTest.kt new file mode 100644 index 00000000..30ba456b --- /dev/null +++ b/android/src/test/java/com/auth0/react/A0Auth0ModuleNetworkingOptionsTest.kt @@ -0,0 +1,76 @@ +package com.auth0.react + +import com.auth0.android.request.HttpMethod +import com.auth0.android.request.RequestOptions +import com.facebook.react.bridge.JavaOnlyMap +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.system.measureTimeMillis + +// Proves that A0Auth0Module.buildNetworkingClient() genuinely threads androidNetworkingOptions +// into the DefaultClient it builds, rather than just compiling. Exercises the client against a +// real (local) server so the OkHttp timeout machinery actually runs. +class A0Auth0ModuleNetworkingOptionsTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `readTimeout from androidNetworkingOptions is applied to the built DefaultClient`() { + val configuredTimeoutSeconds = 1 + // Stall the response well past the configured timeout. + server.enqueue(MockResponse().setHeadersDelay(3, TimeUnit.SECONDS).setBody("{}")) + + val client = A0Auth0Module.buildNetworkingClient( + JavaOnlyMap.of("readTimeout", configuredTimeoutSeconds) + ) + + var threw = false + val elapsedMillis = measureTimeMillis { + try { + client.load(server.url("/").toString(), RequestOptions(HttpMethod.GET)) + fail("Expected the configured read timeout to fire") + } catch (e: IOException) { + threw = true + } + } + + assertTrue("Expected an IOException from the read timeout", threw) + // The server stalls for 3s; a working 1s readTimeout must fire well before that. + assertTrue( + "Expected the call to fail near the configured ${configuredTimeoutSeconds}s timeout, took ${elapsedMillis}ms", + elapsedMillis < TimeUnit.SECONDS.toMillis(2) + ) + } + + @Test + fun `defaultHeaders from androidNetworkingOptions are sent on every request`() { + server.enqueue(MockResponse().setBody("{}")) + + val client = A0Auth0Module.buildNetworkingClient( + JavaOnlyMap.of("defaultHeaders", JavaOnlyMap.of("X-Custom-Header", "custom-value")) + ) + + client.load(server.url("/").toString(), RequestOptions(HttpMethod.GET)) + + val recordedRequest = server.takeRequest() + assertTrue(recordedRequest.getHeader("X-Custom-Header") == "custom-value") + } +} diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm index 99024107..fbe3bd9a 100644 --- a/ios/A0Auth0.mm +++ b/ios/A0Auth0.mm @@ -100,8 +100,10 @@ - (dispatch_queue_t)methodQueue useDPoP:(nonnull NSNumber *)useDPoP maxRetries:(double)maxRetries credentialsManagerStorageKey:(NSString * _Nullable)credentialsManagerStorageKey + androidNetworkingOptions:(NSDictionary * _Nullable)androidNetworkingOptions resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { + // androidNetworkingOptions is Android-only; intentionally not forwarded to NativeBridge. [self tryAndInitializeNativeBridge:clientId domain:domain withLocalAuthenticationOptions:localAuthenticationOptions useDPoP:useDPoP maxRetries:(NSInteger)maxRetries credentialsManagerStorageKey:credentialsManagerStorageKey resolve:resolve reject:reject]; } diff --git a/src/core/utils/__tests__/configSignature.spec.ts b/src/core/utils/__tests__/configSignature.spec.ts index 5369f838..611b408e 100644 --- a/src/core/utils/__tests__/configSignature.spec.ts +++ b/src/core/utils/__tests__/configSignature.spec.ts @@ -84,4 +84,30 @@ describe('getConfigSignature', () => { getConfigSignature({ ...base, maxRetries: 3 }) ); }); + + it('differs when androidNetworkingOptions changes', () => { + expect( + getConfigSignature({ + ...base, + androidNetworkingOptions: { connectTimeout: 10 }, + }) + ).not.toBe( + getConfigSignature({ + ...base, + androidNetworkingOptions: { connectTimeout: 30 }, + }) + ); + }); + + it('is insensitive to androidNetworkingOptions key order', () => { + const a = getConfigSignature({ + ...base, + androidNetworkingOptions: { connectTimeout: 10, readTimeout: 20 }, + }); + const b = getConfigSignature({ + ...base, + androidNetworkingOptions: { readTimeout: 20, connectTimeout: 10 }, + }); + expect(a).toBe(b); + }); }); diff --git a/src/core/utils/configSignature.ts b/src/core/utils/configSignature.ts index acd6f426..ef0683fe 100644 --- a/src/core/utils/configSignature.ts +++ b/src/core/utils/configSignature.ts @@ -9,6 +9,7 @@ const SIGNIFICANT_KEYS = [ 'useDPoP', 'maxRetries', 'credentialsManagerStorageKey', + 'androidNetworkingOptions', ] as const satisfies ReadonlyArray; // Stable, order-independent identity string for a config: keys the factory cache, the provider memo, and the native re-init decision. Object values are sorted so key order doesn't matter. diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index edb962e4..fd3d217d 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -107,6 +107,7 @@ export class NativeAuth0Client implements IAuth0Client { useDPoP = false, maxRetries, credentialsManagerStorageKey, + androidNetworkingOptions, } = options; // Re-init when domain/clientId differ (hasValidInstance) or any other // identity option drifted from what was last applied to the native side. @@ -123,7 +124,8 @@ export class NativeAuth0Client implements IAuth0Client { localAuthenticationOptions, useDPoP, maxRetries, - credentialsManagerStorageKey + credentialsManagerStorageKey, + androidNetworkingOptions ); } // Record even on the skip path so siblings differing only in a diff --git a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts index 8b0c97de..7c5845ad 100644 --- a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts @@ -120,7 +120,8 @@ describe('NativeAuth0Client', () => { undefined, // No local auth options provided in this test false, // useDPoP defaults to false undefined, // maxRetries not provided - undefined // credentialsManagerStorageKey not provided + undefined, // credentialsManagerStorageKey not provided + undefined // androidNetworkingOptions not provided ); // Use client to avoid unused variable warning @@ -142,7 +143,8 @@ describe('NativeAuth0Client', () => { undefined, false, undefined, - 'tenant-b' + 'tenant-b', + undefined ); expect(client).toBeDefined(); }); @@ -163,13 +165,37 @@ describe('NativeAuth0Client', () => { localAuthOptions, false, // useDPoP defaults to false undefined, // maxRetries not provided - undefined // credentialsManagerStorageKey not provided + undefined, // credentialsManagerStorageKey not provided + undefined // androidNetworkingOptions not provided ); // Use client to avoid unused variable warning expect(client).toBeDefined(); }); + it('should pass androidNetworkingOptions to initialize when provided', async () => { + mockBridgeInstance.hasValidInstance.mockResolvedValue(false); + const androidNetworkingOptions = { connectTimeout: 30, readTimeout: 30 }; + + const client = new NativeAuth0Client({ + ...options, + androidNetworkingOptions, + }); + await new Promise(process.nextTick); + + expect(mockBridgeInstance.initialize).toHaveBeenCalledWith( + options.clientId, + options.domain, + undefined, + false, + undefined, + undefined, + androidNetworkingOptions + ); + + expect(client).toBeDefined(); + }); + it('should ensure initialization is complete before calling a bridge method', async () => { let resolveInitialization: () => void; const initializationPromise = new Promise((resolve) => { @@ -686,6 +712,7 @@ describe('NativeAuth0Client', () => { undefined, false, undefined, + undefined, undefined ); expect(mockBridgeInstance.authorize).toHaveBeenCalledTimes(1); @@ -729,6 +756,7 @@ describe('NativeAuth0Client', () => { undefined, false, // useDPoP flipped to false undefined, + undefined, undefined ); }); diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts index bb4e7f35..539e7aa6 100644 --- a/src/platforms/native/bridge/INativeBridge.ts +++ b/src/platforms/native/bridge/INativeBridge.ts @@ -9,6 +9,7 @@ import type { MfaEnrollmentChallenge, MfaChallengeResult, PasskeyChallengeResponse, + AndroidNetworkingOptions, } from '../../../types'; import type { LocalAuthenticationOptions, @@ -39,6 +40,7 @@ export interface INativeBridge { * @param useDPoP Whether to enable DPoP (Demonstrating Proof-of-Possession) for token requests. * @param maxRetries The maximum number of retry attempts for transient errors during credential renewal. **iOS only** - ignored on Android. Defaults to 0. * @param credentialsManagerStorageKey Namespaces the credentials store. **Android only** SharedPreferences file name. **iOS only** Keychain service name. Defaults to the shared store when omitted. + * @param androidNetworkingOptions Configures the native networking client. **Android only** - ignored on iOS. */ initialize( clientId: string, @@ -46,7 +48,8 @@ export interface INativeBridge { localAuthenticationOptions?: LocalAuthenticationOptions, useDPoP?: boolean, maxRetries?: number, - credentialsManagerStorageKey?: string + credentialsManagerStorageKey?: string, + androidNetworkingOptions?: AndroidNetworkingOptions ): Promise; /** diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index b99c7886..8d05a52e 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -11,6 +11,7 @@ import type { MfaEnrollmentChallenge, MfaChallengeResult, PasskeyChallengeResponse, + AndroidNetworkingOptions, } from '../../../types'; import { SafariViewControllerPresentationStyle, @@ -60,7 +61,8 @@ export class NativeBridgeManager implements INativeBridge { localAuthenticationOptions?: LocalAuthenticationOptions, useDPoP: boolean = false, maxRetries: number = 0, - credentialsManagerStorageKey?: string + credentialsManagerStorageKey?: string, + androidNetworkingOptions?: AndroidNetworkingOptions ): Promise { // This is a new method we'd add to the native side to ensure the // underlying Auth0.swift/Auth0.android SDKs are configured. @@ -73,7 +75,8 @@ export class NativeBridgeManager implements INativeBridge { localAuthenticationOptions, useDPoP, maxRetries, - credentialsManagerStorageKey + credentialsManagerStorageKey, + androidNetworkingOptions ); } diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index 1ac8c9f4..e8aca9fb 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -267,7 +267,8 @@ describe('NativeBridgeManager', () => { undefined, true, 0, - 'tenant-b' + 'tenant-b', + undefined ); }); @@ -282,7 +283,38 @@ describe('NativeBridgeManager', () => { undefined, // localAuthenticationOptions false, // useDPoP default 0, // maxRetries default - undefined // credentialsManagerStorageKey + undefined, // credentialsManagerStorageKey + undefined // androidNetworkingOptions + ); + }); + + it('forwards androidNetworkingOptions to the native module when provided', async () => { + const androidNetworkingOptions = { + connectTimeout: 30, + readTimeout: 30, + defaultHeaders: { 'X-Custom': 'value' }, + }; + + await bridge.initialize( + 'client-id', + 'tenant-c.auth0.com', + undefined, + false, + 0, + undefined, + androidNetworkingOptions + ); + + expect( + MockedAuth0NativeModule.initializeAuth0WithConfiguration + ).toHaveBeenCalledWith( + 'client-id', + 'tenant-c.auth0.com', + undefined, + false, + 0, + undefined, + androidNetworkingOptions ); }); diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts index 299b6f8a..e0a0c14a 100644 --- a/src/specs/NativeA0Auth0.ts +++ b/src/specs/NativeA0Auth0.ts @@ -27,7 +27,8 @@ export interface Spec extends TurboModule { | undefined, useDPoP: boolean | undefined, maxRetries: Int32, - credentialsManagerStorageKey: string | undefined + credentialsManagerStorageKey: string | undefined, + androidNetworkingOptions: Object | undefined ): Promise; /** diff --git a/src/types/common.ts b/src/types/common.ts index 14b7d52c..3560e6cb 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -214,9 +214,48 @@ export interface Auth0Options { * @remarks Native only (iOS/Android). Has no effect on the web platform. */ credentialsManagerStorageKey?: string; + /** + * Configures the native networking client (OkHttp) that Auth0.Android uses for every + * request it makes (web auth token exchange, credential renewal, MFA, passkeys, etc.). + * @remarks Android only. Accepted on iOS for API compatibility but has no effect. + */ + androidNetworkingOptions?: AndroidNetworkingOptions; // Telemetry and localAuthenticationOptions are platform-specific extensions } +/** + * Configuration for the native networking client used by Auth0.Android. + * Mirrors `DefaultClient.Builder` from the Auth0.Android SDK. + * + * @remarks Android only. Has no effect on iOS or web. + */ +export interface AndroidNetworkingOptions { + /** Connection timeout, in seconds. @default 10 */ + connectTimeout?: number; + /** Read timeout, in seconds. @default 10 */ + readTimeout?: number; + /** Write timeout, in seconds. @default 10 */ + writeTimeout?: number; + /** Overall timeout for the entire call, in seconds. `0` means no timeout. @default 0 */ + callTimeout?: number; + /** + * Headers sent on every request made by the native networking client. If a specific + * request sets a header with the same name, the request-level header takes precedence. + * @default {} + */ + defaultHeaders?: Record; + /** + * Enables verbose HTTP request/response logging to Logcat. + * + * @remarks + * **Debug-only.** Auth0.Android logs full request and response bodies at this level, + * which includes access, refresh, and ID tokens in plaintext for token-endpoint calls. + * Never enable this in production. + * @default false + */ + enableLogging?: boolean; +} + // ========= MFA Flexible Factors Grant Types ========= /** Represents an enrolled MFA authenticator. */