Metadata-Version: 2.4
Name: coffeehouse-common
Version: 0.21.0
Summary: Shared CSRF, security headers, logging, observability, flash messages, role-cleanup, and Auth client for Coffee House FastAPI apps
Author-email: Ruben Sukiasyan <rubsksn@gmail.com>
License: MIT License
        
        Copyright (c) 2026 Ruben Sukiasyan
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/coffeehouse-tools/coffeehouse-common
Project-URL: Repository, https://github.com/coffeehouse-tools/coffeehouse-common
Project-URL: Issues, https://github.com/coffeehouse-tools/coffeehouse-common/issues
Project-URL: Changelog, https://github.com/coffeehouse-tools/coffeehouse-common/blob/master/CHANGELOG.md
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Classifier: Framework :: FastAPI
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: itsdangerous==2.2.0
Requires-Dist: starlette>=0.38.0
Requires-Dist: starlette-csrf==3.0.0
Requires-Dist: anyio<5,>=4.0.0
Requires-Dist: SQLAlchemy>=2.0
Requires-Dist: requests>=2.31
Requires-Dist: sentry-sdk<3.0,>=2.20
Provides-Extra: testing
Requires-Dist: pytest>=8.0; extra == "testing"
Requires-Dist: PyJWT>=2.8; extra == "testing"
Requires-Dist: httpx>=0.27; extra == "testing"
Requires-Dist: python-multipart>=0.0.9; extra == "testing"
Dynamic: license-file

# coffeehouse-common

Shared Python package for Coffee House ecosystem FastAPI apps:

- `coffeehouse_common.csrf` — `CSRFMiddleware` subclass (form body + header + safe body replay; the cookie has a lifetime, every refusal is logged with its reason)
- `coffeehouse_common.security_middleware` — `SecurityHeadersMiddleware` (raw ASGI; the HTTPS-only header is switched on per app)
- `coffeehouse_common.session_renewal` — `SessionRenewalMiddleware` (raw ASGI): renews an expired session inside the request that needs it
- `coffeehouse_common.auth_gate` — `auth_required_handler`: the one answer every app gives when a session cannot be renewed
- `coffeehouse_common.logging_config` — `RequestIdMiddleware` (raw ASGI), `RequestIdFilter`, `configure_logging`, `get_request_id`
- `coffeehouse_common.observability` — `init_sentry(app_slug)` Sentry SDK bootstrap; reads `SENTRY_DSN` from env (no-op when unset), tags events with `app_slug`, drops 4xx HTTP/CSRF noise, keeps 5xx; stack-frame locals named `pin`, `pin_hash` or `temporary_pin` are scrubbed on top of the SDK's own list

**v0.3.0+** implements security headers and request ID middleware as **raw ASGI** (not `BaseHTTPMiddleware`) so streaming responses are not fully buffered. **v0.3.1** sets `X-Frame-Options: SAMEORIGIN` (same-origin iframes allowed; third-party framing blocked). Logs include a per-request correlation id when `configure_logging()` + `RequestIdMiddleware` are used.

## Install

From GitHub (pinned tag):

```bash
pip install "coffeehouse-common @ git+https://github.com/rubencfh/coffeehouse-common.git@v0.9.0"
```

Local editable install:

```bash
pip install -e ../coffeehouse-common
```

## Development

Set up the repo for local development + testing:

```bash
git clone https://github.com/rubencfh/coffeehouse-common.git
cd coffeehouse-common
pip install -e ".[testing]"
pip install -r requirements-dev.txt
pre-commit install
```

Run the full test suite (with coverage, fails under 70%):

```bash
pytest
```

Run the pre-commit hooks against all files (format, lint, secret scan, syntax check):

```bash
pre-commit run --all-files
```

Individual checks:

```bash
ruff format --check .          # formatting
ruff check .                   # linting
mypy coffeehouse_common        # type-checking (lenient baseline)
bandit -c pyproject.toml -r coffeehouse_common coffeehouse_testing  # SAST
pip-audit -r requirements-dev.txt                                    # SCA
```

CI runs the same checks on every push / PR via `.github/workflows/ci.yml`.
See `coffeehouse-ecosystem/docs/pipeline.md` for the broader pipeline.

### Shared test fixtures

The `coffeehouse_testing` subpackage ships with this repo and provides shared
pytest fixtures that consumer apps import in their own `tests/conftest.py`:

```python
from coffeehouse_testing.jwt_factory import jwt_factory
from coffeehouse_testing.client import authed_client
from coffeehouse_testing.db import postgres_container, clean_db  # noqa: F401
from coffeehouse_testing.auth_stub import make_auth_stub, build_roles_manifest
```

