Skip to content
Merged
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
40 changes: 40 additions & 0 deletions docs-website/docs/cookie-sync.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs-website/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand Down
Original file line number Diff line number Diff line change
@@ -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<NitroHeader>?): 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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<NitroHeader> =
info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray()
val status = info.httpStatusCode
Expand Down Expand Up @@ -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)
Expand Down
Loading