Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- We addressed a scenario where, in iOS, an Auth dialogue could continuously popup on app startup due to access of either large keychain blob items or blocked items.

## [v0.5.1] - 2026-06-22

- We fixed an issue that could cause iOS apps to restart repeatedly after an OTA update.
Expand Down
16 changes: 16 additions & 0 deletions android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mendixnative.cookie

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
Expand All @@ -18,6 +19,21 @@ class MxCookieModule(reactContext: ReactApplicationContext) :
cookieModule.clearAll(promise)
}

// The following methods are iOS-only (keychain / SessionCookieStore).
// They are no-ops on Android so the TurboModule spec is satisfied.

override fun persistTestCookies(count: Double, valueSize: Double, promise: Promise) {
promise.resolve(null)
}

override fun restoreSessionCookies(promise: Promise) {
promise.resolve(Arguments.createArray())
}

override fun getKeychainChunkCount(promise: Promise) {
promise.resolve(0.0)
}

companion object {
const val NAME = "MxCookie"
}
Expand Down
192 changes: 192 additions & 0 deletions example/__tests__/session-cookie-store.harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, test } from 'react-native-harness';
import { NativeCookie, NativeCookieTestHelpers } from 'mendix-native';

// Cookie sizing constants.
// 10 cookies × 7 000-char value → serialised blob ≈ 70–80 KB, above the 64 KB chunk threshold.
const LARGE_COUNT = 10;
const LARGE_VALUE_SIZE = 7_000;
// 3 cookies with tiny values → blob well under 64 KB (single-item path).
const SMALL_COUNT = 3;
const SMALL_VALUE_SIZE = 10;