All heavy dependencies (PyJWT, testcontainers, starlette) are loaded lazily —
import the modules you need in tests and the import errors only fire if the
underlying package is not installed.

## Usage

```python
from coffeehouse_common.csrf import CSRFMiddleware
from coffeehouse_common.logging_config import RequestIdMiddleware, configure_logging
from coffeehouse_common.observability import init_sentry
from coffeehouse_common.security_middleware import SecurityHeadersMiddleware

configure_logging()  # once at startup; attaches RequestIdFilter for %(request_id)s
init_sentry(app_slug="auth")  # no-op if SENTRY_DSN is unset
```

`init_sentry` reads `SENTRY_DSN`, `RAILWAY_ENVIRONMENT_NAME` (or `ENVIRONMENT`),
and `RAILWAY_GIT_COMMIT_SHA` from the environment. Errors-only sample rate by
default (`traces_sample_rate=0.0`). 401/403/404/405 responses, 4xx
`HTTPException`s, and `CSRFError`s are dropped client-side so they don't burn
the Sentry quota. 5xx HTTPExceptions and any other exception class are kept.
Pass `extra_ignored_status_codes=` / `extra_ignored_exceptions=` to extend the
filter per app.

Explicit dependency: **`itsdangerous`** (CSRF token signing).

### Report-table sorting

`coffeehouse_common.report_sort` (promoted from Grind after Stoply became the
second adopter) pairs with `coffeehouse-ui`'s `sortable_th` macro:
`parse_sort(request, allowed=..., default_sort=...)` validates
`?sort=`/`?dir=` against a per-report allowlist; `sort_data(rows, sort, dir)`
sorts a materialized `list[dict]` in place (Nones last in both directions,
case-insensitive strings). Both are re-exported from the package root.

### Redirects by route name

`coffeehouse_common.redirects` (promoted from Cupping) builds every
Post/Redirect/Get target from the app's own route table instead of a string:

```python
# app/redirects.py -- one Redirector per app
from typing import Any

from coffeehouse_common.redirects import Redirector


def _app() -> Any:
    from app.server import app  # lazy: server imports the route modules

    return app


redirects = Redirector(_app)

# in a route
return redirects.to("view_thing", fallback="list_things", thing_id=7, success="saved")
```

`to()` answers 303 with flash **codes** in the query string (`error=`,
`success=`, `warning=`; a value that is not a code is dropped). The `Location`
comes from Starlette's `url_path_for`, so it can only be one of the app's own
paths. A route name the table does not serve, or a path parameter that is not an
`int` or a string of URL-unreserved characters, falls back to the named
`fallback` route, and to `root` (`/`) if that cannot be built either -- with a
warning that names parameter keys and types, never values. `url_for()` is the
`href`-building half and raises instead of falling back.
`Redirector(..., int_param_suffix="_id")` additionally requires every `*_id`
parameter to be an `int`. Unlike `coffeehouse_common.flash` (a sentence in a
session cookie), this needs no middleware.

### Sessions: renewal, and the answer when renewal is not possible

Auth's `ch_session` cookie lasts 15 minutes and activity does not extend it.
Two modules (0.21.0) make that invisible to a person at work and uniform when
it cannot be:

```python
from coffeehouse_common.auth_gate import auth_required_handler
from coffeehouse_common.session_renewal import SessionRenewalMiddleware

app.add_exception_handler(AuthRequired, auth_required_handler(build_login_url))

app.add_middleware(RequestIdMiddleware)
app.add_middleware(SecurityHeadersMiddleware, hsts_max_age=31_536_000)
app.add_middleware(CSRFMiddleware, secret=APP_SECRET_KEY, exempt_urls=[...])
# Immediately before CORS, which stays the last registration (= outermost).
app.add_middleware(SessionRenewalMiddleware, auth_url=AUTH_SERVICE_URL)
app.add_middleware(CORSMiddleware, ...)
```

**`SessionRenewalMiddleware`** acts on a request that carries the week-long
`ch_refresh` cookie and whose `ch_session` is absent, unreadable or within 60
seconds of expiry. It asks Auth (`POST {auth_url}/api/session/renew`, in a
worker thread, at most four at a time, 2 s to connect and 3 s to read), hands
the app the renewed session and passes Auth's `Set-Cookie` through on the same
response. So a form saved after 15 minutes is saved.

- It is registered **outside CSRF and inside CORS**: the `add_middleware` call
  immediately before the CORS one.
- It never raises and never reads the request body. A refusal (401), an
  unreachable Auth or a malformed answer leaves the request as it arrived, and
  the app's own guard answers it. After a network failure nobody asks again for
  30 seconds; a refusal starts no such pause.
