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
24 changes: 24 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,27 @@ jobs:
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f
with:
directory: coverage

android-test:
name: Run Android tests
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

- name: Setup
uses: ./.github/actions/setup

- name: Set up JDK 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '17'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5

- name: Run Android unit tests
working-directory: example/android
run: ./gradlew :react-native-auth0:testDebugUnitTest
63 changes: 63 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 `networkingOptions` 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
networkingOptions?: {
connectTimeout?: number; // seconds, default 10
readTimeout?: number; // seconds, default 10
writeTimeout?: number; // seconds, default 10
callTimeout?: number; // seconds, default 0 (no limit)
defaultHeaders?: Record<string, string>; // 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 (
<Auth0Provider
domain="YOUR_AUTH0_DOMAIN"
clientId="YOUR_AUTH0_CLIENT_ID"
networkingOptions={{
connectTimeout: 30,
readTimeout: 30,
defaultHeaders: { 'X-App-Version': '1.2.3' },
}}
>
<MyComponent />
</Auth0Provider>
);
}
```

### 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',
networkingOptions: {
connectTimeout: 30,
readTimeout: 30,
},
});
```

## IPSIE Session Expiry

> **Platform Support:** iOS, Android, and Web.
Expand Down
3 changes: 3 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
NandanPrabhu marked this conversation as resolved.
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
}

react {
Expand Down
49 changes: 43 additions & 6 deletions android/src/main/java/com/auth0/react/A0Auth0Module.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.auth0.react

import android.app.Activity
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
Expand All @@ -20,6 +21,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
Expand Down Expand Up @@ -64,6 +66,37 @@ 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, never expose the raw HttpLoggingInterceptor.Logger to JS, and
// ignore the option entirely unless the host app is a debug build.
internal fun buildNetworkingClient(options: ReadableMap, isDebuggable: Boolean): 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 (isDebuggable && options.hasKey("enableLogging")) {
builder.enableLogging(options.getBoolean("enableLogging"))
}
return builder.build()
}

// Auth0.getInstance() returns a shared singleton per clientId/domain: a sibling client
// (or this same client on re-init) must not inherit another initialization's networking
// config, so this always resolves to a fresh DefaultClient() when options are absent
// rather than leaving the previous networkingClient in place.
internal fun resolveNetworkingClient(
networkingOptions: ReadableMap?,
isDebuggable: Boolean
): DefaultClient =
networkingOptions?.let { buildNetworkingClient(it, isDebuggable) } ?: DefaultClient()
}

