bearerbridge 0.1.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.
- bearerbridge-0.1.0/.gitignore +12 -0
- bearerbridge-0.1.0/LICENSE +21 -0
- bearerbridge-0.1.0/MANIFEST.in +3 -0
- bearerbridge-0.1.0/PKG-INFO +228 -0
- bearerbridge-0.1.0/README.md +194 -0
- bearerbridge-0.1.0/pyproject.toml +55 -0
- bearerbridge-0.1.0/src/bearerbridge/__init__.py +13 -0
- bearerbridge-0.1.0/src/bearerbridge/config.py +26 -0
- bearerbridge-0.1.0/src/bearerbridge/dependencies.py +75 -0
- bearerbridge-0.1.0/src/bearerbridge/errors.py +10 -0
- bearerbridge-0.1.0/src/bearerbridge/jwks.py +106 -0
- bearerbridge-0.1.0/src/bearerbridge/py.typed +0 -0
- bearerbridge-0.1.0/tests/test_forwarding.py +31 -0
- bearerbridge-0.1.0/tests/test_settings.py +10 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BearerBridge Contributors
|
|
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,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bearerbridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI helpers for Supabase/JWKS bearer JWT validation and service-to-service auth forwarding.
|
|
5
|
+
Author: BearerBridge Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: auth,bearer,fastapi,jwks,jwt,service-to-service,supabase
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: fastapi>=0.110
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: python-jose[cryptography]>=3.3
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# BearerBridge
|
|
36
|
+
|
|
37
|
+
BearerBridge is a small FastAPI auth helper for teams that pass user bearer tokens across multiple services.
|
|
38
|
+
It validates Supabase/JWKS access tokens at each service boundary and adds a simple internal service key check for service-to-service calls.
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
frontend -> core API -> agent API -> MCP API -> core API
|
|
42
|
+
JWT + internal key all the way across trusted service hops
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
BearerBridge does not generate users, store secrets, or replace your identity provider. It helps your APIs consistently answer two questions:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
Who is the user? Authorization: Bearer <user jwt>
|
|
49
|
+
Is this our service? X-Internal-Service-Key: <shared service secret>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- Validate bearer JWTs from Supabase or any JWKS-compatible issuer.
|
|
55
|
+
- Verify issuer, audience, expiration, signature, and allowed algorithms.
|
|
56
|
+
- Cache JWKS responses with automatic refresh when a new `kid` appears.
|
|
57
|
+
- FastAPI dependencies for user JWT auth, internal service auth, or both together.
|
|
58
|
+
- Constant-time internal service key comparison using `secrets.compare_digest`.
|
|
59
|
+
- Header forwarding helper for chained service calls.
|
|
60
|
+
- Framework-light core with only `fastapi`, `httpx`, and `python-jose` runtime dependencies.
|
|
61
|
+
- MIT licensed and safe for public reuse; secrets stay in environment variables, not in the package.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install bearerbridge
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For local development from this repo:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e .[dev]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from typing import Any
|
|
79
|
+
|
|
80
|
+
from fastapi import Depends, FastAPI, Request
|
|
81
|
+
from bearerbridge import BearerBridge, BridgeSettings
|
|
82
|
+
|
|
83
|
+
bridge = BearerBridge(
|
|
84
|
+
BridgeSettings(
|
|
85
|
+
jwks_url="https://PROJECT_REF.supabase.co/auth/v1/.well-known/jwks.json",
|
|
86
|
+
issuer="https://PROJECT_REF.supabase.co/auth/v1",
|
|
87
|
+
audience="authenticated",
|
|
88
|
+
algorithms=("ES256", "RS256"),
|
|
89
|
+
internal_service_key="use-a-long-random-secret-from-env",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
app = FastAPI()
|
|
94
|
+
|
|
95
|
+
@app.get("/me")
|
|
96
|
+
async def me(claims: dict[str, Any] = Depends(bridge.require_user)):
|
|
97
|
+
return {"sub": claims.get("sub"), "email": claims.get("email")}
|
|
98
|
+
|
|
99
|
+
@app.post("/internal/run")
|
|
100
|
+
async def run_internal(
|
|
101
|
+
claims: dict[str, Any] = Depends(bridge.require_user_and_internal_service),
|
|
102
|
+
):
|
|
103
|
+
return {"ok": True, "user_id": claims.get("sub")}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Environment Example
|
|
107
|
+
|
|
108
|
+
BearerBridge intentionally does not read your environment by itself. Your app should load settings however it already does, then pass them into `BridgeSettings`.
|
|
109
|
+
|
|
110
|
+
```env
|
|
111
|
+
JWKS_URL=https://PROJECT_REF.supabase.co/auth/v1/.well-known/jwks.json
|
|
112
|
+
JWKS_ISS=https://PROJECT_REF.supabase.co/auth/v1
|
|
113
|
+
JWKS_AUD=authenticated
|
|
114
|
+
JWKS_ALG=ES256,RS256
|
|
115
|
+
INTERNAL_SERVICE_KEY=generate-a-long-random-secret
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Generate a service key:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Use the same `INTERNAL_SERVICE_KEY` in services that are allowed to call each other. Do not expose it to browsers.
|
|
125
|
+
|
|
126
|
+
## Forwarding Auth Headers
|
|
127
|
+
|
|
128
|
+
When one service calls the next service, forward the user JWT and add your internal service key:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
import httpx
|
|
132
|
+
from fastapi import Request
|
|
133
|
+
from bearerbridge import BearerBridge, BridgeSettings
|
|
134
|
+
|
|
135
|
+
bridge = BearerBridge(BridgeSettings(...))
|
|
136
|
+
|
|
137
|
+
async def call_agent(request: Request, payload: dict):
|
|
138
|
+
async with httpx.AsyncClient(timeout=20) as client:
|
|
139
|
+
response = await client.post(
|
|
140
|
+
"https://agent.example.com/run_tool",
|
|
141
|
+
json=payload,
|
|
142
|
+
headers=bridge.forward_headers(request.headers),
|
|
143
|
+
)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
return response.json()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`forward_headers` copies the inbound `Authorization` header and adds `X-Internal-Service-Key` when configured.
|
|
149
|
+
|
|
150
|
+
## Common Service Chain
|
|
151
|
+
|
|
152
|
+
For a public multi-service chain, validate at every public service boundary:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
React app
|
|
156
|
+
-> Core API: validates user JWT
|
|
157
|
+
-> Agent API: validates user JWT + internal service key
|
|
158
|
+
-> MCP API: validates user JWT + internal service key
|
|
159
|
+
-> Core API: validates user JWT + internal service key for internal callbacks
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Public health endpoints can stay unauthenticated for load balancers. Business endpoints should require at least the user JWT, and service-only endpoints should require both JWT and internal service key.
|
|
163
|
+
|
|
164
|
+
## API
|
|
165
|
+
|
|
166
|
+
### `BridgeSettings`
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
BridgeSettings(
|
|
170
|
+
jwks_url: str,
|
|
171
|
+
issuer: str,
|
|
172
|
+
audience: str | tuple[str, ...] = "authenticated",
|
|
173
|
+
algorithms: tuple[str, ...] = ("ES256", "RS256"),
|
|
174
|
+
jwks_ttl_seconds: int = 36000,
|
|
175
|
+
internal_service_key: str | None = None,
|
|
176
|
+
internal_header_name: str = "X-Internal-Service-Key",
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `BearerBridge`
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
await bridge.decode_token(token)
|
|
184
|
+
await bridge.require_user(...)
|
|
185
|
+
await bridge.require_internal_service(...)
|
|
186
|
+
await bridge.require_user_and_internal_service(...)
|
|
187
|
+
bridge.forward_headers(inbound_headers)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Security Notes
|
|
191
|
+
|
|
192
|
+
- Never put `INTERNAL_SERVICE_KEY` in frontend code.
|
|
193
|
+
- Prefer HTTPS everywhere.
|
|
194
|
+
- Use a long random service key and rotate it when team access changes.
|
|
195
|
+
- Keep JWT validation enabled in each separately reachable service.
|
|
196
|
+
- Do not trust decoded JWT claims unless signature, issuer, audience, algorithm, and expiry were verified.
|
|
197
|
+
- BearerBridge validates authentication; your app still owns authorization decisions such as tenant access, roles, and row-level security.
|
|
198
|
+
|
|
199
|
+
## Publishing
|
|
200
|
+
|
|
201
|
+
Build:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
python -m build
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Check:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
python -m twine check dist/*
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Publish to TestPyPI first:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
python -m twine upload --repository testpypi dist/*
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Publish to PyPI:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
python -m twine upload dist/*
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT
|
|
228
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# BearerBridge
|
|
2
|
+
|
|
3
|
+
BearerBridge is a small FastAPI auth helper for teams that pass user bearer tokens across multiple services.
|
|
4
|
+
It validates Supabase/JWKS access tokens at each service boundary and adds a simple internal service key check for service-to-service calls.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
frontend -> core API -> agent API -> MCP API -> core API
|
|
8
|
+
JWT + internal key all the way across trusted service hops
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
BearerBridge does not generate users, store secrets, or replace your identity provider. It helps your APIs consistently answer two questions:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
Who is the user? Authorization: Bearer <user jwt>
|
|
15
|
+
Is this our service? X-Internal-Service-Key: <shared service secret>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Validate bearer JWTs from Supabase or any JWKS-compatible issuer.
|
|
21
|
+
- Verify issuer, audience, expiration, signature, and allowed algorithms.
|
|
22
|
+
- Cache JWKS responses with automatic refresh when a new `kid` appears.
|
|
23
|
+
- FastAPI dependencies for user JWT auth, internal service auth, or both together.
|
|
24
|
+
- Constant-time internal service key comparison using `secrets.compare_digest`.
|
|
25
|
+
- Header forwarding helper for chained service calls.
|
|
26
|
+
- Framework-light core with only `fastapi`, `httpx`, and `python-jose` runtime dependencies.
|
|
27
|
+
- MIT licensed and safe for public reuse; secrets stay in environment variables, not in the package.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install bearerbridge
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For local development from this repo:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install -e .[dev]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from fastapi import Depends, FastAPI, Request
|
|
47
|
+
from bearerbridge import BearerBridge, BridgeSettings
|
|
48
|
+
|
|
49
|
+
bridge = BearerBridge(
|
|
50
|
+
BridgeSettings(
|
|
51
|
+
jwks_url="https://PROJECT_REF.supabase.co/auth/v1/.well-known/jwks.json",
|
|
52
|
+
issuer="https://PROJECT_REF.supabase.co/auth/v1",
|
|
53
|
+
audience="authenticated",
|
|
54
|
+
algorithms=("ES256", "RS256"),
|
|
55
|
+
internal_service_key="use-a-long-random-secret-from-env",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
app = FastAPI()
|
|
60
|
+
|
|
61
|
+
@app.get("/me")
|
|
62
|
+
async def me(claims: dict[str, Any] = Depends(bridge.require_user)):
|
|
63
|
+
return {"sub": claims.get("sub"), "email": claims.get("email")}
|
|
64
|
+
|
|
65
|
+
@app.post("/internal/run")
|
|
66
|
+
async def run_internal(
|
|
67
|
+
claims: dict[str, Any] = Depends(bridge.require_user_and_internal_service),
|
|
68
|
+
):
|
|
69
|
+
return {"ok": True, "user_id": claims.get("sub")}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Environment Example
|
|
73
|
+
|
|
74
|
+
BearerBridge intentionally does not read your environment by itself. Your app should load settings however it already does, then pass them into `BridgeSettings`.
|
|
75
|
+
|
|
76
|
+
```env
|
|
77
|
+
JWKS_URL=https://PROJECT_REF.supabase.co/auth/v1/.well-known/jwks.json
|
|
78
|
+
JWKS_ISS=https://PROJECT_REF.supabase.co/auth/v1
|
|
79
|
+
JWKS_AUD=authenticated
|
|
80
|
+
JWKS_ALG=ES256,RS256
|
|
81
|
+
INTERNAL_SERVICE_KEY=generate-a-long-random-secret
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Generate a service key:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use the same `INTERNAL_SERVICE_KEY` in services that are allowed to call each other. Do not expose it to browsers.
|
|
91
|
+
|
|
92
|
+
## Forwarding Auth Headers
|
|
93
|
+
|
|
94
|
+
When one service calls the next service, forward the user JWT and add your internal service key:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import httpx
|
|
98
|
+
from fastapi import Request
|
|
99
|
+
from bearerbridge import BearerBridge, BridgeSettings
|
|
100
|
+
|
|
101
|
+
bridge = BearerBridge(BridgeSettings(...))
|
|
102
|
+
|
|
103
|
+
async def call_agent(request: Request, payload: dict):
|
|
104
|
+
async with httpx.AsyncClient(timeout=20) as client:
|
|
105
|
+
response = await client.post(
|
|
106
|
+
"https://agent.example.com/run_tool",
|
|
107
|
+
json=payload,
|
|
108
|
+
headers=bridge.forward_headers(request.headers),
|
|
109
|
+
)
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
return response.json()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`forward_headers` copies the inbound `Authorization` header and adds `X-Internal-Service-Key` when configured.
|
|
115
|
+
|
|
116
|
+
## Common Service Chain
|
|
117
|
+
|
|
118
|
+
For a public multi-service chain, validate at every public service boundary:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
React app
|
|
122
|
+
-> Core API: validates user JWT
|
|
123
|
+
-> Agent API: validates user JWT + internal service key
|
|
124
|
+
-> MCP API: validates user JWT + internal service key
|
|
125
|
+
-> Core API: validates user JWT + internal service key for internal callbacks
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Public health endpoints can stay unauthenticated for load balancers. Business endpoints should require at least the user JWT, and service-only endpoints should require both JWT and internal service key.
|
|
129
|
+
|
|
130
|
+
## API
|
|
131
|
+
|
|
132
|
+
### `BridgeSettings`
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
BridgeSettings(
|
|
136
|
+
jwks_url: str,
|
|
137
|
+
issuer: str,
|
|
138
|
+
audience: str | tuple[str, ...] = "authenticated",
|
|
139
|
+
algorithms: tuple[str, ...] = ("ES256", "RS256"),
|
|
140
|
+
jwks_ttl_seconds: int = 36000,
|
|
141
|
+
internal_service_key: str | None = None,
|
|
142
|
+
internal_header_name: str = "X-Internal-Service-Key",
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### `BearerBridge`
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
await bridge.decode_token(token)
|
|
150
|
+
await bridge.require_user(...)
|
|
151
|
+
await bridge.require_internal_service(...)
|
|
152
|
+
await bridge.require_user_and_internal_service(...)
|
|
153
|
+
bridge.forward_headers(inbound_headers)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Security Notes
|
|
157
|
+
|
|
158
|
+
- Never put `INTERNAL_SERVICE_KEY` in frontend code.
|
|
159
|
+
- Prefer HTTPS everywhere.
|
|
160
|
+
- Use a long random service key and rotate it when team access changes.
|
|
161
|
+
- Keep JWT validation enabled in each separately reachable service.
|
|
162
|
+
- Do not trust decoded JWT claims unless signature, issuer, audience, algorithm, and expiry were verified.
|
|
163
|
+
- BearerBridge validates authentication; your app still owns authorization decisions such as tenant access, roles, and row-level security.
|
|
164
|
+
|
|
165
|
+
## Publishing
|
|
166
|
+
|
|
167
|
+
Build:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
python -m build
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Check:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
python -m twine check dist/*
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Publish to TestPyPI first:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python -m twine upload --repository testpypi dist/*
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Publish to PyPI:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python -m twine upload dist/*
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
194
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bearerbridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "FastAPI helpers for Supabase/JWKS bearer JWT validation and service-to-service auth forwarding."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "BearerBridge Contributors" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["fastapi", "supabase", "jwt", "jwks", "bearer", "service-to-service", "auth"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: FastAPI",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
"Topic :: Security",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"fastapi>=0.110",
|
|
32
|
+
"httpx>=0.27",
|
|
33
|
+
"python-jose[cryptography]>=3.3",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
test = [
|
|
38
|
+
"pytest>=8.0",
|
|
39
|
+
"pytest-asyncio>=0.23",
|
|
40
|
+
]
|
|
41
|
+
dev = [
|
|
42
|
+
"build>=1.2",
|
|
43
|
+
"twine>=5.0",
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"pytest-asyncio>=0.23",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/bearerbridge"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from bearerbridge.config import BridgeSettings
|
|
2
|
+
from bearerbridge.dependencies import BearerBridge
|
|
3
|
+
from bearerbridge.errors import BearerBridgeError, InternalServiceAuthError, TokenValidationError
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BearerBridge",
|
|
7
|
+
"BearerBridgeError",
|
|
8
|
+
"BridgeSettings",
|
|
9
|
+
"InternalServiceAuthError",
|
|
10
|
+
"TokenValidationError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class BridgeSettings:
|
|
8
|
+
jwks_url: str
|
|
9
|
+
issuer: str
|
|
10
|
+
audience: str | tuple[str, ...] = "authenticated"
|
|
11
|
+
algorithms: tuple[str, ...] = ("ES256", "RS256")
|
|
12
|
+
jwks_ttl_seconds: int = 36000
|
|
13
|
+
internal_service_key: str | None = None
|
|
14
|
+
internal_header_name: str = "X-Internal-Service-Key"
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
if not self.jwks_url:
|
|
18
|
+
raise ValueError("jwks_url is required")
|
|
19
|
+
if not self.issuer:
|
|
20
|
+
raise ValueError("issuer is required")
|
|
21
|
+
if self.jwks_ttl_seconds <= 0:
|
|
22
|
+
raise ValueError("jwks_ttl_seconds must be positive")
|
|
23
|
+
if not self.algorithms:
|
|
24
|
+
raise ValueError("at least one JWT algorithm is required")
|
|
25
|
+
if not self.internal_header_name:
|
|
26
|
+
raise ValueError("internal_header_name is required")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
7
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
8
|
+
from starlette.datastructures import Headers
|
|
9
|
+
|
|
10
|
+
from bearerbridge.config import BridgeSettings
|
|
11
|
+
from bearerbridge.errors import InternalServiceAuthError, TokenValidationError
|
|
12
|
+
from bearerbridge.jwks import JWKSVerifier
|
|
13
|
+
|
|
14
|
+
_security = HTTPBearer(auto_error=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BearerBridge:
|
|
18
|
+
def __init__(self, settings: BridgeSettings) -> None:
|
|
19
|
+
self.settings = settings
|
|
20
|
+
self._verifier = JWKSVerifier(settings)
|
|
21
|
+
|
|
22
|
+
async def decode_token(self, token: str) -> dict[str, Any]:
|
|
23
|
+
return await self._verifier.decode(token)
|
|
24
|
+
|
|
25
|
+
async def require_user(
|
|
26
|
+
self,
|
|
27
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(_security),
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
30
|
+
raise HTTPException(
|
|
31
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
32
|
+
detail="Missing bearer token",
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
return await self.decode_token(credentials.credentials)
|
|
36
|
+
except TokenValidationError as exc:
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
39
|
+
detail=str(exc),
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
async def require_internal_service(self, request: Request) -> None:
|
|
43
|
+
try:
|
|
44
|
+
self.verify_internal_service(request.headers)
|
|
45
|
+
except InternalServiceAuthError as exc:
|
|
46
|
+
raise HTTPException(
|
|
47
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
48
|
+
detail=str(exc),
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
async def require_user_and_internal_service(
|
|
52
|
+
self,
|
|
53
|
+
request: Request,
|
|
54
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(_security),
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
await self.require_internal_service(request)
|
|
57
|
+
return await self.require_user(credentials)
|
|
58
|
+
|
|
59
|
+
def verify_internal_service(self, headers: Mapping[str, str] | Headers) -> None:
|
|
60
|
+
expected = self.settings.internal_service_key
|
|
61
|
+
if not expected:
|
|
62
|
+
raise InternalServiceAuthError("Internal service key is not configured")
|
|
63
|
+
|
|
64
|
+
provided = headers.get(self.settings.internal_header_name)
|
|
65
|
+
if not provided or not secrets.compare_digest(provided, expected):
|
|
66
|
+
raise InternalServiceAuthError("Invalid internal service key")
|
|
67
|
+
|
|
68
|
+
def forward_headers(self, inbound_headers: Mapping[str, str] | Headers) -> dict[str, str]:
|
|
69
|
+
headers: dict[str, str] = {}
|
|
70
|
+
authorization = inbound_headers.get("authorization") or inbound_headers.get("Authorization")
|
|
71
|
+
if authorization:
|
|
72
|
+
headers["Authorization"] = authorization
|
|
73
|
+
if self.settings.internal_service_key:
|
|
74
|
+
headers[self.settings.internal_header_name] = self.settings.internal_service_key
|
|
75
|
+
return headers
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class BearerBridgeError(Exception):
|
|
2
|
+
"""Base package exception."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TokenValidationError(BearerBridgeError):
|
|
6
|
+
"""Raised when bearer JWT validation fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InternalServiceAuthError(BearerBridgeError):
|
|
10
|
+
"""Raised when internal service authentication fails."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from jose import jwk as jose_jwk
|
|
8
|
+
from jose import jwt as jose_jwt
|
|
9
|
+
from jose.exceptions import JWTError
|
|
10
|
+
|
|
11
|
+
from bearerbridge.config import BridgeSettings
|
|
12
|
+
from bearerbridge.errors import TokenValidationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JWKSVerifier:
|
|
16
|
+
def __init__(self, settings: BridgeSettings) -> None:
|
|
17
|
+
self._settings = settings
|
|
18
|
+
self._jwks_cache: dict[str, Any] | None = None
|
|
19
|
+
self._jwks_cache_ts: float | None = None
|
|
20
|
+
|
|
21
|
+
async def decode(self, token: str) -> dict[str, Any]:
|
|
22
|
+
try:
|
|
23
|
+
header = jose_jwt.get_unverified_header(token)
|
|
24
|
+
except JWTError as exc:
|
|
25
|
+
raise TokenValidationError("Invalid token header") from exc
|
|
26
|
+
|
|
27
|
+
kid = header.get("kid")
|
|
28
|
+
jwks = await self._get_jwks_cached()
|
|
29
|
+
key = self._find_jwk(jwks, kid)
|
|
30
|
+
|
|
31
|
+
if key is None and kid:
|
|
32
|
+
jwks = await self._refresh_jwks()
|
|
33
|
+
key = self._find_jwk(jwks, kid)
|
|
34
|
+
|
|
35
|
+
if key is None:
|
|
36
|
+
raise TokenValidationError("Signing key not found")
|
|
37
|
+
|
|
38
|
+
public_key = jose_jwk.construct(key).to_pem().decode("utf-8")
|
|
39
|
+
audiences = (
|
|
40
|
+
self._settings.audience
|
|
41
|
+
if isinstance(self._settings.audience, tuple)
|
|
42
|
+
else (self._settings.audience,)
|
|
43
|
+
)
|
|
44
|
+
last_error: JWTError | None = None
|
|
45
|
+
for audience in audiences:
|
|
46
|
+
try:
|
|
47
|
+
decoded = jose_jwt.decode(
|
|
48
|
+
token,
|
|
49
|
+
key=public_key,
|
|
50
|
+
algorithms=list(self._settings.algorithms),
|
|
51
|
+
audience=audience,
|
|
52
|
+
issuer=self._settings.issuer,
|
|
53
|
+
)
|
|
54
|
+
if not isinstance(decoded, dict):
|
|
55
|
+
raise TokenValidationError("Decoded token payload is not an object")
|
|
56
|
+
return decoded
|
|
57
|
+
except JWTError as exc:
|
|
58
|
+
last_error = exc
|
|
59
|
+
|
|
60
|
+
raise TokenValidationError("Token validation failed") from last_error
|
|
61
|
+
|
|
62
|
+
async def _fetch_jwks(self) -> dict[str, Any]:
|
|
63
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
64
|
+
response = await client.get(self._settings.jwks_url)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
data = response.json()
|
|
67
|
+
|
|
68
|
+
if not isinstance(data, dict) or not isinstance(data.get("keys"), list):
|
|
69
|
+
raise TokenValidationError("Invalid JWKS response")
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
async def _get_jwks_cached(self) -> dict[str, Any]:
|
|
73
|
+
now = time.time()
|
|
74
|
+
if (
|
|
75
|
+
self._jwks_cache is not None
|
|
76
|
+
and self._jwks_cache_ts is not None
|
|
77
|
+
and now - self._jwks_cache_ts < self._settings.jwks_ttl_seconds
|
|
78
|
+
):
|
|
79
|
+
return self._jwks_cache
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return await self._refresh_jwks()
|
|
83
|
+
except httpx.HTTPError as exc:
|
|
84
|
+
if self._jwks_cache is not None:
|
|
85
|
+
return self._jwks_cache
|
|
86
|
+
raise TokenValidationError("Failed to fetch JWKS") from exc
|
|
87
|
+
|
|
88
|
+
async def _refresh_jwks(self) -> dict[str, Any]:
|
|
89
|
+
data = await self._fetch_jwks()
|
|
90
|
+
self._jwks_cache = data
|
|
91
|
+
self._jwks_cache_ts = time.time()
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _find_jwk(jwks: dict[str, Any], kid: str | None) -> dict[str, Any] | None:
|
|
96
|
+
keys = jwks.get("keys", [])
|
|
97
|
+
if not isinstance(keys, list):
|
|
98
|
+
return None
|
|
99
|
+
if kid:
|
|
100
|
+
for key in keys:
|
|
101
|
+
if isinstance(key, dict) and key.get("kid") == kid:
|
|
102
|
+
return key
|
|
103
|
+
if len(keys) == 1 and isinstance(keys[0], dict):
|
|
104
|
+
return keys[0]
|
|
105
|
+
return None
|
|
106
|
+
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from bearerbridge.config import BridgeSettings
|
|
2
|
+
from bearerbridge.dependencies import BearerBridge
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_forward_headers_adds_internal_key_and_preserves_authorization() -> None:
|
|
6
|
+
bridge = BearerBridge(
|
|
7
|
+
BridgeSettings(
|
|
8
|
+
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
|
|
9
|
+
issuer="https://example.supabase.co/auth/v1",
|
|
10
|
+
internal_service_key="secret",
|
|
11
|
+
)
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
headers = bridge.forward_headers({"authorization": "Bearer abc"})
|
|
15
|
+
|
|
16
|
+
assert headers == {
|
|
17
|
+
"Authorization": "Bearer abc",
|
|
18
|
+
"X-Internal-Service-Key": "secret",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_verify_internal_service_accepts_matching_key() -> None:
|
|
23
|
+
bridge = BearerBridge(
|
|
24
|
+
BridgeSettings(
|
|
25
|
+
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
|
|
26
|
+
issuer="https://example.supabase.co/auth/v1",
|
|
27
|
+
internal_service_key="secret",
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
bridge.verify_internal_service({"X-Internal-Service-Key": "secret"})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from bearerbridge.config import BridgeSettings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_settings_requires_jwks_url() -> None:
|
|
5
|
+
try:
|
|
6
|
+
BridgeSettings(jwks_url="", issuer="issuer")
|
|
7
|
+
except ValueError as exc:
|
|
8
|
+
assert "jwks_url" in str(exc)
|
|
9
|
+
else:
|
|
10
|
+
raise AssertionError("expected ValueError")
|