- It renews only on a well-formed 200: a `token` of three base64url segments
  and a `set_cookie` whose `ch_session` value is exactly that token, with no
  control character. The app runs this request as `token` and the browser sends
  the cookie's value from then on, so the two must be one session. Any other
  200 counts as Auth being unreachable.
- It never renews a background poll (`X-Background-Request`, set by
  coffeehouse-ui's `session.js` on timer-fired HTMX requests) or a request with
  `Authorization: Bearer`. Only a person's action renews.
- `exp` is read without checking the signature, only to decide whether to ask;
  the app's guard still verifies the token.
- No cache and no skip list. `auth_url` empty or `None` switches it off.
- A route that raises is answered by Starlette outside every middleware, so a
  500 does not carry the renewed cookie; the next request renews again.

**`auth_required_handler(build_login_url, *, api_response=None)`** is the
exception handler for the app's own `AuthRequired`. It answers by kind of
request and returns the person to the page they were on:

| Request | Answer | Comes back to |
|---|---|---|
| page load (GET/HEAD) | 302 to login | the request URL |
| HTMX | 401 + `HX-Redirect` | `HX-Current-URL` (same host only) |
| HTMX background poll | 204, empty | nothing navigates |
| any other method | 303 to login | `Referer` (same host only), else the app root |

`api_response(request)` runs first, for an app that answers its JSON API in its
own shape; return `None` to fall through. `session_expired_response(request,
login_url)` is the same answer without the exception plumbing.

**Log lines.** Each names the first path segment (`area=/questions`), never the
path, and never a token:

```
session renewal: outcome=renewed|refused|unreachable reason=<...> method=<...> area=<...>
session expired: kind=write|htmx method=<...> area=<...>
csrf rejected: reason=cookie_missing|token_missing|mismatch method=<...> area=<...> session=yes|no
```

`session expired` is how a lost save is counted: after adoption it is a 303,
the same status as a saved one. `session=yes` means the request carried
`ch_session` or `ch_refresh`.

**The security cookie** (`csrftoken`) is issued with `Max-Age` of one year;
`CSRFMiddleware(..., cookie_max_age=None)` restores a session cookie; zero or a
negative number is refused at start-up, because the browser would discard the
cookie at once. A browser
that already holds a valid cookie is not sent a new one.

**The HTTPS-only header** is off by default.
`SecurityHeadersMiddleware(hsts_max_age=31_536_000)` sends
`Strict-Transport-Security: max-age=31536000` on every response, never with
`includeSubDomains` or `preload`. A browser keeps the rule for a year: to take
it back pass `hsts_max_age=0`; `None` only stops sending it.

#### In an app's tests

```python
from coffeehouse_testing.auth_gate import (
    assert_every_route_requires_session,
    assert_public_paths_are_served,
)
from coffeehouse_testing.session_renewal import assert_renewal_wraps_csrf, fake_renewal

PUBLIC = {"/health", "/csrf-smoke", "/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}


def test_every_route_requires_a_session():
    client = TestClient(app, follow_redirects=False)
    checked = assert_every_route_requires_session(
        client, public_paths=PUBLIC, login_url=f"{AUTH_SERVICE_URL}/login"
    )
    assert len(checked) > 5  # an empty walk would pass everything


def test_public_paths_are_real_routes():
    assert_public_paths_are_served(app, PUBLIC)


def test_renewal_is_registered_where_it_must_be():
    assert_renewal_wraps_csrf(app)


def test_an_expired_session_is_renewed_inside_the_request():
    token = jwt_factory(sub="E-1", apps={"myapp": {"role": "admin"}}, secret=JWT_SECRET)
    client = TestClient(app, follow_redirects=False)
    client.cookies.set("ch_refresh", "any-value")  # no ch_session: it expired
    with fake_renewal("renewed", token=token) as calls:
        assert client.get("/a-guarded-page").status_code == 200
    assert calls == ["any-value"]
```

The walk sends an anonymous GET to every route the app serves and fails on any
that renders. It counts a 404, 405 or 410 as denied (catch-all routes need
that), so a route that looks an id up *before* checking the session passes on
its 404: for an app that checks the session inside the handler, the check must
come before any lookup. `fake_renewal("refused")` and
`fake_renewal("unreachable")` produce the other two outcomes; only the HTTP call
is replaced, so the test fails when the middleware line is removed.

See `CONVENTIONS.md` in the `coffeehouse-ecosystem` repo for middleware order and CSRF patterns.

## First-time publish (maintainers)

1. Create an empty repo on GitHub: `rubencfh/coffeehouse-common`
2. From this directory:

```bash
git remote add origin https://github.com/rubencfh/coffeehouse-common.git
git push -u origin master
git push origin v0.9.0
```

Until the repo and tag exist, app Docker builds that `pip install` from GitHub will fail. For local dev, use editable install (above).
