guest-auth 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- guest_auth-0.1.1/.github/workflows/ci.yml +28 -0
- guest_auth-0.1.1/.github/workflows/release.yml +39 -0
- guest_auth-0.1.1/.gitignore +27 -0
- guest_auth-0.1.1/LICENSE +21 -0
- guest_auth-0.1.1/PKG-INFO +182 -0
- guest_auth-0.1.1/README.md +133 -0
- guest_auth-0.1.1/docs/integration.md +201 -0
- guest_auth-0.1.1/pyproject.toml +56 -0
- guest_auth-0.1.1/src/guest_auth/__init__.py +47 -0
- guest_auth-0.1.1/src/guest_auth/contextvar_middleware.py +77 -0
- guest_auth-0.1.1/src/guest_auth/identity.py +47 -0
- guest_auth-0.1.1/src/guest_auth/middleware.py +246 -0
- guest_auth-0.1.1/tests/test_contextvar_middleware.py +225 -0
- guest_auth-0.1.1/tests/test_guest_auth.py +231 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
18
|
+
uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- name: Install
|
|
22
|
+
run: |
|
|
23
|
+
python -m pip install --upgrade pip
|
|
24
|
+
pip install -e ".[dev]"
|
|
25
|
+
- name: Ruff
|
|
26
|
+
run: ruff check src tests
|
|
27
|
+
- name: Pytest
|
|
28
|
+
run: pytest -v
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publish to PyPI via OpenID Connect "trusted publishing" — no API token.
|
|
4
|
+
# Triggered by pushing a version tag (e.g. `git tag v0.1.1 && git push origin v0.1.1`).
|
|
5
|
+
# The tagged commit's pyproject.toml `version` is what gets published, so bump
|
|
6
|
+
# it in the same commit you tag.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags:
|
|
10
|
+
- "v*"
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
id-token: write # required for PyPI trusted publishing (OIDC)
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.12"
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
pip install -e ".[dev]" build
|
|
29
|
+
|
|
30
|
+
- name: Lint & test (gate the release)
|
|
31
|
+
run: |
|
|
32
|
+
ruff check src tests
|
|
33
|
+
pytest -v
|
|
34
|
+
|
|
35
|
+
- name: Build sdist + wheel
|
|
36
|
+
run: python -m build
|
|
37
|
+
|
|
38
|
+
- name: Publish to PyPI (trusted publishing)
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
.coverage.*
|
|
15
|
+
htmlcov/
|
|
16
|
+
.tox/
|
|
17
|
+
|
|
18
|
+
# Virtualenvs
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
env/
|
|
22
|
+
|
|
23
|
+
# Editors
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
.DS_Store
|
guest_auth-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Cooper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: guest-auth
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A static-allowlist invite-token gate for pre-production demos and invite-only previews (pure-ASGI middleware for Starlette / FastAPI apps).
|
|
5
|
+
Project-URL: Homepage, https://github.com/ecoop/guest-auth
|
|
6
|
+
Project-URL: Repository, https://github.com/ecoop/guest-auth
|
|
7
|
+
Project-URL: Issues, https://github.com/ecoop/guest-auth/issues
|
|
8
|
+
Author: Eric Cooper
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Eric Cooper
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: asgi,auth,demo,fastapi,invite,starlette
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Framework :: FastAPI
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
41
|
+
Requires-Python: >=3.11
|
|
42
|
+
Requires-Dist: starlette>=0.35
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
45
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# guest-auth
|
|
51
|
+
|
|
52
|
+
**Not a replacement for real authentication.** A static-allowlist invite-token gate for pre-production demos and invite-only previews. Pure-ASGI middleware that plugs into any Starlette / FastAPI app in ~5 lines.
|
|
53
|
+
|
|
54
|
+
Give a tester a link like `https://your-app.example.com/?token=tok_abc123`; the middleware validates the token against an allowlist you own, exchanges it for an `httpOnly` cookie, and attaches an identity (`token` + human-readable `recipient` label) to the request via a `ContextVar` that reaches sync endpoints in the threadpool as well.
|
|
55
|
+
|
|
56
|
+
The library was extracted from [Pitchcraft](https://github.com/ecoop/pitchcraft) and is consumed there in production; [Rulebook](https://github.com/ecoop/rulebook) and JobScout are scheduled to adopt it.
|
|
57
|
+
|
|
58
|
+
**Adopting this in a new app?** See [`docs/integration.md`](docs/integration.md) for the DI pattern, the `app_state.py` template, gotchas (init order, pure-ASGI vs `BaseHTTPMiddleware`, ContextVar propagation), and the constructor reference.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## What this is NOT
|
|
63
|
+
|
|
64
|
+
Naming a library `*-auth` invites expectations it doesn't meet. To be explicit:
|
|
65
|
+
|
|
66
|
+
- **No password handling, no MFA, no OAuth / OIDC, no account lifecycle.** The credential is an opaque token you generate and hand to a tester.
|
|
67
|
+
- **No token rotation, expiry, revocation-list, or signed cookies.** The cookie is httpOnly + Secure + SameSite=Lax with a 30-day convenience lifetime; revoking access means removing the token from the allowlist and redeploying.
|
|
68
|
+
- **No rate limiting.** Compose one separately (e.g. [`llm-guardrails`](https://github.com/ecoop/llm-guardrails) ships an IP rate limiter).
|
|
69
|
+
- **Not audited for adversarial threat models.** This is a gate to keep pre-production URLs off the open web and attribute per-tester activity, not a substitute for real identity infrastructure. If you're gating production PII or payment flows, use something else.
|
|
70
|
+
|
|
71
|
+
The value the library provides — a well-scoped ASGI middleware that publishes a per-request identity ContextVar that reaches sync endpoints — is genuinely useful and hard to get right (the "pure-ASGI vs `BaseHTTPMiddleware`" trap is subtle). Everything above is deferred, not planned.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install guest-auth
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires Python 3.11+. The only runtime dependency is `starlette`, which any ASGI host already has.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Quick example
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from dataclasses import dataclass, field
|
|
89
|
+
from fastapi import FastAPI
|
|
90
|
+
from guest_auth import InviteAuthMiddleware, get_current_guest
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Settings:
|
|
95
|
+
demo_mode: bool = True
|
|
96
|
+
invite_tokens: dict = field(
|
|
97
|
+
default_factory=lambda: {"tok_abc123": "Jane Tester"}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
settings = Settings()
|
|
102
|
+
app = FastAPI()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.get("/")
|
|
106
|
+
def home():
|
|
107
|
+
guest = get_current_guest()
|
|
108
|
+
return {"welcome": guest.recipient if guest else "anonymous"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
app.add_middleware(
|
|
112
|
+
InviteAuthMiddleware,
|
|
113
|
+
config=settings,
|
|
114
|
+
# Optional — pre-rendered HTML for the 401 / welcome page.
|
|
115
|
+
# Omit to use the built-in "This site is currently invite-only." body.
|
|
116
|
+
welcome_html="<h1>Preview build</h1><p>Ask jane@example.com for a link.</p>",
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Now:
|
|
121
|
+
|
|
122
|
+
- `GET /?token=tok_abc123` → 302 to `/`, sets `guest_session` cookie.
|
|
123
|
+
- `GET /` with the cookie → returns `{"welcome": "Jane Tester"}`.
|
|
124
|
+
- `GET /` without a cookie → 401 with the welcome page.
|
|
125
|
+
- Flip `settings.demo_mode = False` → gate becomes a complete pass-through with no restart.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Core concepts
|
|
130
|
+
|
|
131
|
+
### `GuestAuthConfig` (Protocol)
|
|
132
|
+
|
|
133
|
+
The middleware takes a `config` object that satisfies:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
class GuestAuthConfig(Protocol):
|
|
137
|
+
demo_mode: bool
|
|
138
|
+
invite_tokens: Mapping[str, str] # token → recipient label
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Both attributes are read at request time, so mutating a live `config` instance (a pydantic `BaseSettings`, a dataclass, whatever) takes effect on the next request without rebuilding the middleware.
|
|
142
|
+
|
|
143
|
+
### `GuestIdentity` + `get_current_guest()`
|
|
144
|
+
|
|
145
|
+
On a successful cookie match, the middleware sets a request-scoped `ContextVar` with `GuestIdentity(token=..., recipient=...)`. Anywhere downstream — sync or async, including code paths in Starlette's threadpool — `get_current_guest()` returns it or `None`.
|
|
146
|
+
|
|
147
|
+
Because the middleware is pure-ASGI (not `BaseHTTPMiddleware`), the `ContextVar` survives into the threadpool that runs `def` (sync) endpoints. See the [integration doc](docs/integration.md#gotchas) for why this matters.
|
|
148
|
+
|
|
149
|
+
### `PathScopedContextVarMiddleware` (bonus)
|
|
150
|
+
|
|
151
|
+
An adjacent generic that ships in the same package: match a regex against `scope["path"]`, publish an extracted value on a caller-supplied `ContextVar` for the duration of the request. Same pure-ASGI rationale as the auth middleware. Use for `/api/things/{id}/…` style path-scoped ContextVars (session IDs, tenant IDs, whatever).
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
git clone https://github.com/ecoop/guest-auth
|
|
159
|
+
cd guest-auth
|
|
160
|
+
python -m venv .venv && source .venv/bin/activate
|
|
161
|
+
pip install -e ".[dev]"
|
|
162
|
+
pytest
|
|
163
|
+
ruff check src tests
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
CI runs on Python 3.11 and 3.12 via [GitHub Actions](.github/workflows/ci.yml).
|
|
167
|
+
|
|
168
|
+
## Versioning
|
|
169
|
+
|
|
170
|
+
Currently `v0.1.1`. Semver from `v1.0.0` onward; anything before is "shipped but pre-stable API — expect breaking changes."
|
|
171
|
+
|
|
172
|
+
## Contributing
|
|
173
|
+
|
|
174
|
+
Issues and pull requests welcome. For substantive changes, open an issue first — this library has a deliberately small surface and staying small is a feature.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT. See [LICENSE](LICENSE).
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
_Last updated: 2026-08-06_
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# guest-auth
|
|
2
|
+
|
|
3
|
+
**Not a replacement for real authentication.** A static-allowlist invite-token gate for pre-production demos and invite-only previews. Pure-ASGI middleware that plugs into any Starlette / FastAPI app in ~5 lines.
|
|
4
|
+
|
|
5
|
+
Give a tester a link like `https://your-app.example.com/?token=tok_abc123`; the middleware validates the token against an allowlist you own, exchanges it for an `httpOnly` cookie, and attaches an identity (`token` + human-readable `recipient` label) to the request via a `ContextVar` that reaches sync endpoints in the threadpool as well.
|
|
6
|
+
|
|
7
|
+
The library was extracted from [Pitchcraft](https://github.com/ecoop/pitchcraft) and is consumed there in production; [Rulebook](https://github.com/ecoop/rulebook) and JobScout are scheduled to adopt it.
|
|
8
|
+
|
|
9
|
+
**Adopting this in a new app?** See [`docs/integration.md`](docs/integration.md) for the DI pattern, the `app_state.py` template, gotchas (init order, pure-ASGI vs `BaseHTTPMiddleware`, ContextVar propagation), and the constructor reference.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What this is NOT
|
|
14
|
+
|
|
15
|
+
Naming a library `*-auth` invites expectations it doesn't meet. To be explicit:
|
|
16
|
+
|
|
17
|
+
- **No password handling, no MFA, no OAuth / OIDC, no account lifecycle.** The credential is an opaque token you generate and hand to a tester.
|
|
18
|
+
- **No token rotation, expiry, revocation-list, or signed cookies.** The cookie is httpOnly + Secure + SameSite=Lax with a 30-day convenience lifetime; revoking access means removing the token from the allowlist and redeploying.
|
|
19
|
+
- **No rate limiting.** Compose one separately (e.g. [`llm-guardrails`](https://github.com/ecoop/llm-guardrails) ships an IP rate limiter).
|
|
20
|
+
- **Not audited for adversarial threat models.** This is a gate to keep pre-production URLs off the open web and attribute per-tester activity, not a substitute for real identity infrastructure. If you're gating production PII or payment flows, use something else.
|
|
21
|
+
|
|
22
|
+
The value the library provides — a well-scoped ASGI middleware that publishes a per-request identity ContextVar that reaches sync endpoints — is genuinely useful and hard to get right (the "pure-ASGI vs `BaseHTTPMiddleware`" trap is subtle). Everything above is deferred, not planned.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install guest-auth
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.11+. The only runtime dependency is `starlette`, which any ASGI host already has.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick example
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from fastapi import FastAPI
|
|
41
|
+
from guest_auth import InviteAuthMiddleware, get_current_guest
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Settings:
|
|
46
|
+
demo_mode: bool = True
|
|
47
|
+
invite_tokens: dict = field(
|
|
48
|
+
default_factory=lambda: {"tok_abc123": "Jane Tester"}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
settings = Settings()
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.get("/")
|
|
57
|
+
def home():
|
|
58
|
+
guest = get_current_guest()
|
|
59
|
+
return {"welcome": guest.recipient if guest else "anonymous"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
app.add_middleware(
|
|
63
|
+
InviteAuthMiddleware,
|
|
64
|
+
config=settings,
|
|
65
|
+
# Optional — pre-rendered HTML for the 401 / welcome page.
|
|
66
|
+
# Omit to use the built-in "This site is currently invite-only." body.
|
|
67
|
+
welcome_html="<h1>Preview build</h1><p>Ask jane@example.com for a link.</p>",
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Now:
|
|
72
|
+
|
|
73
|
+
- `GET /?token=tok_abc123` → 302 to `/`, sets `guest_session` cookie.
|
|
74
|
+
- `GET /` with the cookie → returns `{"welcome": "Jane Tester"}`.
|
|
75
|
+
- `GET /` without a cookie → 401 with the welcome page.
|
|
76
|
+
- Flip `settings.demo_mode = False` → gate becomes a complete pass-through with no restart.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Core concepts
|
|
81
|
+
|
|
82
|
+
### `GuestAuthConfig` (Protocol)
|
|
83
|
+
|
|
84
|
+
The middleware takes a `config` object that satisfies:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
class GuestAuthConfig(Protocol):
|
|
88
|
+
demo_mode: bool
|
|
89
|
+
invite_tokens: Mapping[str, str] # token → recipient label
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Both attributes are read at request time, so mutating a live `config` instance (a pydantic `BaseSettings`, a dataclass, whatever) takes effect on the next request without rebuilding the middleware.
|
|
93
|
+
|
|
94
|
+
### `GuestIdentity` + `get_current_guest()`
|
|
95
|
+
|
|
96
|
+
On a successful cookie match, the middleware sets a request-scoped `ContextVar` with `GuestIdentity(token=..., recipient=...)`. Anywhere downstream — sync or async, including code paths in Starlette's threadpool — `get_current_guest()` returns it or `None`.
|
|
97
|
+
|
|
98
|
+
Because the middleware is pure-ASGI (not `BaseHTTPMiddleware`), the `ContextVar` survives into the threadpool that runs `def` (sync) endpoints. See the [integration doc](docs/integration.md#gotchas) for why this matters.
|
|
99
|
+
|
|
100
|
+
### `PathScopedContextVarMiddleware` (bonus)
|
|
101
|
+
|
|
102
|
+
An adjacent generic that ships in the same package: match a regex against `scope["path"]`, publish an extracted value on a caller-supplied `ContextVar` for the duration of the request. Same pure-ASGI rationale as the auth middleware. Use for `/api/things/{id}/…` style path-scoped ContextVars (session IDs, tenant IDs, whatever).
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git clone https://github.com/ecoop/guest-auth
|
|
110
|
+
cd guest-auth
|
|
111
|
+
python -m venv .venv && source .venv/bin/activate
|
|
112
|
+
pip install -e ".[dev]"
|
|
113
|
+
pytest
|
|
114
|
+
ruff check src tests
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
CI runs on Python 3.11 and 3.12 via [GitHub Actions](.github/workflows/ci.yml).
|
|
118
|
+
|
|
119
|
+
## Versioning
|
|
120
|
+
|
|
121
|
+
Currently `v0.1.1`. Semver from `v1.0.0` onward; anything before is "shipped but pre-stable API — expect breaking changes."
|
|
122
|
+
|
|
123
|
+
## Contributing
|
|
124
|
+
|
|
125
|
+
Issues and pull requests welcome. For substantive changes, open an issue first — this library has a deliberately small surface and staying small is a feature.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT. See [LICENSE](LICENSE).
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
_Last updated: 2026-08-06_
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Integration Guide
|
|
2
|
+
|
|
3
|
+
How to adopt `guest-auth` in a Python ASGI application. This is the practical companion to the [README](../README.md) — the README explains *what* the library does; this doc explains *how* to wire it into your app.
|
|
4
|
+
|
|
5
|
+
**Reference implementation:** [Pitchcraft](https://github.com/ecoop/pitchcraft) consumes this library in production. Its [`app_state.py`](https://github.com/ecoop/pitchcraft/blob/main/app_state.py) is the canonical adoption pattern; the file pointers throughout this doc are all in that repo.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install guest-auth
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Python 3.11+. The only runtime dependency is `starlette` (which every ASGI host already has), so the install stays lean.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The dependency-injection pattern
|
|
20
|
+
|
|
21
|
+
The library **takes zero configuration from the host app.** The `InviteAuthMiddleware` constructor takes explicit arguments; there are no module singletons inside the library, no config imports, no environment-variable reads.
|
|
22
|
+
|
|
23
|
+
The host app is responsible for:
|
|
24
|
+
|
|
25
|
+
1. Reading its own config (which env vars, which allowlist source).
|
|
26
|
+
2. Constructing the middleware at app-factory time with those values.
|
|
27
|
+
3. Exposing anything downstream needs (e.g. `get_current_guest`) through a facade module rather than reaching into `guest_auth` directly if you'd like to keep the seam narrow.
|
|
28
|
+
|
|
29
|
+
Pitchcraft's [`app_state.py`](https://github.com/ecoop/pitchcraft/blob/main/app_state.py) is the working example for the broader "singleton facade" pattern. The auth-specific slice looks like this:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
# app_state.py — the singleton facade
|
|
33
|
+
from typing import Optional
|
|
34
|
+
from starlette.middleware import Middleware
|
|
35
|
+
|
|
36
|
+
from guest_auth import InviteAuthMiddleware
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_welcome_html: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def initialize(settings) -> None:
|
|
43
|
+
"""Called from main.py at startup, before the FastAPI app factory."""
|
|
44
|
+
global _welcome_html
|
|
45
|
+
_welcome_html = _render_welcome_html(settings.welcome_md_path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def guest_auth_middleware(settings) -> Middleware:
|
|
49
|
+
"""Return the middleware ready to slot into ``FastAPI(middleware=[...])``
|
|
50
|
+
or ``app.add_middleware(...)``. Read once at app-factory time.
|
|
51
|
+
"""
|
|
52
|
+
return Middleware(
|
|
53
|
+
InviteAuthMiddleware,
|
|
54
|
+
config=settings,
|
|
55
|
+
welcome_html=_welcome_html,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _render_welcome_html(md_path) -> str:
|
|
60
|
+
"""Host-side: render your welcome copy. The library takes pre-rendered
|
|
61
|
+
HTML so it stays free of markdown / filesystem dependencies.
|
|
62
|
+
"""
|
|
63
|
+
...
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then in `main.py`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# main.py — order matters
|
|
70
|
+
from config import settings
|
|
71
|
+
|
|
72
|
+
import app_state
|
|
73
|
+
app_state.initialize(settings) # BEFORE the FastAPI app is built
|
|
74
|
+
|
|
75
|
+
from fastapi import FastAPI
|
|
76
|
+
app = FastAPI()
|
|
77
|
+
app.add_middleware(
|
|
78
|
+
InviteAuthMiddleware,
|
|
79
|
+
config=settings,
|
|
80
|
+
welcome_html=app_state._welcome_html,
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Consumers of the identity import `get_current_guest` directly — it's a plain function that reads a `ContextVar`, safe to call from anywhere:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from guest_auth import get_current_guest
|
|
88
|
+
|
|
89
|
+
def some_endpoint():
|
|
90
|
+
guest = get_current_guest()
|
|
91
|
+
if guest is not None:
|
|
92
|
+
log.info("request from %s (%s)", guest.recipient, guest.token[:8])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Gotchas
|
|
98
|
+
|
|
99
|
+
### 1. Pure-ASGI is load-bearing — do NOT use `BaseHTTPMiddleware`
|
|
100
|
+
|
|
101
|
+
The middleware is a pure ASGI class (`__call__(scope, receive, send)`), not `BaseHTTPMiddleware` and not `@app.middleware("http")`. This is not a stylistic choice.
|
|
102
|
+
|
|
103
|
+
Both `BaseHTTPMiddleware` and the `@app.middleware` decorator run the downstream app in a **separate anyio task**. Any `ContextVar` set inside their `dispatch` reaches the endpoint *only if the endpoint happens to run in the same task*. FastAPI's sync (`def`) endpoints run in Starlette's threadpool — a different task — and the `ContextVar` propagation there is unreliable.
|
|
104
|
+
|
|
105
|
+
`InviteAuthMiddleware` sets the identity `ContextVar` from the same task the endpoint runs in, so it propagates into the threadpool along with the copy anyio takes when it launches the sync worker. If you find yourself wrapping this middleware in another `BaseHTTPMiddleware` layer, understand that any ContextVars *that layer* sets won't reach sync endpoints — that's a Starlette-level issue independent of this library.
|
|
106
|
+
|
|
107
|
+
The generic sibling `PathScopedContextVarMiddleware` follows the same pattern for the same reason.
|
|
108
|
+
|
|
109
|
+
### 2. Init order — `initialize()` before FastAPI app-factory
|
|
110
|
+
|
|
111
|
+
If you're using a facade like the one above, call `app_state.initialize(...)` **before** the FastAPI app is constructed and its routers imported. Once routers are imported, decorators have already fired; anything they closed over (config values, middleware factories) is snapshotted.
|
|
112
|
+
|
|
113
|
+
The middleware itself is fine either way — it reads config at request time — but any *rendering* your facade does at `initialize()` (like the welcome HTML) needs to be ready before whoever calls it.
|
|
114
|
+
|
|
115
|
+
### 3. Config is read at request time, not construction time
|
|
116
|
+
|
|
117
|
+
The middleware reads `config.demo_mode` and `config.invite_tokens` on every request via attribute lookup. Two implications:
|
|
118
|
+
|
|
119
|
+
- **Tests** can `monkeypatch.setattr(settings, "invite_tokens", {"tok_x": "Test"})` on a live app and the next request picks it up.
|
|
120
|
+
- **Production hot-swap**: if your config source is mutable (a reload signal, a file watcher), pushing a new allowlist value into the same instance flips the gate without a restart.
|
|
121
|
+
|
|
122
|
+
If you specifically want construction-time snapshotting instead, wrap your config in an immutable dataclass and pass a fresh instance.
|
|
123
|
+
|
|
124
|
+
### 4. Cookie name is a module constant, not per-instance
|
|
125
|
+
|
|
126
|
+
`guest_auth.COOKIE_NAME = "guest_session"`. If you run two apps on the same origin and want per-app cookies, you need one of:
|
|
127
|
+
|
|
128
|
+
- Deploy them on different domains / paths so cookies don't collide.
|
|
129
|
+
- Fork the library and change the constant (there is no constructor arg for it in v0.1.0 — that's a candidate for v0.2).
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Constructor reference
|
|
134
|
+
|
|
135
|
+
### `InviteAuthMiddleware`
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
InviteAuthMiddleware(
|
|
139
|
+
app, # ASGI app being wrapped
|
|
140
|
+
config, # object exposing demo_mode + invite_tokens
|
|
141
|
+
*,
|
|
142
|
+
welcome_html: str | None = None, # pre-rendered 401 / welcome body
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Arg | Type | Purpose |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `app` | `ASGIApp` | Downstream app the middleware wraps. Populated automatically when using `app.add_middleware()`. |
|
|
149
|
+
| `config` | `GuestAuthConfig` | Any object with `demo_mode: bool` and `invite_tokens: Mapping[str, str]`. Attribute-read per request. |
|
|
150
|
+
| `welcome_html` | `str \| None` | Pre-rendered HTML body injected verbatim into the library's page chrome. `None` uses a minimal built-in fallback. |
|
|
151
|
+
|
|
152
|
+
### `PathScopedContextVarMiddleware`
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
PathScopedContextVarMiddleware(
|
|
156
|
+
app,
|
|
157
|
+
*,
|
|
158
|
+
pattern: re.Pattern | str, # matched against scope["path"]
|
|
159
|
+
contextvar: ContextVar[Any], # target var to publish onto
|
|
160
|
+
extractor: Callable[[re.Match], Any] = lambda m: m.group(1),
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
| Arg | Type | Purpose |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `pattern` | `re.Pattern \| str` | Matched with `.match()` — anchor with `^` for a prefix match. String is compiled internally. |
|
|
167
|
+
| `contextvar` | `ContextVar[Any]` | Value stored on match; `None` on non-match. Reset after each request. |
|
|
168
|
+
| `extractor` | `Callable` | How to turn the `re.Match` into the stored value. Default: first capture group. |
|
|
169
|
+
|
|
170
|
+
### `GuestIdentity`
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class GuestIdentity:
|
|
175
|
+
token: str # the credential itself
|
|
176
|
+
recipient: str # human-readable label from the allowlist
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Module-level constants + functions
|
|
180
|
+
|
|
181
|
+
| Name | Purpose |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `COOKIE_NAME = "guest_session"` | Cookie the middleware sets and reads. |
|
|
184
|
+
| `COOKIE_MAX_AGE = 60 * 60 * 24 * 30` | 30 days, in seconds. Convenience lifetime, not token expiry. |
|
|
185
|
+
| `get_current_guest() -> GuestIdentity \| None` | Read the current request's identity, or `None` outside a request or when demo mode is off. |
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Reference implementation: Pitchcraft
|
|
190
|
+
|
|
191
|
+
Files worth skimming, in priority order:
|
|
192
|
+
|
|
193
|
+
1. [`app_state.py`](https://github.com/ecoop/pitchcraft/blob/main/app_state.py) — singleton facade pattern.
|
|
194
|
+
2. [`api/main.py`](https://github.com/ecoop/pitchcraft/blob/main/api/main.py) — how the middleware slots into the FastAPI app-factory.
|
|
195
|
+
3. [`api/observability.py`](https://github.com/ecoop/pitchcraft/blob/main/api/observability.py) — a consumer of `get_current_guest()` in the request path.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Getting help
|
|
200
|
+
|
|
201
|
+
For questions on the adoption pattern that aren't covered here: open an issue on this repo, or point at the Pitchcraft reference files above — they're the working ground truth.
|