Metadata-Version: 2.5
Name: aiohttp-session-firestore
Version: 0.1.3
Summary: Google Cloud Firestore session storage backend for aiohttp-session
Project-URL: Homepage, https://github.com/dcgudeman/aiohttp-session-firestore
Project-URL: Documentation, https://github.com/dcgudeman/aiohttp-session-firestore#readme
Project-URL: Repository, https://github.com/dcgudeman/aiohttp-session-firestore
Project-URL: Issues, https://github.com/dcgudeman/aiohttp-session-firestore/issues
Project-URL: Changelog, https://github.com/dcgudeman/aiohttp-session-firestore/blob/main/CHANGELOG.md
Author-email: David Gudeman <dcgudeman@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: aiohttp,async,firestore,gcp,google-cloud,session
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: aiohttp
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP :: Session
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: aiohttp-session>=2.12
Requires-Dist: google-cloud-firestore>=2.11; python_version < '3.14'
Requires-Dist: google-cloud-firestore>=2.28; python_version >= '3.14'
Provides-Extra: dev
Requires-Dist: mypy>=1.17; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Description-Content-Type: text/markdown

# aiohttp-session-firestore

[![CI](https://github.com/dcgudeman/aiohttp-session-firestore/actions/workflows/ci.yml/badge.svg)](https://github.com/dcgudeman/aiohttp-session-firestore/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/aiohttp-session-firestore)](https://pypi.org/project/aiohttp-session-firestore/)
[![Python](https://img.shields.io/pypi/pyversions/aiohttp-session-firestore)](https://pypi.org/project/aiohttp-session-firestore/)
[![License](https://img.shields.io/github/license/dcgudeman/aiohttp-session-firestore)](LICENSE)

**Google Cloud Firestore** session storage backend for
[aiohttp-session](https://github.com/aio-libs/aiohttp-session).

Drop-in, async, server-side sessions — the cookie holds only an opaque key
while all session data lives in Firestore.

---

## Installation

```bash
pip install aiohttp-session-firestore
```

## Quick start

```python
from aiohttp import web
from aiohttp_session import setup, get_session
from google.cloud.firestore_v1 import AsyncClient

from aiohttp_session_firestore import FirestoreStorage


async def handler(request: web.Request) -> web.Response:
    session = await get_session(request)
    session["visits"] = session.get("visits", 0) + 1
    return web.Response(text=f"Visits: {session['visits']}")


def create_app() -> web.Application:
    app = web.Application()
    firestore_client = AsyncClient()
    storage = FirestoreStorage(firestore_client, max_age=86400)
    setup(app, storage)
    app.router.add_get("/", handler)
    return app


if __name__ == "__main__":
    web.run_app(create_app())
```

## Configuration

`FirestoreStorage` accepts every parameter that
`aiohttp_session.AbstractStorage` does, plus a few Firestore-specific ones:

| Parameter | Type | Default | Description |
|---|---|---|---|
| `client` | `AsyncClient` | *(required)* | Firestore async client instance |
| `collection_name` | `str` | `"aiohttp_sessions"` | Firestore collection for session documents |
| `key_factory` | `(() -> str) \| None` | `None` | Callable that produces new session keys. `None` uses `secrets.token_urlsafe(32)`. |
| `cookie_name` | `str` | `"__session"` | Name of the HTTP cookie (compatible with Firebase Hosting) |
| `max_age` | `int \| None` | `None` | Session lifetime in seconds (`None` = browser session) |
| `secure` | `bool \| None` | `None` | `Secure` cookie flag — **set to `True` in production** |
| `httponly` | `bool` | `True` | `HttpOnly` cookie flag |
| `samesite` | `str \| None` | `None` | `SameSite` cookie attribute (`"Lax"`, `"Strict"`, or `"None"`) |
| `domain` | `str \| None` | `None` | Cookie domain |
| `path` | `str` | `"/"` | Cookie path |
| `encoder` | `(object) -> str` | Firestore-aware `json.dumps` | Session data encoder (handles `DatetimeWithNanoseconds`) |
| `decoder` | `(str) -> Any` | `json.loads` | Session data decoder |

Custom key factories must return cryptographically unpredictable keys that are
valid single Firestore document IDs. Invalid generated keys raise `ValueError`;
invalid session cookies are treated as missing sessions.

### Production recommendations

```python
storage = FirestoreStorage(
    client,
    max_age=86400,  # 24-hour sessions
    secure=True,  # HTTPS only
    httponly=True,  # default — prevents JS access
    samesite="Lax",  # CSRF protection
)
```

### Cookie name & Firebase Hosting

The default cookie name is `__session`. This is intentional:
[Firebase Hosting strips all cookies](https://firebase.google.com/docs/hosting/manage-cache#using_cookies)
**except** one named `__session` before forwarding requests to Cloud Run,
Cloud Functions, or any other backend. If you change `cookie_name` to
anything else while deployed behind Firebase Hosting, the cookie will be
silently dropped on every request and sessions will never persist.

If you are **not** behind Firebase Hosting (e.g. running directly on
Cloud Run with a custom domain, GKE, or locally), you can safely use any
cookie name:

```python
storage = FirestoreStorage(client, cookie_name="MY_SESSION", ...)
```

## Session expiration & Firestore TTL

When `max_age` is set, saving a changed session writes an `expire` field containing
a UTC `datetime`, calculated from the save time and that session's lifetime.
The library checks this deadline on every read and treats expired documents as
missing immediately. It deletes an expired document only if it has not changed
since the read, so cleanup cannot remove a concurrently refreshed session.

Per-session overrides are persisted and restored on later requests. If you only
change the lifetime, call `session.changed()` so aiohttp-session saves it:

```python
session.max_age = 3600
session.changed()
```

Setting `session.max_age = None` creates a browser-session cookie and removes the
server expiration; those documents have no automatic TTL cleanup. Documents
written by earlier versions without a stored `max_age` use the storage default
when loaded, while an existing `expire` timestamp remains the current deadline.

For **automatic cleanup** of expired documents, configure a
[Firestore TTL policy](https://cloud.google.com/firestore/docs/ttl) on the
`expire` field:

```bash
gcloud firestore fields ttls update expire \
    --collection-group=aiohttp_sessions \
    --enable-ttl
```

> **Note:** Firestore's TTL deletion is asynchronous. The server-side expiration
> check in `load_session` ensures
> correctness regardless of TTL policy timing.

## Document structure

Each session is stored as a Firestore document:

```
aiohttp_sessions/{session-key}
├── data: '{"created": 1700000000, "session": {"user": "alice"}}'
├── max_age: 3600                (null for a browser session)
└── expire: 2024-11-15T12:00:00Z   (only when max_age is set)
```

The `data` field contains a JSON-encoded string (controlled by the
`encoder`/`decoder` parameters). The `expire` field is a native Firestore
`Timestamp`, compatible with TTL policies.

## Firestore costs

Each request that touches the session incurs:

| Operation | Firestore cost |
|---|---|
| Load (no cookie) | 0 reads (short-circuits before Firestore call) |
| Load (cookie, doc exists) | 1 document read |
| Load (cookie, doc missing) | 1 document read |
| Save (non-empty session) | 1 document write |
| Save (empty new session) | 0 writes (skipped) |
| Save (empty existing session) | 1 document delete |

The library avoids unnecessary writes — new sessions that remain empty are
never persisted.

## Default encoder

The default encoder is a Firestore-aware `json.dumps` that automatically
converts `datetime` objects (including Firestore's `DatetimeWithNanoseconds`)
to millisecond Unix timestamps. This prevents serialization errors when session
data contains values that originated from Firestore queries. To override, pass
a custom `encoder`.

## Limitations

- **Concurrent requests:** Concurrent changes to an existing session use
  last-writer-wins behavior. A stale request cannot recreate a session deleted by
  logout; its response clears the session cookie instead.
- **Session data must be JSON-serializable** (or serializable by your custom
  encoder). Avoid storing large blobs; Firestore documents are limited to
  1 MiB.
- **Database mode:** This library requires Firestore in Native mode. Datastore
  mode does not support the Firestore API used by this library. Document reads
  are strongly consistent.
- **No built-in encryption:** Firestore encrypts data at rest (Google-managed
  keys). If you need app-level encryption, provide a custom `encoder`/`decoder`
  pair that encrypts before writing and decrypts after reading.
- **Sensitive data:** Avoid storing secrets (passwords, tokens) in sessions.
  Sessions are intended for user state, not credential storage.

## Development

```bash
# Clone and install dev dependencies
git clone https://github.com/dcgudeman/aiohttp-session-firestore.git
cd aiohttp-session-firestore
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

# Run checks
ruff check .
ruff format --check .
mypy aiohttp_session_firestore
pytest --cov
```

To run the integration tests against an already-running local Firestore emulator:

```bash
FIRESTORE_EMULATOR_HOST=127.0.0.1:8789 pytest tests/test_firestore_integration.py
```

## License

[Apache 2.0](LICENSE)
