keycardai-starlette 0.2.0__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.
- keycardai_starlette-0.2.0/.gitignore +193 -0
- keycardai_starlette-0.2.0/CHANGELOG.md +223 -0
- keycardai_starlette-0.2.0/PKG-INFO +183 -0
- keycardai_starlette-0.2.0/README.md +148 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/.env.example +4 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/.gitignore +4 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/.python-version +1 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/README.md +66 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/keycard.toml +6 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/main.py +78 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/pyproject.toml +18 -0
- keycardai_starlette-0.2.0/examples/protected_resource_server/uv.lock +571 -0
- keycardai_starlette-0.2.0/pyproject.toml +125 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/__init__.py +77 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/authorization.py +282 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/handlers/__init__.py +11 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/handlers/jwks.py +27 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/handlers/metadata.py +200 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/middleware/__init__.py +19 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/middleware/bearer.py +382 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/provider.py +344 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/routers/__init__.py +13 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/routers/metadata.py +212 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/shared/__init__.py +3 -0
- keycardai_starlette-0.2.0/src/keycardai/starlette/shared/starlette.py +23 -0
- keycardai_starlette-0.2.0/tests/__init__.py +0 -0
- keycardai_starlette-0.2.0/tests/keycardai/__init__.py +0 -0
- keycardai_starlette-0.2.0/tests/keycardai/starlette/__init__.py +0 -0
- keycardai_starlette-0.2.0/tests/keycardai/starlette/test_provider.py +512 -0
- keycardai_starlette-0.2.0/tests/keycardai/starlette/test_routers.py +146 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
110
|
+
.pdm.toml
|
|
111
|
+
|
|
112
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
113
|
+
__pypackages__/
|
|
114
|
+
|
|
115
|
+
# Celery stuff
|
|
116
|
+
celerybeat-schedule
|
|
117
|
+
celerybeat.pid
|
|
118
|
+
|
|
119
|
+
# SageMath parsed files
|
|
120
|
+
*.sage.py
|
|
121
|
+
|
|
122
|
+
# Environments
|
|
123
|
+
.env
|
|
124
|
+
.venv
|
|
125
|
+
env/
|
|
126
|
+
venv/
|
|
127
|
+
ENV/
|
|
128
|
+
env.bak/
|
|
129
|
+
venv.bak/
|
|
130
|
+
|
|
131
|
+
# Spyder project settings
|
|
132
|
+
.spyderproject
|
|
133
|
+
.spyproject
|
|
134
|
+
|
|
135
|
+
# Rope project settings
|
|
136
|
+
.ropeproject
|
|
137
|
+
|
|
138
|
+
# mkdocs documentation
|
|
139
|
+
/site
|
|
140
|
+
|
|
141
|
+
# mypy
|
|
142
|
+
.mypy_cache/
|
|
143
|
+
.dmypy.json
|
|
144
|
+
dmypy.json
|
|
145
|
+
|
|
146
|
+
# Pyre type checker
|
|
147
|
+
.pyre/
|
|
148
|
+
|
|
149
|
+
# pytype static type analyzer
|
|
150
|
+
.pytype/
|
|
151
|
+
|
|
152
|
+
# Cython debug symbols
|
|
153
|
+
cython_debug/
|
|
154
|
+
|
|
155
|
+
# PyCharm
|
|
156
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
157
|
+
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
|
|
158
|
+
# project, uncomment below!
|
|
159
|
+
#.idea/
|
|
160
|
+
|
|
161
|
+
# VS Code
|
|
162
|
+
.vscode/
|
|
163
|
+
|
|
164
|
+
# OS generated files
|
|
165
|
+
.DS_Store
|
|
166
|
+
.DS_Store?
|
|
167
|
+
._*
|
|
168
|
+
.Spotlight-V100
|
|
169
|
+
.Trashes
|
|
170
|
+
ehthumbs.db
|
|
171
|
+
Thumbs.db
|
|
172
|
+
|
|
173
|
+
# Backup files
|
|
174
|
+
*~
|
|
175
|
+
*.bak
|
|
176
|
+
*.backup
|
|
177
|
+
*.old
|
|
178
|
+
*.orig
|
|
179
|
+
|
|
180
|
+
# Temporary files
|
|
181
|
+
*.tmp
|
|
182
|
+
*.temp
|
|
183
|
+
|
|
184
|
+
# IDE files
|
|
185
|
+
*.swp
|
|
186
|
+
*.swo
|
|
187
|
+
*~
|
|
188
|
+
|
|
189
|
+
# Local development
|
|
190
|
+
.local/
|
|
191
|
+
|
|
192
|
+
# Claude Code
|
|
193
|
+
CLAUDE.md
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
## 0.2.0-keycardai-starlette (2026-04-26)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
- feat(keycardai-starlette): new package for Starlette/FastAPI Keycard integration (#97)
|
|
5
|
+
- * feat(keycardai-starlette-oauth): new package for Starlette/FastAPI OAuth middleware
|
|
6
|
+
- Implements Tier 2 of the Protocol-Agnostic SDK KEP: a new
|
|
7
|
+
keycardai-starlette-oauth package that provides Starlette-specific
|
|
8
|
+
middleware and route builders without any MCP dependency.
|
|
9
|
+
- New package (packages/starlette-oauth/):
|
|
10
|
+
- middleware/bearer.py: BearerAuthMiddleware
|
|
11
|
+
- handlers/metadata.py: RFC 9728 + RFC 8414 metadata with local
|
|
12
|
+
ProtectedResourceMetadata model (no mcp.shared.auth dependency)
|
|
13
|
+
- handlers/jwks.py: JWKS endpoint handler
|
|
14
|
+
- routers/metadata.py: Route builders + protected_router()
|
|
15
|
+
- provider.py: AuthProvider with install() and @protect() decorator
|
|
16
|
+
- shared/starlette.py: Proxy-aware URL helpers
|
|
17
|
+
- keycardai-mcp changes:
|
|
18
|
+
- Now depends on keycardai-starlette-oauth (starlette removed from
|
|
19
|
+
direct deps since it comes transitively)
|
|
20
|
+
- Server middleware/handlers/routers replaced with re-export shims
|
|
21
|
+
- protected_mcp_router wraps protected_router with mcp_app kwarg compat
|
|
22
|
+
- All existing imports continue to work
|
|
23
|
+
- * refactor(keycardai-starlette): rename from keycardai-starlette-oauth
|
|
24
|
+
- Per revised KEP naming decisions: drop the OAuth suffix from the
|
|
25
|
+
customer-facing package since it will cover more than just OAuth
|
|
26
|
+
(token exchange, policy enforcement, vaulted creds, etc.). The
|
|
27
|
+
keycardai-oauth package stays as an internal building block.
|
|
28
|
+
- Renames:
|
|
29
|
+
- packages/starlette-oauth/ → packages/starlette/
|
|
30
|
+
- src/keycardai/starlette_oauth/ → src/keycardai/starlette/
|
|
31
|
+
- keycardai-starlette-oauth → keycardai-starlette (PyPI name)
|
|
32
|
+
- keycardai.starlette_oauth → keycardai.starlette (import path)
|
|
33
|
+
- Updated workspace source, MCP dependency, and all MCP shim imports.
|
|
34
|
+
Backward-compat shims in keycardai-mcp continue to work.
|
|
35
|
+
- * feat(keycardai-starlette): add smoke tests and fix .well-known middleware bypass
|
|
36
|
+
- - Add 22 smoke tests covering metadata routes, AuthProvider install/config,
|
|
37
|
+
and a guarantee that keycardai.starlette has no keycardai.mcp imports.
|
|
38
|
+
- Fix BearerAuthMiddleware to skip /.well-known/* paths. Without this,
|
|
39
|
+
AuthProvider.install() (which adds the middleware globally) blocked the
|
|
40
|
+
OAuth discovery endpoints it had just registered — clients got 401 trying
|
|
41
|
+
to learn how to authenticate. Metadata discovery per RFC 9728 §2 must
|
|
42
|
+
remain publicly reachable.
|
|
43
|
+
- Add fastapi and httpx to the starlette package test extras.
|
|
44
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
45
|
+
- * chore: adjust coverage thresholds after starlette extraction
|
|
46
|
+
- - Add keycardai-starlette to test-coverage and test recipes
|
|
47
|
+
- Lower mcp threshold from 65% to 60%: the well-tested server auth code
|
|
48
|
+
moved to keycardai-oauth / keycardai-starlette, leaving a higher
|
|
49
|
+
proportion of under-tested client integrations (CrewAI/LangChain/OpenAI
|
|
50
|
+
adapters at 14-25%) in the denominator. Absolute coverage of the
|
|
51
|
+
remaining code is unchanged; the ratio is what shifted.
|
|
52
|
+
- Set starlette threshold to 55% (smoke tests cover the surface area;
|
|
53
|
+
provider.py @protect() decorator and async client init are the main
|
|
54
|
+
gap, tracked as a follow-up)
|
|
55
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
56
|
+
- * fix(scripts): pass --yes to cz bump in version_preview for new packages
|
|
57
|
+
- Commitizen prompts "Is this the first tag created?" when it cannot find an
|
|
58
|
+
existing tag matching a package's tag_format. For brand-new packages like
|
|
59
|
+
keycardai-starlette that have no tag yet, this prompt EOFs in non-TTY CI
|
|
60
|
+
runs and causes release-preview to report an error instead of a version
|
|
61
|
+
delta.
|
|
62
|
+
- --yes auto-confirms the prompt. Existing packages with prior tags never
|
|
63
|
+
see the prompt, so their output is unchanged.
|
|
64
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
65
|
+
- * chore: regenerate uv.lock with uv >= 0.9 format
|
|
66
|
+
- Older lock file (generated with uv 0.8.x) failed to parse on CI's newer
|
|
67
|
+
uv with "Dependency `pytokens` has missing `source` field but has more
|
|
68
|
+
than one matching package". The lock format tightened in 0.9+ to require
|
|
69
|
+
explicit source annotations when multiple resolution markers are in play.
|
|
70
|
+
- Regenerated with uv 0.11.7. Resolution now succeeds under setup-uv@v4
|
|
71
|
+
(unpinned, tracks latest). All package test suites still pass
|
|
72
|
+
(oauth 208, starlette 22, mcp 560, mcp-fastmcp 51).
|
|
73
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
74
|
+
- * ci: wire keycardai-starlette into release workflow tag filter
|
|
75
|
+
- The release workflow only triggers on tag patterns explicitly listed in
|
|
76
|
+
on.push.tags. Without adding *-keycardai-starlette, tags created by
|
|
77
|
+
commitizen for the new package (e.g. 0.1.0-keycardai-starlette) would
|
|
78
|
+
not trigger the release job, so nothing would publish to PyPI even if a
|
|
79
|
+
Trusted Publisher were configured.
|
|
80
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
81
|
+
- * chore: minimize uv.lock diff to just the keycardai-starlette addition
|
|
82
|
+
- The previous regeneration pass rebuilt the lock wholesale and produced
|
|
83
|
+
a 5-marker resolution format (splitting python_full_version >= '3.14'
|
|
84
|
+
into '3.15' and '3.14.*'). CI's uv 0.11.7 could not parse that,
|
|
85
|
+
failing with "pytokens has missing source field but has more than one
|
|
86
|
+
matching package" during uv sync --all-extras.
|
|
87
|
+
- Revert to origin/main's lock and re-run `uv lock --no-upgrade`, which
|
|
88
|
+
adds only the keycardai-starlette workspace member (34-line diff) and
|
|
89
|
+
leaves the resolution-markers block identical to main. CI parses it
|
|
90
|
+
cleanly; all package test suites pass.
|
|
91
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
92
|
+
- * style: trim verbose comments added during review
|
|
93
|
+
- Condense the justfile coverage-threshold note and version_preview.py
|
|
94
|
+
--yes flag comment to one sentence each.
|
|
95
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
96
|
+
- * fix(keycardai-starlette): address PR review feedback from cmars
|
|
97
|
+
- Seven correctness and style fixes:
|
|
98
|
+
- 1. bearer.py: tighten the auth-bypass path match. The previous
|
|
99
|
+
`path.startswith("/.well-known/")` exempted ALL well-known URIs (e.g.
|
|
100
|
+
`/.well-known/change-password`, `assetlinks.json`) from bearer auth.
|
|
101
|
+
Replace with an explicit allowlist of OAuth metadata endpoints
|
|
102
|
+
(`oauth-protected-resource`, `oauth-authorization-server`, `jwks.json`),
|
|
103
|
+
matched as exact paths or delimited subpaths. Cite RFC 9728 §2 / RFC
|
|
104
|
+
8414 §3 as the spec basis.
|
|
105
|
+
- 2. provider.py `_get_or_create_client`: the parameter was annotated
|
|
106
|
+
`dict[str, str] | None = None` but every line dereferenced it
|
|
107
|
+
unguarded. Drop the Optional from the signature; callers always pass
|
|
108
|
+
a non-None dict.
|
|
109
|
+
- 3. provider.py `__init__`: construct `_init_lock = asyncio.Lock()`
|
|
110
|
+
eagerly instead of lazily. The previous `if self._init_lock is None:
|
|
111
|
+
self._init_lock = asyncio.Lock()` was technically safe in pure
|
|
112
|
+
asyncio (no await between check and assign) but reads as a race
|
|
113
|
+
smell. Eager init removes the question. asyncio.Lock can be created
|
|
114
|
+
outside an event loop in Python 3.10+.
|
|
115
|
+
- 4. provider.py docstring: rephrase the AuthProvider class docstring to
|
|
116
|
+
describe what the class does instead of what it lacks ("without any
|
|
117
|
+
MCP dependency").
|
|
118
|
+
- 5. handlers/metadata.py `protected_resource_metadata`: return
|
|
119
|
+
`JSONResponse(content=dict)` instead of `Response(content=json_string)`.
|
|
120
|
+
The previous implementation served `Content-Type: text/plain`.
|
|
121
|
+
- 6. handlers/metadata.py `authorization_server_metadata`: pass an explicit
|
|
122
|
+
`timeout=httpx.Timeout(5.0)` to `httpx.Client` so a slow upstream
|
|
123
|
+
cannot pin a Starlette threadpool worker indefinitely. Switch the
|
|
124
|
+
error responses to JSONResponse for the same Content-Type reason.
|
|
125
|
+
- 7. shared/starlette.py `get_base_url`: guard against `None` port. When
|
|
126
|
+
`request_base_url.port` is None (proxy stripped it, missing from
|
|
127
|
+
pydantic parsing), the previous code interpolated `:None` into the
|
|
128
|
+
URL string. Now treat None like the default ports (omit).
|
|
129
|
+
- Adds regression tests:
|
|
130
|
+
- `/.well-known/change-password` returns 401 (path-specific bypass)
|
|
131
|
+
- `/.well-known/oauth-protected-resource/zone-id/path` returns 200
|
|
132
|
+
- `_init_lock` is an asyncio.Lock after `__init__`
|
|
133
|
+
- `Content-Type` is `application/json` on the metadata response
|
|
134
|
+
- `httpx.Client` is constructed with an explicit `timeout=` kwarg
|
|
135
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
136
|
+
- * refactor(keycardai-starlette): make install() per-route opt-in instead of whole-app lockdown
|
|
137
|
+
- The previous install() shape added BearerAuthMiddleware globally so every
|
|
138
|
+
route in the FastAPI/Starlette app required a bearer token. A /health or
|
|
139
|
+
/version endpoint returned 401, which contradicts the framing in the
|
|
140
|
+
Protect Any API guide ("an API that knows which agent is calling") and the
|
|
141
|
+
existing per-subtree code patterns the docs already show
|
|
142
|
+
(BearerAuthMiddleware on a Mount, protected_mcp_router(...)).
|
|
143
|
+
- After this change:
|
|
144
|
+
- install(app) adds OAuth metadata routes only (.well-known/oauth-*).
|
|
145
|
+
No global middleware. Routes are public by default.
|
|
146
|
+
- @auth.protect() (no args) verifies the bearer token, returns 401 on
|
|
147
|
+
missing/invalid. No delegation, no AccessContext required.
|
|
148
|
+
- @auth.protect("resource") verifies + runs delegated token exchange and
|
|
149
|
+
populates an AccessContext as before.
|
|
150
|
+
- protected_router() is unchanged. Still the right pattern for protecting
|
|
151
|
+
a whole subtree (MCP transport, internal admin app, etc.).
|
|
152
|
+
- Implementation:
|
|
153
|
+
- Extract the verification body of BearerAuthMiddleware.dispatch() into a
|
|
154
|
+
free verify_bearer_token(request, verifier) helper that returns either an
|
|
155
|
+
auth_info dict on success or an RFC 6750 challenge Response on failure.
|
|
156
|
+
Both the middleware and the decorator call it.
|
|
157
|
+
- The decorator reuses request.state.keycardai_auth_info if the middleware
|
|
158
|
+
already populated it (e.g. inside a protected_router() mount), otherwise
|
|
159
|
+
calls verify_bearer_token itself and returns the 401 directly on failure.
|
|
160
|
+
- AccessContext lookup and injection only run when resources is set.
|
|
161
|
+
- Test changes:
|
|
162
|
+
- Removed test_install_rejects_requests_without_bearer_token (old contract).
|
|
163
|
+
- Removed test_install_does_not_bypass_unrelated_well_known_paths (without
|
|
164
|
+
global middleware, /.well-known/change-password is now a 404, which the
|
|
165
|
+
framework provides; nothing for us to assert here).
|
|
166
|
+
- Added test_install_does_not_block_unprotected_routes: /health stays 200.
|
|
167
|
+
- Added test_install_does_not_add_global_middleware: BearerAuthMiddleware
|
|
168
|
+
is NOT in app.user_middleware after install().
|
|
169
|
+
- Added TestProtectDecorator class:
|
|
170
|
+
- no-args form returns 401 without bearer
|
|
171
|
+
- resource form returns 401 without bearer
|
|
172
|
+
- no-args form does not require AccessContext on the function signature
|
|
173
|
+
- decorator reuses request.state when middleware preset it (verify_token
|
|
174
|
+
asserts if called)
|
|
175
|
+
- README and module docstrings rewritten to show the new model with three
|
|
176
|
+
distinct patterns (decorator no-args, decorator with resource, protected_router).
|
|
177
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
178
|
+
- * style(keycardai-starlette): drop temporal/historical comments and tighten test names
|
|
179
|
+
- The previous refactor commit shipped a few comments framed against the
|
|
180
|
+
prior code shape ("Reuse middleware-set auth info if BearerAuthMiddleware
|
|
181
|
+
ran ... otherwise verify the bearer token here") and a couple of
|
|
182
|
+
section-header style comments restating what the code does. Drop them.
|
|
183
|
+
Move the "two-call-sites" framing out of the verify_bearer_token
|
|
184
|
+
docstring; describe the present contract.
|
|
185
|
+
- Rename test_install_does_not_add_global_middleware to
|
|
186
|
+
test_install_leaves_user_middleware_stack_empty and
|
|
187
|
+
test_install_does_not_block_unprotected_routes to
|
|
188
|
+
test_routes_without_protect_decorator_stay_public for clearer positive
|
|
189
|
+
framing.
|
|
190
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
191
|
+
- * Kamil/starlette auth model (#98)
|
|
192
|
+
- * align keycardai-starlette with starlette authentication framework
|
|
193
|
+
- * add protected_resource_server example for keycardai-starlette
|
|
194
|
+
- * prevent transitive load_dotenv from polluting mcp test environment
|
|
195
|
+
- * fix(lint): resolve ruff B026 and I001 errors after merging #98
|
|
196
|
+
- Three errors flagged by `just check` after the #98 merge:
|
|
197
|
+
- - packages/mcp/tests/conftest.py: B026 star-arg unpacking after keyword
|
|
198
|
+
argument. Forward dotenv_path/stream positionally to the real load_dotenv.
|
|
199
|
+
- packages/starlette/src/keycardai/starlette/authorization.py: I001 import
|
|
200
|
+
ordering (auto-fixed).
|
|
201
|
+
- packages/starlette/src/keycardai/starlette/provider.py: I001 import
|
|
202
|
+
ordering (auto-fixed).
|
|
203
|
+
- All test suites still pass: starlette 42, mcp 560, oauth 208, mcp-fastmcp 51.
|
|
204
|
+
- Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
205
|
+
- * refactor(keycardai-starlette): tighten review findings before merge
|
|
206
|
+
- - mcp.server.routers re-exports the protected_mcp_router wrapper so the
|
|
207
|
+
mcp_app= kwarg keeps working through the package-level import
|
|
208
|
+
- consolidate the RFC 6750 challenge response into one helper shared by
|
|
209
|
+
keycard_on_error and the @requires/@auth.grant decorators
|
|
210
|
+
- drop KeycardUser.resource_client_id (was always equal to
|
|
211
|
+
resource_server_url); grant.wrapper reads resource_server_url for both
|
|
212
|
+
auth_info dict keys
|
|
213
|
+
- type _get_or_create_client auth_info as dict[str, str | None] so
|
|
214
|
+
zone_id is no longer mistyped as str
|
|
215
|
+
- replace test that asserted staticmethod identity with regression tests
|
|
216
|
+
for the well-known bypass: OAuth metadata paths short-circuit, sibling
|
|
217
|
+
paths (change-password, security.txt, oauth-protected-resource-fake,
|
|
218
|
+
openid-configuration) still raise KeycardAuthError
|
|
219
|
+
- rewrite test_no_auth_header_returns_none to call the backend directly
|
|
220
|
+
instead of building a FastAPI app and patching middleware kwargs
|
|
221
|
+
- ---------
|
|
222
|
+
- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
223
|
+
Co-authored-by: Kamil <kamil@keycard.ai>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keycardai-starlette
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Starlette/FastAPI middleware and route builders for protecting HTTP APIs with Keycard OAuth
|
|
5
|
+
Project-URL: Homepage, https://github.com/keycardai/python-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/keycardai/python-sdk
|
|
7
|
+
Project-URL: Documentation, https://docs.keycardai.com
|
|
8
|
+
Project-URL: Issues, https://github.com/keycardai/python-sdk/issues
|
|
9
|
+
Author-email: Keycard <support@keycard.ai>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: authentication,fastapi,keycard,middleware,oauth,oauth2,starlette
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx>=0.27.2
|
|
26
|
+
Requires-Dist: keycardai-oauth>=0.9.0
|
|
27
|
+
Requires-Dist: pydantic>=2.11.7
|
|
28
|
+
Requires-Dist: starlette>=0.47.3
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: fastapi>=0.116.0; extra == 'test'
|
|
31
|
+
Requires-Dist: httpx>=0.27.2; extra == 'test'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=1.1.0; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest>=8.4.1; extra == 'test'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# keycardai-starlette
|
|
37
|
+
|
|
38
|
+
Starlette/FastAPI integration for Keycard. Plugs into Starlette's standard
|
|
39
|
+
authentication framework: an `AuthenticationBackend` populates `request.user`
|
|
40
|
+
and `request.auth`, the `@requires` decorator gates routes, and
|
|
41
|
+
`@auth.grant(resource)` performs delegated OAuth 2.0 token exchange.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install keycardai-starlette
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from fastapi import FastAPI, Request
|
|
53
|
+
from keycardai.starlette import AuthProvider, KeycardUser, requires
|
|
54
|
+
from keycardai.oauth.server import AccessContext, ClientSecret
|
|
55
|
+
|
|
56
|
+
auth = AuthProvider(
|
|
57
|
+
zone_id="your-zone-id",
|
|
58
|
+
application_credential=ClientSecret(("client_id", "client_secret")),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
auth.install(app) # AuthenticationMiddleware + /.well-known/* routes
|
|
63
|
+
|
|
64
|
+
@app.get("/health")
|
|
65
|
+
async def health():
|
|
66
|
+
return {"ok": True} # public, no decorator
|
|
67
|
+
|
|
68
|
+
@app.get("/api/me")
|
|
69
|
+
@requires("authenticated") # standard Starlette gating
|
|
70
|
+
async def me(request: Request):
|
|
71
|
+
user: KeycardUser = request.user
|
|
72
|
+
return {"client_id": user.client_id, "scopes": list(request.auth.scopes)}
|
|
73
|
+
|
|
74
|
+
@app.get("/api/data")
|
|
75
|
+
@requires("authenticated")
|
|
76
|
+
@auth.grant("https://api.example.com") # delegated token exchange (RFC 8693)
|
|
77
|
+
async def get_data(request: Request, access: AccessContext):
|
|
78
|
+
token = access.access("https://api.example.com").access_token
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## How it integrates with Starlette
|
|
82
|
+
|
|
83
|
+
`AuthProvider.install(app)` does two things:
|
|
84
|
+
|
|
85
|
+
1. Adds `starlette.middleware.authentication.AuthenticationMiddleware` wired
|
|
86
|
+
to a `KeycardAuthBackend` so every request gets a populated `request.user`
|
|
87
|
+
and `request.auth`.
|
|
88
|
+
2. Mounts the OAuth discovery endpoints under `/.well-known/`.
|
|
89
|
+
|
|
90
|
+
Routes you do not decorate stay public: the backend returns `None` (anonymous
|
|
91
|
+
user) when no `Authorization` header is present, exactly like
|
|
92
|
+
`starlette.authentication.UnauthenticatedUser`. Routes that need a verified
|
|
93
|
+
caller use the `@requires(...)` decorator.
|
|
94
|
+
|
|
95
|
+
## Decorators
|
|
96
|
+
|
|
97
|
+
### `@requires(scopes)`
|
|
98
|
+
|
|
99
|
+
`keycardai.starlette.requires` is a drop-in for
|
|
100
|
+
`starlette.authentication.requires` with one difference: anonymous requests
|
|
101
|
+
get an RFC 6750 401 response with a
|
|
102
|
+
`WWW-Authenticate: Bearer ... resource_metadata="..."` header (RFC 9728)
|
|
103
|
+
instead of stock `HTTPException(403)`. Scope checks behave the same.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@requires("authenticated") # any verified caller
|
|
107
|
+
@requires(["authenticated", "admin"]) # additional scope check
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`AuthProvider.requires` is exposed as a static-method alias if you prefer
|
|
111
|
+
accessing the decorator via the provider instance:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@auth.requires("authenticated")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `@auth.grant(resource)`
|
|
118
|
+
|
|
119
|
+
Performs OAuth 2.0 delegated token exchange (RFC 8693) for one or more
|
|
120
|
+
downstream resources and injects an `AccessContext` parameter into the
|
|
121
|
+
endpoint. Mirrors the `@grant()` decorator from `keycardai-mcp` so the
|
|
122
|
+
decorator name is consistent across packages.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
@app.get("/api/calendar")
|
|
126
|
+
@requires("authenticated")
|
|
127
|
+
@auth.grant("https://graph.microsoft.com")
|
|
128
|
+
async def calendar(request: Request, access: AccessContext):
|
|
129
|
+
token = access.access("https://graph.microsoft.com").access_token
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Errors from the exchange are stored per-resource on the `AccessContext`
|
|
133
|
+
rather than raised: call `access.has_errors()` / `access.get_errors()` to
|
|
134
|
+
decide how to respond. The `AccessContext` parameter is hidden from FastAPI
|
|
135
|
+
introspection via `__signature__` rewriting, so it never appears in the
|
|
136
|
+
generated OpenAPI schema.
|
|
137
|
+
|
|
138
|
+
## Other entry points
|
|
139
|
+
|
|
140
|
+
### `protected_router()`
|
|
141
|
+
|
|
142
|
+
Mount any ASGI app behind Keycard authentication and the `/.well-known/*`
|
|
143
|
+
metadata routes in one call. Useful when every route under some prefix needs
|
|
144
|
+
the same protection (for example an MCP transport, an internal admin app).
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from keycardai.starlette import protected_router
|
|
148
|
+
from starlette.applications import Starlette
|
|
149
|
+
|
|
150
|
+
inner = build_my_api() # any ASGI app
|
|
151
|
+
|
|
152
|
+
app = Starlette(routes=protected_router(
|
|
153
|
+
issuer=auth.issuer,
|
|
154
|
+
app=inner,
|
|
155
|
+
verifier=auth.get_token_verifier(),
|
|
156
|
+
))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `AuthenticationMiddleware` directly
|
|
160
|
+
|
|
161
|
+
For full control over middleware ordering, register the standard Starlette
|
|
162
|
+
middleware yourself:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
166
|
+
from keycardai.starlette import KeycardAuthBackend, keycard_on_error
|
|
167
|
+
|
|
168
|
+
app.add_middleware(
|
|
169
|
+
AuthenticationMiddleware,
|
|
170
|
+
backend=KeycardAuthBackend(auth.get_token_verifier()),
|
|
171
|
+
on_error=keycard_on_error,
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## What `install()` adds
|
|
176
|
+
|
|
177
|
+
- `AuthenticationMiddleware` with `KeycardAuthBackend`
|
|
178
|
+
- `/.well-known/oauth-protected-resource` (RFC 9728)
|
|
179
|
+
- `/.well-known/oauth-authorization-server` (RFC 8414)
|
|
180
|
+
- `/.well-known/jwks.json` (only when `WebIdentity` is configured)
|
|
181
|
+
|
|
182
|
+
The middleware never gates access on its own; it just populates
|
|
183
|
+
`request.user` / `request.auth`. Routes you do not decorate stay public.
|