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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Unreleased

- Add pluggable cookie jar primitives with `CookiePolicy`, `CookieStore`, `MemoryCookieStore`, `CookieJar`, and `CookieKey`.
- Add stored-cookie creation and last-access metadata for jar sorting and storage use cases.

## 0.2.0

### Breaking Changes
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ final stored = StoredCookie.fromSetCookie(
print(stored.matches(Uri.parse('https://example.com/profile'))); // true
print(stored.toRequestCookie()); // sid=abc

final jar = CookieJar();
await jar.save(
Uri.parse('https://example.com/login'),
['sid=abc; Path=/; HttpOnly'],
);
print(await jar.header(Uri.parse('https://example.com/profile'))); // sid=abc

final values = Cookie.splitSetCookie(
'a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT, c=d; Path=/',
);
Expand All @@ -48,6 +55,8 @@ print(values); // [a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT, c=d; Path=/]
- `Cookie.fromString` - Parse a set-cookie string to Cookie instance.
- `Cookie.splitSetCookie` - Split a string of multiple set-cookie values into a set-cookie string list.
- `StoredCookie.fromSetCookie` - Normalize a Set-Cookie value for request matching.
- `CookieJar` - Save Set-Cookie values and build matching Cookie request headers.
- `CookieStore` - Plug in custom persistence behind the jar policy layer.

## CopyWith And Clear

Expand Down
2 changes: 2 additions & 0 deletions lib/ocookie.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export 'src/cookie.dart';
export 'src/cookie_jar.dart';
export 'src/cookie_key.dart';
export 'src/stored_cookie.dart';
export 'src/types.dart';
315 changes: 315 additions & 0 deletions lib/src/cookie_jar.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import 'cookie.dart';
import 'cookie_key.dart';
import 'stored_cookie.dart';
import 'types.dart';

/// RFC cookie-store policy that is independent from storage.
final class CookiePolicy {
const CookiePolicy();

/// Parses and normalizes a Set-Cookie value received for [requestUri].
StoredCookie normalizeSetCookie(
String setCookie, {
required Uri requestUri,
DateTime? now,
CookieCodec? decode,
}) {
return StoredCookie.fromSetCookie(
setCookie,
requestUri: requestUri,
now: now,
decode: decode,
);
}

/// Normalizes [cookie] as if it was received for [requestUri].
StoredCookie normalizeCookie(
Cookie cookie, {
required Uri requestUri,
DateTime? now,
}) {
return StoredCookie.fromCookie(
cookie,
requestUri: requestUri,
now: now,
);
}

/// Whether [cookie] should be sent to [uri] at [now].
bool matches(StoredCookie cookie, Uri uri, DateTime now) {
return cookie.matches(uri, now: now);
}

/// Sorts cookies for a Cookie request header.
///
/// Longer paths are ordered first. Cookies with the same path length are
/// ordered by earlier creation time.
List<StoredCookie> sortForHeader(Iterable<StoredCookie> cookies) {
return cookies.toList()
..sort((a, b) {
final pathOrder = b.path.length.compareTo(a.path.length);
if (pathOrder != 0) {
return pathOrder;
}

return a.creationTime.compareTo(b.creationTime);
});
}

/// Serializes one stored cookie as a request Cookie header pair.
String toRequestCookie(StoredCookie cookie, {CookieCodec? encode}) {
return cookie.toRequestCookie(encode: encode);
}

/// Serializes stored cookies as a single Cookie request header value.
///
/// Set [sort] to false when [cookies] are already in header order.
String toRequestHeaderValue(
Iterable<StoredCookie> cookies, {
CookieCodec? encode,
bool sort = true,
}) {
final headerCookies = sort ? sortForHeader(cookies) : cookies;
return headerCookies
.map((cookie) => toRequestCookie(cookie, encode: encode))
.join('; ');
}
}

/// Pluggable persistence boundary for stored cookies.
abstract interface class CookieStore {
/// Loads cookies that may match or be replaced by cookies from [uri].
Future<Iterable<StoredCookie>> loadCandidates(Uri uri);

/// Inserts or replaces [cookie] by name, domain, path, and host-only state.
Future<void> upsert(StoredCookie cookie);

/// Deletes the cookie identified by [key].
Future<void> delete(CookieKey key);

/// Removes every cookie.
Future<void> clear();
}

/// Dependency-light in-memory cookie storage.
final class MemoryCookieStore implements CookieStore {
final Map<CookieKey, StoredCookie> _cookies = <CookieKey, StoredCookie>{};

@override
Future<void> clear() async {
_cookies.clear();
}

@override
Future<void> delete(CookieKey key) async {
_cookies.remove(key);
}

@override
Future<Iterable<StoredCookie>> loadCandidates(Uri uri) async {
return _cookies.values.toList(growable: false);
}

@override
Future<void> upsert(StoredCookie cookie) async {
_cookies[CookieKey.fromStoredCookie(cookie)] = cookie;
}
}

/// Cookie jar that combines RFC policy with a pluggable store.
final class CookieJar {
CookieJar({
CookieStore? store,
this.policy = const CookiePolicy(),
}) : store = store ?? MemoryCookieStore();

/// Cookie persistence boundary used by this jar.
final CookieStore store;

/// Policy used for normalization, matching, sorting, and serialization.
final CookiePolicy policy;

/// Saves received Set-Cookie header values for [uri].
Future<void> save(
Uri uri,
Iterable<String> setCookieValues, {
DateTime? now,
CookieCodec? decode,
}) async {
final nowUtc = (now ?? DateTime.now()).toUtc();
final existing = await _loadByKey(uri, nowUtc);

for (final setCookie in setCookieValues) {
final cookie = policy.normalizeSetCookie(
setCookie,
requestUri: uri,
now: nowUtc,
decode: decode,
);

await _storeNormalized(cookie, existing, nowUtc, uri);
}
}

/// Saves parsed cookies as if they were received for [uri].
Future<void> saveCookies(
Uri uri,
Iterable<Cookie> cookies, {
DateTime? now,
}) async {
final nowUtc = (now ?? DateTime.now()).toUtc();
final existing = await _loadByKey(uri, nowUtc);

for (final cookie in cookies) {
final stored = policy.normalizeCookie(
cookie,
requestUri: uri,
now: nowUtc,
);

await _storeNormalized(stored, existing, nowUtc, uri);
}
}

/// Loads stored cookies that match [uri].
Future<List<StoredCookie>> loadStored(Uri uri, {DateTime? now}) async {
final nowUtc = (now ?? DateTime.now()).toUtc();
final matched = <StoredCookie>[];

for (final cookie in await store.loadCandidates(uri)) {
final key = CookieKey.fromStoredCookie(cookie);
if (cookie.isExpired(nowUtc)) {
await store.delete(key);
continue;
}

if (policy.matches(cookie, uri, nowUtc)) {
final accessed = cookie.copyWith(lastAccessTime: nowUtc);
await store.upsert(accessed);
matched.add(accessed);
}
}

return policy.sortForHeader(matched);
}

/// Loads parsed cookies that match [uri].
Future<List<Cookie>> load(Uri uri, {DateTime? now}) async {
return [
for (final stored in await loadStored(uri, now: now)) stored.cookie,
];
}

/// Builds the Cookie request header value for [uri].
///
/// Returns null when no stored cookie matches.
Future<String?> header(
Uri uri, {
DateTime? now,
CookieCodec? encode,
}) async {
final cookies = await loadStored(uri, now: now);
if (cookies.isEmpty) {
return null;
}

return policy.toRequestHeaderValue(cookies, encode: encode, sort: false);
}

/// Removes every cookie from the underlying store.
Future<void> clear() {
return store.clear();
}

Future<Map<CookieKey, StoredCookie>> _loadByKey(
Uri uri,
DateTime now,
) async {
final result = <CookieKey, StoredCookie>{};
for (final cookie in await store.loadCandidates(uri)) {
final key = CookieKey.fromStoredCookie(cookie);
if (cookie.isExpired(now)) {
await store.delete(key);
} else {
result[key] = cookie;
}
}

return result;
}

Future<void> _storeNormalized(
StoredCookie cookie,
Map<CookieKey, StoredCookie> existing,
DateTime now,
Uri uri,
) async {
final key = CookieKey.fromStoredCookie(cookie);
final previous = existing[key];
Comment thread
medz marked this conversation as resolved.
if (_wouldOverlaySecureCookie(cookie, existing.values, uri)) {
return;
}

if (cookie.isExpired(now)) {
await store.delete(key);
existing.remove(key);
return;
}

final normalized = previous == null
? cookie
: cookie.copyWith(creationTime: previous.creationTime);
await store.upsert(normalized);
Comment thread
medz marked this conversation as resolved.
existing[key] = normalized;
}

bool _wouldOverlaySecureCookie(
StoredCookie cookie,
Iterable<StoredCookie> existing,
Uri uri,
) {
if (cookie.cookie.secure || uri.scheme == 'https') {
return false;
}

return existing.any((stored) {
return stored.cookie.secure &&
stored.cookie.name == cookie.cookie.name &&
_domainsOverlap(stored, cookie) &&
_pathMatches(cookie.path, stored.path);
});
}

bool _domainsOverlap(StoredCookie a, StoredCookie b) {
if (a.hostOnly && b.hostOnly) {
return a.domain == b.domain;
}
if (a.hostOnly) {
return _domainMatches(a.domain, b.domain);
}
if (b.hostOnly) {
return _domainMatches(b.domain, a.domain);
}

return _domainMatches(a.domain, b.domain) ||
_domainMatches(b.domain, a.domain);
}

bool _domainMatches(String host, String domain) {
return host == domain || host.endsWith('.$domain');
}

bool _pathMatches(String requestPath, String cookiePath) {
if (requestPath == cookiePath) {
return true;
}
if (!requestPath.startsWith(cookiePath)) {
return false;
}
if (cookiePath.endsWith('/')) {
return true;
}

return requestPath[cookiePath.length] == '/';
}
}
Loading