coffeehouse-common
Shared Python package for Coffee House ecosystem FastAPI apps:
coffeehouse_common.csrf—CSRFMiddlewaresubclass (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 itcoffeehouse_common.auth_gate—auth_required_handler: the one answer every app gives when a session cannot be renewedcoffeehouse_common.logging_config—RequestIdMiddleware(raw ASGI),RequestIdFilter,configure_logging,get_request_idcoffeehouse_common.observability—init_sentry(app_slug)Sentry SDK bootstrap; readsSENTRY_DSNfrom env (no-op when unset), tags events withapp_slug, drops 4xx HTTP/CSRF noise, keeps 5xx; stack-frame locals namedpin,pin_hashortemporary_pinare 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):
pip install "coffeehouse-common @ git+https://github.com/rubencfh/coffeehouse-common.git@v0.9.0"
Local editable install:
pip install -e ../coffeehouse-common
Development
Set up the repo for local development + testing:
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%):
pytest
Run the pre-commit hooks against all files (format, lint, secret scan, syntax check):
pre-commit run --all-files
Individual checks:
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:
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
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
HTTPExceptions, and CSRFErrors 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:
# 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:
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_middlewarecall 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
tokenof three base64url segments and aset_cookiewhosech_sessionvalue is exactly that token, with no control character. The app runs this request astokenand 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'ssession.json timer-fired HTMX requests) or a request withAuthorization: Bearer. Only a person's action renews. expis 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_urlempty orNoneswitches 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
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)
- Create an empty repo on GitHub:
rubencfh/coffeehouse-common - From this directory:
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).
Metadata
Release files for coffeehouse-common 0.21.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| coffeehouse_common-0.21.0.tar.gz | 89.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| coffeehouse_common-0.21.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 161.6 kB
Release files / coffeehouse_common-0.21.0.tar.gz
| Download URL | coffeehouse_common-0.21.0.tar.gz |
|---|---|
| Size | 89.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
abe85581b17d8450b82ba9878725dd24ff8e227c19e91c51787b30a4b99147f4
|
|
BLAKE2b-256 checksum How to use checksums |
aa0b51ac91c892e0cbb83adc143bd93bf0c15dff7b50eb0de5c85b86c778af2c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logRelease files / coffeehouse_common-0.21.0-py3-none-any.whl
| Download URL | coffeehouse_common-0.21.0-py3-none-any.whl |
|---|---|
| Size | 72.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dece1c8dd5263a54cec7171d44792f4c5b551dfdd857fe2156111189840b9005
|
|
BLAKE2b-256 checksum How to use checksums |
debc8ef41a5ab72196476b84da9e1e7e4cb6b91c6d0b71e22a311da07cbee474
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency log