Skip to content

Cookie Storage

Алексей . edited this page Jul 26, 2026 · 1 revision

Cookie Storage

English Русский

LogsParser does not own cookie persistence — the caller does, through the ICookieStorage contract. This page documents the contract and its exact semantics, the built-in MemoryCookieStorage, how to write a storage that survives process restarts, and how a storage is selected for a request.

Why the contract exists

Authentication is reactive: there is no LoginAsync to call first. The transport reacts to the service's 302 redirects, so the very first data request of a process performs the login and the two-factor confirmation when the session cookies it sends are missing or stale. Everything that identifies that session lives in cookies.

A storage that only lives in memory therefore means a full login on every process start — new credentials POST, a freshly generated TOTP code, and another pass through the site's login pages before any data is returned. A storage that writes its contents somewhere durable and reads them back in its constructor hands the transport a session that is already authenticated, and the first request goes straight to the data.

The library never picks that location for you. It reads cookies before sending a request and writes them back after every response; where they go in between is entirely the implementation's business.

The ICookieStorage contract

Namespace: LogsParser.Abstractions.

public interface ICookieStorage
IReadOnlyCollection<ParserCookie> GetCookies();
void SetCookies(IReadOnlyCollection<ParserCookie> cookies);
Member Returns Description
GetCookies() IReadOnlyCollection<ParserCookie> The full current cookie set. Called before every request to build the Cookie header, and at the start of every update.
SetCookies(IReadOnlyCollection<ParserCookie> cookies) void Replaces the entire cookie set with cookies. Called after responses that carry Set-Cookie and when the anti-DDoS token is stored.

ParserCookie

Namespace: LogsParser (root).

public sealed record ParserCookie(string Name, string Value);
Property Type Description
Name string Cookie name, compared with StringComparer.Ordinal everywhere in the library.
Value string Cookie value, stored and sent verbatim — no encoding or decoding is applied.

Semantics an implementation must honour

Rule Detail
Name/value pairs only Every Set-Cookie attribute — expires, max-age, path, domain, secure, httponly, samesite — is parsed off and discarded before the cookie reaches the storage. Only the first name=value segment of the header survives. A storage is not expected to model expiry or scope, and nothing in the library will ask it to.
SetCookies is a full replace It is not a merge. The collection passed in is the complete new state; anything previously held and not present in the argument is gone. Merging is done by the library before the call, never by the storage.
Ordinal name comparison Cookie names are compared with StringComparer.Ordinal. XSRF-TOKEN and xsrf-token are two different cookies.
GetCookies returns a snapshot Return a copy, not the live backing collection. The caller must not be able to mutate the storage's state behind its back, and the storage must not observe the caller's edits.
Thread safety Both methods can be called concurrently — from several requests in flight, from the authentication flow, from the challenge solver — so an implementation must be safe to call from several threads.
Blank names are meaningless The library never emits a cookie with an empty or whitespace name; a storage may drop such entries, as MemoryCookieStorage does.
One entry per name The Cookie header is built by joining Name=Value pairs with "; " in the order GetCookies returned them. Duplicate names would be sent twice, so de-duplicate on write.

MemoryCookieStorage

The default implementation. Namespace: LogsParser.Abstractions (file lives in Infrastructure/Cookies/).

public sealed class MemoryCookieStorage : ICookieStorage
  • sealed, with the implicit parameterless constructor.
  • Both methods take a private lock, so the type is safe to share between concurrent requests.
  • GetCookies returns a fresh array each call — a snapshot, never the internal list.
  • SetCookies drops entries whose Name is null, empty or whitespace, then de-duplicates by Name with StringComparer.Ordinal, keeping the first occurrence of each name.
  • State is per instance and lives only as long as the process. Two instances share nothing; nothing is written anywhere. Restarting the process loses the session.

Throws

Method Exception When
SetCookies ArgumentNullException cookies is null.
using LogsParser;
using LogsParser.Abstractions;

var storage = new MemoryCookieStorage();

storage.SetCookies(
[
    new ParserCookie("arizonarp_session", "abc"),
    new ParserCookie("arizonarp_session", "ignored-duplicate"),
    new ParserCookie("", "dropped"),
    new ParserCookie("XSRF-TOKEN", "xyz")
]);

foreach (var cookie in storage.GetCookies())
{
    Console.WriteLine($"{cookie.Name}={cookie.Value}");
}

// arizonarp_session=abc
// XSRF-TOKEN=xyz

What ends up in the store

The site's session cookies and the anti-DDoS token share one flat store — there is no separation by purpose, domain or path, and no second storage anywhere in the library.

Cookie Written by Meaning
arizonarp_session the service, via Set-Cookie on any response The authenticated session. Losing it means a full login on the next request.
XSRF-TOKEN the service, via Set-Cookie on any response The CSRF cookie the site issues alongside the session.
R3ACTLB the library, after solving the React anti-DDoS challenge The challenge token, stored under this exact name and replayed on subsequent requests.

Because they live together, persisting the storage persists both the session and a solved challenge token: one file, one restore, no separate bookkeeping. See Transport and Authentication for the flows that produce these values.

Only names and values are kept. Two consequences worth knowing: the library never expires a cookie on its own, and a server-side deletion (Set-Cookie: name=; expires=…) is recorded as an entry with an empty value rather than as a removal.

Persisting a session to disk

A complete implementation. It loads its contents in the constructor, writes them in SetCookies, and guards everything with one instance-level lock.

using System.Text.Json;
using LogsParser;
using LogsParser.Abstractions;

public sealed class FileCookieStorage : ICookieStorage
{
    private readonly object _sync = new();
    private readonly string _path;
    private Dictionary<string, string> _cookies;

