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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ .env
12
+ .venv/
@@ -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,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include src/bearerbridge py.typed
@@ -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")