Metadata-Version: 2.5
Name: flask-session.manager.sk
Version: 1.3.2
Summary: Flask companion package for react-session.manager.sk
Project-URL: Homepage, https://github.com/Skulldorom/flask-session.manager.sk
Project-URL: Repository, https://github.com/Skulldorom/flask-session.manager.sk
Project-URL: Issues, https://github.com/Skulldorom/flask-session.manager.sk/issues
Author: Skulldorom
License: MIT
License-File: LICENSE
Classifier: Framework :: Flask
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: flask-jwt-extended>=4.7
Requires-Dist: flask>=3.1
Requires-Dist: ua-parser>=1.0
Description-Content-Type: text/markdown

# flask-session.manager.sk

[![Version](https://img.shields.io/github/v/tag/Skulldorom/flask-session.manager.sk)](https://github.com/Skulldorom/flask-session.manager.sk/tags)
[![Python](https://img.shields.io/pypi/pyversions/flask-session.manager.sk)](https://pypi.org/project/flask-session.manager.sk/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)

<p align="center">
  <a href="https://ko-fi.com/skulldorom"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support me on Ko-fi" /></a>
</p>

Flask companion package for
[react-session.manager.sk](https://github.com/Skulldorom/react-session.manager.sk).

Cookie-driven JWT session management for Flask backends, designed to pair with the
React session manager's HttpOnly-cookie transport.

## Install

```bash
# uv (recommended)
uv add flask-session.manager.sk

# pip
pip install flask-session.manager.sk
```

## Quick Start

```python
from flask import Flask, jsonify
from flask_jwt_extended import create_access_token
from flask_session_manager_sk import (
    SessionManager,
    SessionManagerCallbacks,
    verify_session_token_record,
)

app = Flask(__name__)
app.config.update(
    {
        "SECRET_KEY": "your-secret-key",
        "JWT_TOKEN_LOCATION": ["cookies"],
        "JWT_COOKIE_SECURE": True,  # require HTTPS in production
        "JWT_COOKIE_CSRF_PROTECT": True,
        "JWT_ACCESS_COOKIE_NAME": "access_token_cookie",
        "FRONTEND_URL": "https://myapp.example.com",
        "CORS_ORIGINS": ["https://myapp.example.com"],
    }
)

manager = SessionManager()


def verify_user_token(user, agent, device_uid, token):
    record = user.find_session_record(agent=agent, device_uid=device_uid)
    if verify_session_token_record(token, record):
        return record
    return None


callbacks = SessionManagerCallbacks(
    user_lookup=lambda identity: get_user_by_id(identity),
    refresh_user_token=lambda user, agent, device_uid: create_and_store_token(
        user, agent, device_uid
    ),
    verify_user_token=verify_user_token,
    is_user_active=lambda user: user.is_active,
    is_session_persistent=lambda user, agent, device_uid, record=None: bool(
        record and record.persistent
    ),
)

manager.init_app(app, callbacks=callbacks)


@app.route("/auth/who")
@jwt_required(optional=True)
def whoami():
    from flask_jwt_extended import current_user

    if current_user:
        return jsonify(logged_in=True, user_id=current_user.id)
    return jsonify(logged_in=False)
```

`SessionManager.init_app()` automatically registers the package CSRF/origin guard
for cookie authentication. Browser unsafe requests using cookies must come from a
configured `FRONTEND_URL` or `CORS_ORIGINS` origin unless you deliberately opt out
with `FSM_CSRF_ORIGIN_CHECK=False`.

## Public API

Only these names are part of the stable surface:

| Name | Description |
|------|-------------|
| `SessionManager` | Flask extension that wires JWT callbacks |
| `SessionManagerCallbacks` | Frozen dataclass of application hooks |
| `token_response` | Create a JSON response with an HttpOnly JWT cookie |
| `session_response` | Create an authenticated bootstrap response that exposes only the current CSRF claim |
| `clear_token_response` | Create a response that clears JWT cookies |
| `verify_session_token_record` | Verify a presented JWT against a server-side token record |

Everything else in the package is internal and may change without notice.

```python
import flask_session_manager_sk

print(flask_session_manager_sk.__version__)  # e.g. "1.0.0"
```

### SessionManagerCallbacks

```python
@dataclass(frozen=True)
class SessionManagerCallbacks:
    # Required
    user_lookup: Callable[[str], Any | None]
    refresh_user_token: Callable[[Any, str, str | None], str | None]

    # Optional
    verify_user_token: (
        Callable[[Any, str | None, str | None, str | None], Any | None] | None
    ) = None
    is_user_active: Callable[[Any], bool] | None = None
    is_session_persistent: (
        Callable[[Any, str | None, str | None, Any | None], bool] | None
    ) = None
```

`verify_user_token` receives `(user, agent, device_uid, token)`. Agent and device
UID are identifiers/signals only. They are not secrets and must never be enough
to authenticate a session by themselves. Resolve the relevant server-side session
record with metadata, then compare the presented JWT against the record's stored
hash with `verify_session_token_record()` or equivalent constant-time logic.

`refresh_user_token` may persist the replacement JWT. Before invoking it, the
extension validates all package-controlled persistent and Partitioned cookie
configuration so a known configuration failure cannot leave server and browser
token state out of sync.

`is_session_persistent` receives `(user, agent, device_uid, token_record)` during
expired-token refresh. Return `True` only when the remembered-session policy is
still valid for that server-side record. This lets the package reissue persistent
cookies without owning your database schema or lifetime policy.

## Flask Configuration Reference

These values are read at runtime via `current_app.config`. No configuration is
stored in the package.

| Key | Required | Default | Description |
|-----|----------|---------|-------------|
| `SECRET_KEY` | Yes | - | Flask secret, must be >=32 bytes for HMAC-SHA256 |
| `JWT_TOKEN_LOCATION` | Yes | - | Include `"cookies"` for browser cookie auth; bearer-only clients can use `"headers"` |
| `JWT_ACCESS_COOKIE_NAME` | Yes | - | Cookie name; `react-session.manager.sk` expects `"access_token_cookie"` |
| `JWT_COOKIE_CSRF_PROTECT` | Yes | - | Must be `True` for recommended browser cookie auth. May be set to `False` when `FSM_CSRF_ORIGIN_CHECK=True` as an explicit reduced-defense fallback. |
| `JWT_COOKIE_SECURE` | Yes | - | `True` in production and always required when `JWT_COOKIE_SAMESITE="None"` |
| `JWT_COOKIE_SAMESITE` | No | - | `"Lax"` or `"Strict"`; use `"None"` only with `Secure=True` for cross-site cookies |
| `FSM_CSRF_ORIGIN_CHECK` | No | `True` | Automatically reject unsafe cookie-auth requests from unconfigured or missing origins. Set `False` only for deliberate, security-reviewed opt-out. |
| `FSM_PERSISTENT_MAX_AGE` | For `persistent=True` | - | `timedelta` or int seconds for remembered-session cookie `Max-Age` |
| `FSM_COOKIE_PARTITIONED` | No | `False` | Append `; Partitioned` (CHIPS) to all auth cookies. Requires `JWT_COOKIE_SAMESITE="None"` and `JWT_COOKIE_SECURE=True`. Helps cross-site cookie auth on supported iOS WebKit/Safari ITP and other third-party-cookie blockers. |
| `FRONTEND_URL` | For cookie origin checks | - | Canonical URL of the SPA |
| `CORS_ORIGINS` | For cookie origin checks | - | Allowed browser origins as a string or list |

When cookie auth is enabled, initialization validates the security-sensitive
combinations above and raises `RuntimeError` with an actionable message if the
configuration is unsafe or missing required browser origins. Cookie auth
requires at least one of `JWT_COOKIE_CSRF_PROTECT=True` or
`FSM_CSRF_ORIGIN_CHECK=True`; disabling both is rejected. Bearer-only
configurations are not blocked by these cookie checks.

## CSRF and Origin Policy

Flask-JWT-Extended's double-submit CSRF token and this package's origin check are
complementary defenses:

- safe methods (`GET`, `HEAD`, `OPTIONS`) are not blocked by the package origin guard
- unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) using cookie auth require an
  allowed `Origin` or `Referer`
- if both `Origin` and `Referer` are missing on an unsafe cookie-auth request,
  the guard fails closed with `403`
- bearer-authenticated requests skip the browser cookie origin guard
- if both a cookie and a bearer authorization header are present, bearer mode
  wins for the package origin guard

### Same-site readable-cookie CSRF

When the SPA and API share a registrable domain (e.g. `app.example.com` and
`api.example.com`), the browser can read the `csrf_access_token` cookie that
`token_response()` sets. Configure the frontend Axios instance with:

```js
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
axios.defaults.xsrfCookieName = "csrf_access_token";
axios.defaults.xsrfHeaderName = "X-CSRF-TOKEN";
```

Axios then reads the cookie and sends it as `X-CSRF-TOKEN` automatically.

### Genuinely cross-site SPA/API CSRF

When the SPA and API live on different registrable domains (e.g.
`https://portal.example.com` and `https://api.herokuapp.com`), the browser cannot
read the API-scoped `csrf_access_token` cookie from the SPA's origin. In this
case `token_response()`, `session_response()`, and the expired-token refresh
path return the current CSRF value in the response header:

```http
X-CSRF-TOKEN: <current csrf claim>
Access-Control-Expose-Headers: X-CSRF-TOKEN
```

The access JWT itself remains HttpOnly and is never exposed to JavaScript.

Use `session_response()` from the authenticated route supplied as the React
companion's `userLoader`. This restores in-memory CSRF state after a hard reload
without rotating or exposing the access JWT:

```python
from flask_jwt_extended import current_user, jwt_required
from flask_session_manager_sk import session_response


@app.get("/auth/who")
@jwt_required(optional=True)
def who_am_i():
    if current_user:
        return session_response({"logged_in": True, "Info": current_user.to_dict()})
    return session_response({"logged_in": False})
```

Required CORS configuration for cross-site deployments:

```python
CORS(
    app,
    supports_credentials=True,
    origins=app.config["CORS_ORIGINS"],  # must not be "*" with credentials
    allow_headers=["Content-Type", "appVersion", "deviceUID", "X-CSRF-TOKEN"],
    expose_headers=["X-CSRF-TOKEN"],
)
```

The frontend companion `react-session.manager.sk` (v4.2+) captures this header
in memory and attaches it to unsafe requests automatically. Do not store the
CSRF value in `localStorage` or `sessionStorage`.

### Required request header

Unsafe cookie-auth requests must include:

```http
X-CSRF-TOKEN: <current csrf claim>
```

`token_response()` emits both the readable `csrf_access_token` cookie and the
`X-CSRF-TOKEN` response header. `session_response()` re-emits the authenticated
JWT's existing CSRF claim during session bootstrap, so both topologies continue
working after a hard reload.

### Origin-only fallback (reduced defense)

The recommended configuration enables both protections:

```python
JWT_COOKIE_CSRF_PROTECT = True
FSM_CSRF_ORIGIN_CHECK = True
```

The header transport above is the preferred cross-site solution and is now
implemented by this package (v1.3+) paired with
`react-session.manager.sk` v4.2+. For consumers that cannot yet adopt it, an
explicit origin-only fallback remains supported:

```python
JWT_COOKIE_CSRF_PROTECT = False
FSM_CSRF_ORIGIN_CHECK = True
```

This disables Flask-JWT-Extended's double-submit CSRF check while keeping the
strict `Origin`/`Referer` guard active for unsafe cookie-authenticated requests.
It is a reduced-defense fallback, not a permanent configuration.

The package fails closed when both protections are disabled together
(`JWT_COOKIE_CSRF_PROTECT=False` + `FSM_CSRF_ORIGIN_CHECK=False`) for cookie
authentication.

## Session Lifetime and Remember Me

JWT expiry, browser cookie lifetime, and remembered-session lifetime are separate
concepts:

- `JWT_ACCESS_TOKEN_EXPIRES` controls how long an individual JWT is valid.
- Session cookies (`persistent=False`) are browser-session cookies and do not
  receive `Max-Age`.
- Remembered cookies (`persistent=True`) receive `Max-Age` from
  `FSM_PERSISTENT_MAX_AGE` and can survive browser restarts.
- Expired cookie JWTs are refreshed through replacement HttpOnly cookies. The
  refresh response is signal-only (`{"refreshed": true}`) and does not expose a
  new raw access token to JavaScript.
- The refresh response also emits a new `X-CSRF-TOKEN` header and exposes it,
  so cross-site SPAs can update their in-memory CSRF value.
- `is_session_persistent(...)` decides whether refresh should preserve persistent
  cookie attributes for a remembered session.
- Absolute versus sliding remembered-session lifetime is application policy. Store
  fields such as `created_at`, `expires_at`, `last_seen`, or `revoked_at` on your
  own session record and return `False` from callbacks when the remembered
  session is no longer valid.
- Logout, revocation, inactive users, and failed token-hash verification override
  persistence and must reject refresh.

## Token Verification and Revocation

Use token hashes as the proof of possession. Device UID, User-Agent, IP address,
and similar metadata are useful for finding a candidate session record, but they
are not authentication secrets.

Recommended consumer pattern:

```python
def verify_user_token(user, agent, device_uid, token):
    record = user.find_session_record(agent=agent, device_uid=device_uid)
    if verify_session_token_record(token, record):
        return record
    return None
```

`verify_token_hash()` uses a constant-time digest comparison and fails closed for
missing values. `clear_session_token_value()` preserves the registered-device row
while clearing active token state (`token_hash = None`, empty hint, no plaintext
token). Legacy rows containing the old SHA-256 hash of an empty string are treated
as revoked and never verify.

## Cross-Site Cookies and iOS WebKit (CHIPS)

Browser auth uses an HttpOnly cookie. When the SPA and the API live on different
registrable domains (e.g. `app.example.com` to `api.herokuapp.com`), the cookie
is a third-party cookie. Desktop Chromium accepts `SameSite=None; Secure`
third-party cookies, but iOS WebKit (Safari and every iOS browser, including
Brave, which is WebKit under the hood) blocks them by default via Intelligent
Tracking Prevention. Result: login succeeds, the browser never stores the cookie,
and the next request 401s.

Set `FSM_COOKIE_PARTITIONED=True` to emit `; Partitioned` (CHIPS) on all auth
cookies. Partitioned cookies are stored in a per-top-level-site partition, so
WebKit keeps them even with third-party cookies blocked. Requirements:

- `JWT_COOKIE_SAMESITE="None"` and `JWT_COOKIE_SECURE=True` (enforced; a
  misconfigured combination raises `RuntimeError` at cookie-set time).
- Browser support: iOS 16.4+ / Safari 16.4+, Chrome 114+. Older iOS remains
  affected; the durable fix is serving the API from the same site as the SPA.

## React Companion Contract

The frontend companion
[react-session.manager.sk](https://github.com/Skulldorom/react-session.manager.sk)
(v4.2+) uses this transport contract:

- **Axios** configured with `withCredentials: true`, `withXSRFToken: true`
- Cookies expected at default Flask-JWT-Extended names:
  - `access_token_cookie` (JWT)
  - `csrf_access_token` (CSRF double-submit)
- CSRF header sent as `X-CSRF-TOKEN`
- `deviceUID` header sent on every request
- `appVersion` header sent on every request
- No `Authorization` bearer header for browser requests
- For cross-site deployments, the CSRF value is captured from the
  `X-CSRF-TOKEN` response header (in memory only) and attached to unsafe
  requests automatically
- Legacy `localStorage`/`sessionStorage` bearer tokens are automatically cleared

## Internal Modules

Not part of the stable API. Import at your own risk.

| Module | Content |
|--------|---------|
| `flask_session_manager_sk.cookies` | CSRF origin checks, cookie auth detection, `clear_session_token_value` |
| `flask_session_manager_sk.request` | `get_agent`, `get_ip`, `get_token`, `get_dets_from_request` |
| `flask_session_manager_sk.tokens` | `create_token_hash`, `token_hint`, `verify_token_hash`, `verify_session_token_record`, `update_token_record` |
| `flask_session_manager_sk.extension` | `SessionManager` implementation details |

## Migrating from Bearer Tokens

If your backend currently returns access tokens as JSON payloads (e.g.
`{"access_token": "..."}`) and stores them in `localStorage`:

1. **Package adoption**: Wire `SessionManager` with your user-lookup,
   token-refresh, token-verification, and optional persistence callbacks.
2. **Login endpoint**: Call `token_response(payload, status, access_token)`
   instead of `jsonify(payload)`. This sets the HttpOnly cookie automatically
   and also emits the `X-CSRF-TOKEN` header for cross-site SPAs. The payload
   must not contain an `access_token` field or the JWT value; attempts fail
   loudly instead of exposing the credential to JavaScript.
3. **Session endpoint**: Return authenticated `userLoader`/`whoami` payloads
   through `session_response()` so a hard reload restores cross-site CSRF state.
4. **CSRF/origin checks**: Remove custom boilerplate that manually registers
   `reject_cookie_csrf()` as a `before_request` hook. `SessionManager.init_app()`
   registers it automatically for cookie auth by default.
5. **Token verification**: Make sure your `verify_user_token` callback compares
   the presented JWT against the stored token hash. Matching device metadata
   alone is insecure.
6. **Frontend**: Upgrade `react-session.manager.sk` to the companion version
   documented in its release notes. It removes
   bearer-token browser storage, keeps same-site XSRF support, and adds
   cross-site CSRF header capture for SPAs on a different registrable domain.
7. **Backwards compatibility**: Non-browser clients (API scripts, scheduled
   tasks) can still send valid bearer authorization when `JWT_TOKEN_LOCATION`
   includes `"headers"`. The package origin guard skips bearer-authenticated
   requests. Expired bearer tokens are not auto-refreshed by this package because
   refreshed JWTs are only issued through HttpOnly cookies; bearer clients should
   use the application's explicit token-renewal flow.

## Development

```bash
git clone https://github.com/Skulldorom/flask-session.manager.sk.git
cd flask-session.manager.sk

# Install deps + editable package
uv sync --dev

# Run checks
uv run ruff check .
uv run ruff format --check .
uv run pytest -v

# Build
uv build
```

## License

MIT - see [LICENSE](./LICENSE).

<!-- test auto-versioning pipeline -->