private val errorCodeMap = mapOf(
Expand Down Expand Up @@ -282,19 +315,23 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
useDPoP: Boolean?,
maxRetries: Double,
credentialsManagerStorageKey: String?,
networkingOptions: ReadableMap?,
promise: Promise
) {
// Note: maxRetries parameter is ignored on Android as the Auth0.Android SDK
// does not currently support retry configuration for credential renewal.
// This parameter is accepted for API compatibility with iOS.

this.useDPoP = useDPoP ?: false
auth0 = Auth0.getInstance(clientId, domain)
mfaClient = MfaClient(auth0!!, this.useDPoP, reactContext)
myAccount = MyAccount(auth0!!, this.useDPoP, reactContext)
passwordless = Passwordless(auth0!!, this.useDPoP, reactContext)

val authAPI = AuthenticationAPIClient(auth0!!)
val auth0Instance = Auth0.getInstance(clientId, domain)
auth0 = auth0Instance
val isDebuggable = (reactContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
auth0Instance.networkingClient = resolveNetworkingClient(networkingOptions, isDebuggable)
mfaClient = MfaClient(auth0Instance, this.useDPoP, reactContext)
myAccount = MyAccount(auth0Instance, this.useDPoP, reactContext)
passwordless = Passwordless(auth0Instance, this.useDPoP, reactContext)

val authAPI = AuthenticationAPIClient(auth0Instance)
if (this.useDPoP) {
authAPI.useDPoP(reactContext)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 networkingOptions
// 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 networkingOptions 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),
isDebuggable = true
)

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 networkingOptions are sent on every request`() {
server.enqueue(MockResponse().setBody("{}"))

val client = A0Auth0Module.buildNetworkingClient(
JavaOnlyMap.of("defaultHeaders", JavaOnlyMap.of("X-Custom-Header", "custom-value")),
isDebuggable = true
)

client.load(server.url("/").toString(), RequestOptions(HttpMethod.GET))

val recordedRequest = server.takeRequest()
assertTrue(recordedRequest.getHeader("X-Custom-Header") == "custom-value")
}

@Test
fun `enableLogging is ignored on a non-debuggable build even when requested`() {
server.enqueue(MockResponse().setBody("{}"))

// If the debuggable gate is ever removed, Auth0.Android attaches its logging
// interceptor and this request crashes ("Method ... not mocked") because
// android.util.Log isn't stubbed in this unit test environment - that crash is
// exactly the regression this test is meant to catch.
val client = A0Auth0Module.buildNetworkingClient(
JavaOnlyMap.of("enableLogging", true),
isDebuggable = false
)

client.load(server.url("/").toString(), RequestOptions(HttpMethod.GET))

val recordedRequest = server.takeRequest()
assertTrue(recordedRequest.method == "GET")
}

@Test
fun `re-initializing with the same configuration but no options restores the default client`() {
// First "initialization": custom options give a 1s readTimeout.
val customized = A0Auth0Module.resolveNetworkingClient(
JavaOnlyMap.of("readTimeout", 1),
isDebuggable = true
)

// Re-initialization with the same clientId/domain but networkingOptions omitted
// must not carry the previous readTimeout forward - it should behave like a fresh
// DefaultClient() (10s default readTimeout).
val reset = A0Auth0Module.resolveNetworkingClient(null, isDebuggable = true)

server.enqueue(MockResponse().setHeadersDelay(3, TimeUnit.SECONDS).setBody("{}"))
var threw = false
try {
customized.load(server.url("/").toString(), RequestOptions(HttpMethod.GET))
} catch (e: IOException) {
threw = true
}
assertTrue("Expected the 1s readTimeout to fire on the customized client", threw)

server.enqueue(MockResponse().setHeadersDelay(3, TimeUnit.SECONDS).setBody("{}"))
// Should comfortably survive the 3s delay under the restored 10s default readTimeout.
reset.load(server.url("/").toString(), RequestOptions(HttpMethod.GET))
}
}
2 changes: 2 additions & 0 deletions ios/A0Auth0.mm
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ - (dispatch_queue_t)methodQueue
useDPoP:(nonnull NSNumber *)useDPoP
maxRetries:(double)maxRetries
credentialsManagerStorageKey:(NSString * _Nullable)credentialsManagerStorageKey
networkingOptions:(NSDictionary * _Nullable)networkingOptions
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject) {
// networkingOptions 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];
}

Expand Down
26 changes: 26 additions & 0 deletions src/core/utils/__tests__/configSignature.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,30 @@ describe('getConfigSignature', () => {
getConfigSignature({ ...base, maxRetries: 3 })
);
});

it('differs when networkingOptions changes', () => {
expect(
getConfigSignature({
...base,
networkingOptions: { connectTimeout: 10 },
})
).not.toBe(
getConfigSignature({
...base,
networkingOptions: { connectTimeout: 30 },
})
);
});

it('is insensitive to networkingOptions key order', () => {
const a = getConfigSignature({
...base,
networkingOptions: { connectTimeout: 10, readTimeout: 20 },
});
const b = getConfigSignature({
...base,
networkingOptions: { readTimeout: 20, connectTimeout: 10 },
});
expect(a).toBe(b);
});
});
1 change: 1 addition & 0 deletions src/core/utils/configSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const SIGNIFICANT_KEYS = [
'useDPoP',
'maxRetries',
'credentialsManagerStorageKey',
'networkingOptions',
] as const satisfies ReadonlyArray<keyof Auth0Options>;

// 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.
Expand Down
4 changes: 3 additions & 1 deletion src/platforms/native/adapters/NativeAuth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class NativeAuth0Client implements Auth0Client {
useDPoP = false,
maxRetries,
credentialsManagerStorageKey,
networkingOptions,
} = options;
// Re-init when domain/clientId differ (hasValidInstance) or any other
// identity option drifted from what was last applied to the native side.
Expand All @@ -125,7 +126,8 @@ export class NativeAuth0Client implements Auth0Client {
localAuthenticationOptions,
useDPoP,
maxRetries,
credentialsManagerStorageKey
credentialsManagerStorageKey,
networkingOptions
);
}
// Record even on the skip path so siblings differing only in a
Expand Down
Loading
Loading