diff --git a/docs-website/docs/cookie-sync.md b/docs-website/docs/cookie-sync.md new file mode 100644 index 0000000..365d075 --- /dev/null +++ b/docs-website/docs/cookie-sync.md @@ -0,0 +1,40 @@ +--- +id: cookie-sync +title: Cookie Sync (Android) +sidebar_position: 12 +--- + +# Cookie Sync (Android) + +Bridges Android's WebView [`CookieManager`](https://developer.android.com/reference/android/webkit/CookieManager) with `nitro-fetch`'s Cronet client and the cold-start token-refresh path. Useful when your auth flow stores the session cookie in the WebView cookie jar (e.g. SAML, OAuth login pages rendered in a WebView) and you need subsequent native fetches to send it. + +When enabled: + +- **Outbound requests**: if the request has no `Cookie` header, the matching cookies from `CookieManager` are attached for the request URL. +- **Inbound responses**: any `Set-Cookie` headers (including those returned during redirects) are stored back into `CookieManager`. Persistence is flushed once per request after the final response. + +User-set `Cookie` headers are always respected — sync never overwrites them. + +## Enable + +Cookie sync is **opt-in** and disabled by default. Enable it once from your `Application.onCreate()` (or any code path that runs before the first fetch / auto-prefetch): + +```kotlin +// android/app/src/main/java/.../MainApplication.kt +import com.margelo.nitro.nitrofetch.NitroCookieSync + +class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + NitroCookieSync.enableCookieSync() + // ...rest of your onCreate + } +} +``` + +That's it — both the Cronet client (`fetch`) and the `HttpURLConnection` token-refresh path will start syncing cookies on the next request. + +## Notes + +- **Android only.** iOS `URLSession` already shares cookies with `WKWebView` via `HTTPCookieStorage` and needs no opt-in. +- **Token-refresh redirects**: `HttpURLConnection` follows redirects internally and only the final response's `Set-Cookie` headers are visible. If your refresh endpoint sets cookies on a 3xx hop, point it directly at the final URL. diff --git a/docs-website/sidebars.ts b/docs-website/sidebars.ts index 4cdc1da..69cc245 100644 --- a/docs-website/sidebars.ts +++ b/docs-website/sidebars.ts @@ -20,7 +20,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Advanced', - items: ['worklets', 'inspection', 'global-replace'], + items: ['worklets', 'inspection', 'global-replace', 'cookie-sync'], }, 'skills', 'troubleshooting', diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt index f6872f7..45c64b6 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt @@ -226,12 +226,22 @@ object AutoPrefetcher { conn.setRequestProperty(k, reqHeaders.optString(k, "")) } + NitroCookieSync.attachCookieFromManagerIfMissing( + urlStr, + NitroCookieSync.hasCookieHeaderInJson(reqHeaders) + ) { key, value -> conn.setRequestProperty(key, value) } + if (body != null) { conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val status = conn.responseCode - if (status !in 200..299) return null + if (status !in 200..299) { + android.util.Log.d("NitroFetch", "[TokenRefresh] Refresh endpoint returned HTTP $status") + return null + } + + NitroCookieSync.storeSetCookieFromHttpURLConnection(conn.url.toString(), conn, flush = true) val responseBody = conn.inputStream.use { it.bufferedReader(Charsets.UTF_8).readText() } diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt new file mode 100644 index 0000000..eb2d3a0 --- /dev/null +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroCookieSync.kt @@ -0,0 +1,138 @@ +package com.margelo.nitro.nitrofetch + +import android.util.Log +import android.webkit.CookieManager +import org.json.JSONObject +import org.chromium.net.UrlResponseInfo +import java.net.HttpURLConnection + +/** + * Shared [CookieManager] bridging for Cronet and [HttpURLConnection] token refresh. + * - Attaches `Cookie` from the jar when the request has no `Cookie` header. + * - Persists `Set-Cookie` responses; [flush] is applied only when at least one cookie was stored. + * + * **Opt-in:** Cookie sync is disabled by default to avoid changing behaviour for consumers + * that do not rely on the WebView cookie jar. Call [enableCookieSync] before any requests. + */ +object NitroCookieSync { + private const val LOG_TAG = "NitroCookieSync" + + @Volatile + private var enabled = false + + /** + * Enable cookie synchronisation between Cronet / HttpURLConnection and the system + * [CookieManager]. Call once (e.g. from `Application.onCreate`) before any fetch or + * autoprefetch work. Has no effect when called multiple times. + */ + @JvmStatic + fun enableCookieSync() { + enabled = true + } + + @JvmStatic + fun isCookieSyncEnabled(): Boolean = enabled + + fun hasCookieHeaderInNitroRequest(headers: Array?): Boolean { + return headers?.any { it.key.equals("Cookie", ignoreCase = true) } == true + } + + fun hasCookieHeaderInJson(reqHeaders: JSONObject?): Boolean { + if (reqHeaders == null) return false + return reqHeaders.keys().asSequence().any { it.equals("Cookie", ignoreCase = true) } + } + + /** + * If [hasCookieHeader] is false, adds `Cookie` from [CookieManager] for [url] via [addHeader]. + * No-op when cookie sync is [disabled][enableCookieSync]. + */ + fun attachCookieFromManagerIfMissing( + url: String, + hasCookieHeader: Boolean, + addHeader: (String, String) -> Unit + ) { + if (!enabled) return + if (hasCookieHeader) return + try { + val jar = CookieManager.getInstance() + val cookieHeader = jar.getCookie(url) + if (!cookieHeader.isNullOrEmpty()) { + addHeader("Cookie", cookieHeader) + } + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to attach cookie header", exception) + } + } + + /** + * Applies `Set-Cookie` headers from a Cronet [UrlResponseInfo] into [CookieManager]. + * @param flush If true, [CookieManager.flush] runs only when at least one cookie was applied. + * Use `flush = false` on redirects so persistence happens once on the final response. + * @return true if at least one `Set-Cookie` was stored. + */ + fun storeSetCookieFromUrlResponseInfo( + responseUrl: String, + info: UrlResponseInfo, + flush: Boolean + ): Boolean { + if (!enabled) return false + return try { + val cookieManager = CookieManager.getInstance() + val setCookieHeaders = info.allHeadersAsList.filter { + it.key.equals("Set-Cookie", ignoreCase = true) + } + if (setCookieHeaders.isEmpty()) return false + for (header in setCookieHeaders) { + cookieManager.setCookie(responseUrl, header.value) + } + if (flush) { + cookieManager.flush() + } + true + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to store response cookies", exception) + false + } + } + + /** + * Applies `Set-Cookie` from an [HttpURLConnection] response into [CookieManager]. + * @param flush If true, [CookieManager.flush] runs only when at least one cookie was applied. + */ + fun storeSetCookieFromHttpURLConnection( + urlStr: String, + conn: HttpURLConnection, + flush: Boolean + ): Boolean { + if (!enabled) return false + return try { + val cookieManager = CookieManager.getInstance() + var anySet = false + conn.headerFields?.forEach { (key, values) -> + if (key?.equals("Set-Cookie", ignoreCase = true) == true) { + values.forEach { cookieValue -> + cookieManager.setCookie(urlStr, cookieValue) + anySet = true + } + } + } + if (anySet && flush) { + cookieManager.flush() + } + anySet + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to store response cookies (HttpURLConnection)", exception) + false + } + } + + /** Persists in-memory cookie updates to disk (call after a successful request when any `Set-Cookie` was applied). */ + fun flushCookieManager() { + if (!enabled) return + try { + CookieManager.getInstance().flush() + } catch (exception: Exception) { + Log.w(LOG_TAG, "Failed to flush CookieManager", exception) + } + } +} diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt index f1c7015..d75fa85 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt @@ -90,11 +90,17 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E private val buffer = ByteBuffer.allocateDirect(16 * 1024) private val out = java.io.ByteArrayOutputStream() private var redirectStopped = false + /** True if a redirect response applied at least one `Set-Cookie` (in memory, not yet flushed). */ + private var setCookieAppliedOnRedirect = false private var devToolsBytes = 0 private var devToolsTextual = false override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { if (shouldFollowRedirects) { + // Apply Set-Cookie in-memory; flush once in onSucceeded (avoid flush per hop). + if (NitroCookieSync.storeSetCookieFromUrlResponseInfo(info.url, info, flush = false)) { + setCookieAppliedOnRedirect = true + } request.followRedirect() } else { // Return the redirect response as-is without following @@ -162,6 +168,11 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E DevToolsReporter.reportResponseEnd(devToolsRequestId, devToolsBytes.toLong()) } try { + val storedOnFinal = + NitroCookieSync.storeSetCookieFromUrlResponseInfo(info.url, info, flush = false) + if (storedOnFinal || setCookieAppliedOnRedirect) { + NitroCookieSync.flushCookieManager() + } val headersArr: Array = info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray() val status = info.httpStatusCode @@ -221,6 +232,11 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E builder.setHttpMethod(method) req.headers?.forEach { (k, v) -> builder.addHeader(k, v) } + NitroCookieSync.attachCookieFromManagerIfMissing( + url, + NitroCookieSync.hasCookieHeaderInNitroRequest(req.headers) + ) { key, value -> builder.addHeader(key, value) } + val formParts = req.bodyFormData if (formParts != null && formParts.isNotEmpty()) { val (multipartBody, contentType) = buildMultipartBody(formParts)