    public FileCookieStorage(string path)
    {
        _path = path ?? throw new ArgumentNullException(nameof(path));
        _cookies = Load(path);
    }

    public IReadOnlyCollection<ParserCookie> GetCookies()
    {
        lock (_sync)
        {
            return _cookies.Select(pair => new ParserCookie(pair.Key, pair.Value)).ToArray();
        }
    }

    public void SetCookies(IReadOnlyCollection<ParserCookie> cookies)
    {
        ArgumentNullException.ThrowIfNull(cookies);

        lock (_sync)
        {
            _cookies = cookies
                .Where(cookie => !string.IsNullOrWhiteSpace(cookie.Name))
                .GroupBy(cookie => cookie.Name, StringComparer.Ordinal)
                .ToDictionary(group => group.Key, group => group.First().Value, StringComparer.Ordinal);

            Save();
        }
    }

    private void Save()
    {
        var directory = Path.GetDirectoryName(_path);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }

        var temporary = _path + ".tmp";
        File.WriteAllText(temporary, JsonSerializer.Serialize(_cookies));
        File.Move(temporary, _path, overwrite: true);
    }

    private static Dictionary<string, string> Load(string path)
    {
        if (!File.Exists(path))
        {
            return new Dictionary<string, string>(StringComparer.Ordinal);
        }

        var restored = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path));

        return restored is null
            ? new Dictionary<string, string>(StringComparer.Ordinal)
            : new Dictionary<string, string>(restored, StringComparer.Ordinal);
    }
}

Wiring it in is the only change at the call site:

using LogsParser;
using LogsParser.Models;
using LogsParser.Net;

using var dataSource = new LogsParserHttpDataSource(
    credentials: new LogsParserCredentials("my_login", "my_password", "BASE32SECRET"),
    cookieStorage: new FileCookieStorage("state/logsparser-session.json"));

var client = new LogsParserClient(dataSource);

// First run: logs in, confirms 2FA, writes the session to state/logsparser-session.json.
// Later runs: the session is restored in the constructor and no login happens at all.
var page = await client.GetLogsAsync(new LogsQuery(ServerId: 201));

The file holds live session material. Anyone who can read arizonarp_session can act as the logged-in account without knowing the password or the TOTP secret. Store it outside source control, in a user-private location, with restrictive file permissions — and treat losing it as a session compromise.

The same shape works for any backing store: a database row, a distributed cache, a secrets manager. Only the Load/Save bodies change.

Selecting a storage for a request

The data source is created with a default storage. When none is passed, it creates a MemoryCookieStorage of its own:

public LogsParserHttpDataSource(
    LogsParserCredentials? credentials = null,
    ICookieStorage? cookieStorage = null,
    LogsParserHttpOptions? options = null,
    HttpClient? httpClient = null,
    ILoggerFactory? loggerFactory = null)

Every request carries an optional storage of its own, and it wins for that one call:

public sealed record ParserRequest(string RelativeUri, ICookieStorage? CookieStorage = null);
Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default);

ParserRequest.CookieStorage overrides the data source's default; when it is null, the default is used. Every LogsParserClient method exposes the same override as an optional parameter and forwards it into the ParserRequest it builds:

public async Task<LogsPage> GetLogsAsync(
    LogsQuery query,
    ICookieStorage? cookieStorage = null,
    CancellationToken cancellationToken = default)

That makes one data source enough for several independent sessions — the credentials, HTTP options and rate limit counters are shared, but the cookies are not:

using LogsParser;
using LogsParser.Abstractions;
using LogsParser.Models;
using LogsParser.Net;

using var dataSource = new LogsParserHttpDataSource(
    credentials: new LogsParserCredentials("my_login", "my_password", "BASE32SECRET"),
    cookieStorage: new MemoryCookieStorage());

var client = new LogsParserClient(dataSource);

// Two separate server-side sessions, two separate cookie jars.
ICookieStorage persistent = new FileCookieStorage("state/logsparser-session.json");
ICookieStorage scratch = new MemoryCookieStorage();

var reused = await client.GetLogsAsync(new LogsQuery(ServerId: 201), persistent);
var isolated = await client.GetLogsAsync(new LogsQuery(ServerId: 201), scratch);

// The default storage passed to the constructor is used only when no override is given.
var catalog = await client.GetLogsFilterCatalogAsync();

Both calls authenticate with the one credential set the data source was built with, so both sessions belong to the same account — what differs is the session state, which never leaks from one storage into the other. A storage passed per request that starts out empty will trigger its own login on first use.

Under Microsoft.Extensions.DependencyInjection, ICookieStorage is registered as a singleton (MemoryCookieStorage by default) and can be replaced with LogsParserRegistrationOptions.CookieStorageFactory — see Dependency Injection.

Concurrency and lost updates

The library never calls SetCookies directly. Every mutation goes through internal read-modify-write helpers (applying Set-Cookie headers from a response, and storing the solved R3ACTLB token) that hold a lock keyed on the storage instance itself, kept in a ConditionalWeakTable. Read and write therefore happen inside one critical section, and two concurrent requests sharing a storage cannot overwrite each other's cookies.

That lock is internal and not reachable from caller code. So:

  • A storage's own GetCookies/SetCookies must still be individually thread-safe — the internal lock protects the library's sequences, not the implementation's fields.
  • Code that performs its own read-modify-write — GetCookies, edit the snapshot, SetCookies — is outside that critical section and can lose an update against an in-flight request. Do such edits through a lock of your own, per storage instance, and prefer to make them while no request is running.
  • Blind full replacements (SetCookies with a set built from scratch, e.g. restoring a saved session before the first request) are safe on their own, because they read nothing.

See also

Clone this wiki locally