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.
Files changed (30) hide show
  1. keycardai_starlette-0.2.0/.gitignore +193 -0
  2. keycardai_starlette-0.2.0/CHANGELOG.md +223 -0
  3. keycardai_starlette-0.2.0/PKG-INFO +183 -0
  4. keycardai_starlette-0.2.0/README.md +148 -0
  5. keycardai_starlette-0.2.0/examples/protected_resource_server/.env.example +4 -0
  6. keycardai_starlette-0.2.0/examples/protected_resource_server/.gitignore +4 -0
  7. keycardai_starlette-0.2.0/examples/protected_resource_server/.python-version +1 -0
  8. keycardai_starlette-0.2.0/examples/protected_resource_server/README.md +66 -0
  9. keycardai_starlette-0.2.0/examples/protected_resource_server/keycard.toml +6 -0
  10. keycardai_starlette-0.2.0/examples/protected_resource_server/main.py +78 -0
  11. keycardai_starlette-0.2.0/examples/protected_resource_server/pyproject.toml +18 -0
  12. keycardai_starlette-0.2.0/examples/protected_resource_server/uv.lock +571 -0
  13. keycardai_starlette-0.2.0/pyproject.toml +125 -0
  14. keycardai_starlette-0.2.0/src/keycardai/starlette/__init__.py +77 -0
  15. keycardai_starlette-0.2.0/src/keycardai/starlette/authorization.py +282 -0
  16. keycardai_starlette-0.2.0/src/keycardai/starlette/handlers/__init__.py +11 -0
  17. keycardai_starlette-0.2.0/src/keycardai/starlette/handlers/jwks.py +27 -0
  18. keycardai_starlette-0.2.0/src/keycardai/starlette/handlers/metadata.py +200 -0
  19. keycardai_starlette-0.2.0/src/keycardai/starlette/middleware/__init__.py +19 -0
  20. keycardai_starlette-0.2.0/src/keycardai/starlette/middleware/bearer.py +382 -0
  21. keycardai_starlette-0.2.0/src/keycardai/starlette/provider.py +344 -0
  22. keycardai_starlette-0.2.0/src/keycardai/starlette/routers/__init__.py +13 -0
  23. keycardai_starlette-0.2.0/src/keycardai/starlette/routers/metadata.py +212 -0
  24. keycardai_starlette-0.2.0/src/keycardai/starlette/shared/__init__.py +3 -0
  25. keycardai_starlette-0.2.0/src/keycardai/starlette/shared/starlette.py +23 -0
  26. keycardai_starlette-0.2.0/tests/__init__.py +0 -0
  27. keycardai_starlette-0.2.0/tests/keycardai/__init__.py +0 -0
  28. keycardai_starlette-0.2.0/tests/keycardai/starlette/__init__.py +0 -0
  29. keycardai_starlette-0.2.0/tests/keycardai/starlette/test_provider.py +512 -0
  30. 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.