describe('SessionCookieStore', () => {
beforeEach(async () => {
await NativeCookie.clearAll();
});

// ---------------------------------------------------------------------------
// Small-blob (single-item keychain format, ≤ 64 KB)
// ---------------------------------------------------------------------------

describe('small-blob round-trip (single-item format)', () => {
test('persists and restores small cookies', async () => {
await NativeCookieTestHelpers.persistTestCookies(
SMALL_COUNT,
SMALL_VALUE_SIZE
);

const names = await NativeCookieTestHelpers.restoreSessionCookies();

expect(names.length).toBe(SMALL_COUNT);
for (let i = 0; i < SMALL_COUNT; i++) {
expect(names).toContain(`testCookie${i}`);
}
});

test('single-item write does not create a chunk commit-marker', async () => {
await NativeCookieTestHelpers.persistTestCookies(
SMALL_COUNT,
SMALL_VALUE_SIZE
);

const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();

expect(chunkCount).toBe(0);
});

test('keychain is empty after restore (cleared on read)', async () => {
await NativeCookieTestHelpers.persistTestCookies(
SMALL_COUNT,
SMALL_VALUE_SIZE
);
await NativeCookieTestHelpers.restoreSessionCookies();

// A second restore should find nothing.
const names = await NativeCookieTestHelpers.restoreSessionCookies();

expect(names.length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// Large-blob (chunked keychain format, > 64 KB)
// ---------------------------------------------------------------------------

describe('large-blob round-trip (chunked format)', () => {
test('persists and restores large cookies', async () => {
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);

const names = await NativeCookieTestHelpers.restoreSessionCookies();

expect(names.length).toBe(LARGE_COUNT);
for (let i = 0; i < LARGE_COUNT; i++) {
expect(names).toContain(`testCookie${i}`);
}
});

test('chunked write creates a commit-marker with count > 1', async () => {
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);

const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();

expect(chunkCount).toBeGreaterThan(1);
});

test('commit-marker is removed after restore (chunked keys cleared on read)', async () => {
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);
await NativeCookieTestHelpers.restoreSessionCookies();

const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();

expect(chunkCount).toBe(0);
});

test('keychain is empty after restore (no second restore possible)', async () => {
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);
await NativeCookieTestHelpers.restoreSessionCookies();

const names = await NativeCookieTestHelpers.restoreSessionCookies();

expect(names.length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// Format transitions
// ---------------------------------------------------------------------------

describe('format transitions', () => {
test('overwriting large (chunked) with small (single-item) leaves no chunk marker', async () => {
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);

// Overwrite with a small set.
await NativeCookieTestHelpers.persistTestCookies(
SMALL_COUNT,
SMALL_VALUE_SIZE
);

const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
expect(chunkCount).toBe(0);

const names = await NativeCookieTestHelpers.restoreSessionCookies();
expect(names.length).toBe(SMALL_COUNT);
});

test('overwriting small (single-item) with large (chunked) round-trips correctly', async () => {
await NativeCookieTestHelpers.persistTestCookies(
SMALL_COUNT,
SMALL_VALUE_SIZE
);

// Overwrite with a large set.
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);

const names = await NativeCookieTestHelpers.restoreSessionCookies();
expect(names.length).toBe(LARGE_COUNT);
});
});

// ---------------------------------------------------------------------------
// clearAll
// ---------------------------------------------------------------------------

describe('clearAll', () => {
test('removes cookies after a small-blob persist', async () => {
await NativeCookieTestHelpers.persistTestCookies(
SMALL_COUNT,
SMALL_VALUE_SIZE
);
await NativeCookie.clearAll();

const names = await NativeCookieTestHelpers.restoreSessionCookies();
expect(names.length).toBe(0);
});

test('removes cookies and chunk marker after a large-blob persist', async () => {
await NativeCookieTestHelpers.persistTestCookies(
LARGE_COUNT,
LARGE_VALUE_SIZE
);
await NativeCookie.clearAll();

const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount();
expect(chunkCount).toBe(0);

const names = await NativeCookieTestHelpers.restoreSessionCookies();
expect(names.length).toBe(0);
});

test('does not throw when called on an already-empty store', async () => {
await expect(NativeCookie.clearAll()).resolves.not.toThrow();
await expect(NativeCookie.clearAll()).resolves.not.toThrow();
});
});
});
43 changes: 42 additions & 1 deletion ios/Modules/NativeCookieModule/NativeCookieModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,53 @@ public class NativeCookieModule: NSObject {
NativeCookieModule.clearAll()
promise.resolve(nil)
}

static func clearAll() {
let storage = HTTPCookieStorage.shared
for cookie in (storage.cookies ?? []) {
storage.deleteCookie(cookie)
}
SessionCookieStore.clear()
}

// MARK: - Test / diagnostic helpers (DEBUG builds only)
// These methods are excluded from release builds to prevent cookie injection,
// session DoS, and keychain information disclosure from arbitrary JS callers.

#if DEBUG
/// Writes `count` synthetic session cookies with `valueSize`-byte values directly
/// to the keychain (bypasses HTTPCookieStorage) and resolves once the write completes.
public func persistTestCookies(count: Int, valueSize: Int, promise: Promise) {
SessionCookieStore.persistTestCookies(count: count, valueSize: valueSize) {
promise.resolve(nil)
}
}

public func restoreSessionCookies(_ promise: Promise) {
let names = SessionCookieStore.restoreTestCookieNames()
promise.resolve(names)
}

/// Returns the integer stored in the `_chunkcount` commit-marker keychain item,
/// or `0` if no chunked write exists (single-item or empty).
public func getKeychainChunkCount(_ promise: Promise) {
let bundleId = Bundle.main.bundleIdentifier ?? "com.mendix.app"
let countKey = bundleId + "sessionCookies_chunkcount"
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: countKey,
kSecReturnData: true,
]
var ref: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &ref)
if status == errSecSuccess,
let data = ref as? Data,
let str = String(data: data, encoding: .utf8),
let count = Int(str) {
promise.resolve(NSNumber(value: count))
} else {
promise.resolve(NSNumber(value: 0))
}
}
#endif
}
Loading
Loading