doorin 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.
Files changed (43) hide show
  1. doorin-0.1.0/.gitignore +162 -0
  2. doorin-0.1.0/PKG-INFO +20 -0
  3. doorin-0.1.0/README.md +0 -0
  4. doorin-0.1.0/addons/__init__.py +0 -0
  5. doorin-0.1.0/app/doorin.yaml +32 -0
  6. doorin-0.1.0/app/services/placeholder.yaml +55 -0
  7. doorin-0.1.0/doorin/__init__.py +17 -0
  8. doorin-0.1.0/doorin/addons/__init__.py +0 -0
  9. doorin-0.1.0/doorin/addons/base/__init__.py +7 -0
  10. doorin-0.1.0/doorin/addons/base/auth/__init__.py +0 -0
  11. doorin-0.1.0/doorin/addons/base/auth/api_key.py +19 -0
  12. doorin-0.1.0/doorin/addons/base/middlewares/__init__.py +0 -0
  13. doorin-0.1.0/doorin/addons/base/middlewares/gateway.py +66 -0
  14. doorin-0.1.0/doorin/addons/base/transformers/__init__.py +0 -0
  15. doorin-0.1.0/doorin/addons/base/transformers/request_transformers.py +40 -0
  16. doorin-0.1.0/doorin/addons/oidc/__init__.py +12 -0
  17. doorin-0.1.0/doorin/addons/oidc/auth/__init__.py +1 -0
  18. doorin-0.1.0/doorin/addons/oidc/auth/bearer.py +176 -0
  19. doorin-0.1.0/doorin/addons/oidc/auth/web.py +154 -0
  20. doorin-0.1.0/doorin/addons/oidc/middlewares/__init__.py +1 -0
  21. doorin-0.1.0/doorin/addons/rate_limit/__init__.py +7 -0
  22. doorin-0.1.0/doorin/addons/rate_limit/sliding_window.py +41 -0
  23. doorin-0.1.0/doorin/app.py +138 -0
  24. doorin-0.1.0/doorin/cli.py +141 -0
  25. doorin-0.1.0/doorin/core/__init__.py +2 -0
  26. doorin-0.1.0/doorin/core/addon_manager.py +141 -0
  27. doorin-0.1.0/doorin/core/banner.py +48 -0
  28. doorin-0.1.0/doorin/core/exceptions.py +23 -0
  29. doorin-0.1.0/doorin/core/lifecycle.py +47 -0
  30. doorin-0.1.0/doorin/core/loader.py +107 -0
  31. doorin-0.1.0/doorin/core/logger.py +11 -0
  32. doorin-0.1.0/doorin/core/models.py +45 -0
  33. doorin-0.1.0/doorin/core/proxy.py +133 -0
  34. doorin-0.1.0/doorin/core/registry.py +77 -0
  35. doorin-0.1.0/doorin/core/requests.py +8 -0
  36. doorin-0.1.0/doorin/core/responses.py +26 -0
  37. doorin-0.1.0/doorin/core/shutdown.py +35 -0
  38. doorin-0.1.0/doorin/core/storage.py +181 -0
  39. doorin-0.1.0/doorin/core/transformers.py +47 -0
  40. doorin-0.1.0/doorin/core/types.py +44 -0
  41. doorin-0.1.0/doorin/main.py +21 -0
  42. doorin-0.1.0/doorin/settings.py +46 -0
  43. doorin-0.1.0/pyproject.toml +30 -0
@@ -0,0 +1,162 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+ *.http
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ pip-wheel-metadata/
25
+ share/python-wheels/
26
+ *.egg-info/
27
+ .installed.cfg
28
+ *.egg
29
+ MANIFEST
30
+
31
+ # PyInstaller
32
+ # Usually these files are written by a python script from a template
33
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py,cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+
55
+ # Translations
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+ *.sqlite3
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
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
+ .python-version
87
+
88
+ # pipenv
89
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
91
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
92
+ # install all needed dependencies.
93
+ #Pipfile.lock
94
+
95
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96
+ __pypackages__/
97
+
98
+ # SageMath parsed files
99
+ *.sage.py
100
+
101
+ # Environments
102
+ .env
103
+ .venv
104
+ .venv-speech/
105
+ .venv-cli/
106
+ .venv-toolbox/
107
+ .venv-rag/
108
+ .venv-agents/
109
+ .venv-sdk/
110
+ env/
111
+ venv/
112
+ ENV/
113
+ env.bak/
114
+ venv.bak/
115
+ staticfiles/
116
+ mediafiles/
117
+
118
+ # Spyder project settings
119
+ .spyderproject
120
+ .spyproject
121
+
122
+ # Rope project settings
123
+ .ropeproject
124
+
125
+ # mkdocs documentation
126
+ /site
127
+
128
+ # mypy
129
+ .mypy_cache/
130
+ .dmypy.json
131
+ dmypy.json
132
+
133
+ # Pyre type checker
134
+ .pyre/
135
+
136
+ # EDITOR
137
+ .idea
138
+
139
+ # NODEJS
140
+ node_modules/
141
+
142
+ # LOCAL ONLY
143
+ localonly/
144
+
145
+ # Whoosh index
146
+ whoosh/
147
+ whoosh_index/
148
+ .whoosh/
149
+ .whoosh_index/
150
+
151
+ export/
152
+ .export/
153
+
154
+ *.identifier
155
+ staticfiles/
156
+ redis/
157
+ minio/
158
+ postgresql/
159
+ notebooks/
160
+ volumes/
161
+ .artifacts/
162
+ .env
doorin-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: doorin
3
+ Version: 0.1.0
4
+ Summary: Extensible API Gateway Core
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: authlib>=1.3.0
7
+ Requires-Dist: httpx[http2]>=0.27.0
8
+ Requires-Dist: itsdangerous>=2.2.0
9
+ Requires-Dist: pydantic-settings>=2.2.1
10
+ Requires-Dist: pydantic>=2.7.0
11
+ Requires-Dist: pyfiglet>=1.0.4
12
+ Requires-Dist: python-dotenv>=1.0.1
13
+ Requires-Dist: python-hookups>=0.1.0
14
+ Requires-Dist: python-jose[cryptography]>=3.3.0
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: redis[hiredis]>=8.0.0
17
+ Requires-Dist: rich>=15.0.0
18
+ Requires-Dist: starlette>=1.3.1
19
+ Requires-Dist: typer>=0.26.7
20
+ Requires-Dist: uvicorn>=0.27.0
doorin-0.1.0/README.md ADDED
File without changes
File without changes
@@ -0,0 +1,32 @@
1
+ components:
2
+ transformers:
3
+ - type: add_request_header
4
+ enabled: true
5
+ - type: add_query_param
6
+ enabled: true
7
+ - type: modify_response_status
8
+ enabled: false
9
+ auth_providers:
10
+ - name: api_key
11
+ enabled: true
12
+ rate_limiters:
13
+ - name: sliding_window
14
+ enabled: true
15
+
16
+ services:
17
+ example:
18
+ base_url: "http://httpbin.org"
19
+ default_require_api_key: false
20
+ default_rate_limit: 30
21
+ routes:
22
+ - path: "/get"
23
+ methods: ["GET"]
24
+ transformers:
25
+ - type: add_request_header
26
+ order: 10
27
+ config:
28
+ name: "X-Gateway"
29
+ value: "Doorin"
30
+
31
+ api_keys:
32
+ - "test-key-123"
@@ -0,0 +1,55 @@
1
+ # JSONPlaceholder Service Configuration
2
+ # Documentation: https://jsonplaceholder.typicode.com/
3
+
4
+ transformers:
5
+ # Adds a custom header to the request before forwarding to JSONPlaceholder
6
+ doorin_identity:
7
+ type: "add_request_header"
8
+ order: 1
9
+ config:
10
+ name: "X-Gateway"
11
+ value: "Doorin"
12
+
13
+ # Adds a custom query parameter to every request
14
+ # Example: GET /posts -> GET /posts?source=doorin_gateway
15
+ source_tag:
16
+ type: "add_query_param"
17
+ order: 2
18
+ config:
19
+ name: "source"
20
+ value: "doorin_gateway"
21
+
22
+ services:
23
+ jsonplaceholder:
24
+ base_url: "https://jsonplaceholder.typicode.com"
25
+ default_require_api_key: false
26
+ default_rate_limit: 100
27
+ routes:
28
+ - path: "/posts"
29
+ strip_prefix: false
30
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"]
31
+ transformers: ["doorin_identity", "source_tag"]
32
+
33
+ - path: "/comments"
34
+ strip_prefix: false
35
+ methods: ["GET"]
36
+ transformers: ["doorin_identity"]
37
+
38
+ - path: "/albums"
39
+ strip_prefix: false
40
+ methods: ["GET"]
41
+
42
+ - path: "/photos"
43
+ strip_prefix: false
44
+ methods: ["GET"]
45
+
46
+ - path: "/todos"
47
+ strip_prefix: false
48
+ methods: ["GET", "PATCH"]
49
+
50
+ - path: "/users"
51
+ strip_prefix: false
52
+ methods: ["GET"]
53
+
54
+ api_keys:
55
+ - "sk-dev-placeholder-12345"
@@ -0,0 +1,17 @@
1
+ """Doorin – Extensible API Gateway"""
2
+
3
+ from doorin.core.models import Addon
4
+ from doorin.core.registry import register_manifest
5
+ from doorin.core.storage import storage, init_redis, close_redis
6
+
7
+ def register_addon(config: dict = None, **kwargs):
8
+ """
9
+ Registers an addon manifest. Can be called with a dict or keyword arguments.
10
+ """
11
+ if config:
12
+ # If a dict is passed, merge with kwargs
13
+ kwargs.update(config)
14
+
15
+ addon = Addon(**kwargs)
16
+ register_manifest(addon)
17
+ return addon
File without changes
@@ -0,0 +1,7 @@
1
+ __manifest__ = {
2
+ "name": "base",
3
+ "version": "1.0.0",
4
+ "description": "Base addon with standard transformers, auth providers, and gateway middleware",
5
+ "libraries": [],
6
+ "dependencies": []
7
+ }
File without changes
@@ -0,0 +1,19 @@
1
+ from doorin.core.responses import JSONResponse
2
+ from doorin.core.types import AuthProvider
3
+ from doorin.core.storage import storage
4
+ from doorin.core.registry import register_auth_provider
5
+
6
+ class ApiKeyAuthProvider(AuthProvider):
7
+ async def authenticate(self, request, config):
8
+ api_key = request.headers.get("X-API-Key")
9
+ if not api_key:
10
+ return JSONResponse({"error": "Missing API key"}, status_code=401)
11
+
12
+ valid = await storage.is_api_key_valid(api_key)
13
+ if not valid:
14
+ return JSONResponse({"error": "Invalid API key"}, status_code=401)
15
+ return None
16
+
17
+ # The AddonManager handles registration based on class inheritance,
18
+ # but we keep this for manual imports if needed.
19
+ register_auth_provider("api_key", ApiKeyAuthProvider)
@@ -0,0 +1,66 @@
1
+ from doorin.core.requests import Request
2
+ from doorin.core.responses import JSONResponse
3
+ from doorin.core.registry import (
4
+ get_auth_provider, get_rate_limiter, is_component_enabled, register_middleware
5
+ )
6
+ from doorin.core.types import GatewayMiddleware
7
+ from doorin.core.storage import storage
8
+ from doorin.core.models import RouteConfig, ServiceConfig
9
+ from doorin.core.proxy import load_route, forward_request
10
+
11
+ class MainGatewayMiddleware(GatewayMiddleware):
12
+ async def dispatch(self, request: Request, call_next):
13
+ path = request.url.path
14
+ route_info = await load_route(path)
15
+ if not route_info:
16
+ return await call_next(request)
17
+
18
+ route: RouteConfig = route_info["route"]
19
+ service: ServiceConfig = route_info["service"]
20
+ req_trans = route_info["request_transformers"]
21
+ resp_trans = route_info["response_transformers"]
22
+ route_id = route_info["route_id"]
23
+
24
+ if request.method not in route.methods:
25
+ return JSONResponse({"error": "Method not allowed"}, status_code=405)
26
+
27
+ # Authentication
28
+ auth_name = route.auth_provider or service.default_auth_provider
29
+ if is_component_enabled("auth_providers", auth_name):
30
+ auth_func = get_auth_provider(auth_name)
31
+ if auth_func:
32
+ auth_resp = await auth_func(request, route.auth_config)
33
+ if auth_resp:
34
+ return auth_resp
35
+ elif route.require_api_key if route.require_api_key is not None else service.default_require_api_key:
36
+ key = request.headers.get("X-API-Key")
37
+ if not await storage.is_api_key_valid(key):
38
+ return JSONResponse({"error": "Invalid API key"}, 401)
39
+
40
+ # Rate limiting
41
+ limit = route.rate_limit or service.default_rate_limit
42
+ if limit and limit > 0:
43
+ limiter_name = route.rate_limiter or service.default_rate_limiter
44
+ if is_component_enabled("rate_limiters", limiter_name):
45
+ LimiterClass = get_rate_limiter(limiter_name)
46
+ if LimiterClass:
47
+ limiter = LimiterClass()
48
+ client_ip = request.client.host
49
+ key = storage.rate_limit_key(route_id, client_ip)
50
+ if await limiter.is_limited(key, limit):
51
+ return JSONResponse({"error": "Rate limit exceeded"}, 429)
52
+
53
+ # Forward
54
+ if route.strip_prefix:
55
+ remaining = path[len(route_info["path_prefix"]):]
56
+ else:
57
+ remaining = path
58
+
59
+ if not remaining.startswith("/"):
60
+ remaining = "/" + remaining
61
+
62
+ response = await forward_request(request, service.base_url, remaining, req_trans, resp_trans)
63
+ return response
64
+
65
+
66
+ register_middleware(MainGatewayMiddleware)
@@ -0,0 +1,40 @@
1
+ from doorin.core.requests import Request
2
+ from doorin.core.types import RequestTransformer, ResponseTransformer
3
+ from doorin.core.registry import register_transformer, register_response_transformer
4
+
5
+ class AddHeaderTransformer(RequestTransformer):
6
+ async def transform(self, request: Request, config: dict) -> Request:
7
+ if not hasattr(request.state, "headers_to_add"):
8
+ request.state.headers_to_add = []
9
+ request.state.headers_to_add.append((config["name"], config["value"]))
10
+ return request
11
+
12
+ class RemoveHeaderTransformer(RequestTransformer):
13
+ async def transform(self, request: Request, config: dict) -> Request:
14
+ if not hasattr(request.state, "headers_to_remove"):
15
+ request.state.headers_to_remove = []
16
+ request.state.headers_to_remove.append(config["name"])
17
+ return request
18
+
19
+ class AddQueryParamTransformer(RequestTransformer):
20
+ async def transform(self, request: Request, config: dict) -> Request:
21
+ if not hasattr(request.state, "query_params_to_add"):
22
+ request.state.query_params_to_add = []
23
+ request.state.query_params_to_add.append((config["name"], config["value"]))
24
+ return request
25
+
26
+ class ModifyResponseStatusTransformer(ResponseTransformer):
27
+ async def transform(self, response, config):
28
+ response.status_code = config["status"]
29
+ return response
30
+
31
+ class AddResponseHeaderTransformer(ResponseTransformer):
32
+ async def transform(self, response, config):
33
+ response.headers[config["name"]] = config["value"]
34
+ return response
35
+
36
+ register_transformer("add_request_header", AddHeaderTransformer)
37
+ register_transformer("remove_request_header", RemoveHeaderTransformer)
38
+ register_transformer("add_query_param", AddQueryParamTransformer)
39
+ register_response_transformer("modify_response_status", ModifyResponseStatusTransformer)
40
+ register_response_transformer("add_response_header", AddResponseHeaderTransformer)
@@ -0,0 +1,12 @@
1
+ __manifest__ = {
2
+ "name": "oidc",
3
+ "version": "1.0.0",
4
+ "description": "OpenID Connect addon for Doorin",
5
+ "libraries": [
6
+ "authlib>=1.3.0",
7
+ "itsdangerous",
8
+ "httpx>=0.27.0",
9
+ "python-jose[cryptography]>=3.3.0"
10
+ ],
11
+ "dependencies": ["base"]
12
+ }
@@ -0,0 +1 @@
1
+ # OIDC Auth Providers
@@ -0,0 +1,176 @@
1
+ """
2
+ OIDC Bearer token validation using JWKS and optional introspection.
3
+ """
4
+
5
+ import json
6
+ import time
7
+ import httpx
8
+ from urllib.request import urlopen
9
+ from jose import jwt
10
+ from jose.exceptions import JWTError, ExpiredSignatureError
11
+
12
+ from doorin.core.responses import JSONResponse
13
+ from doorin.core.logger import get_logger
14
+ from doorin.core.types import AuthProvider
15
+ from doorin.core.storage import storage
16
+ from doorin.core.registry import register_auth_provider
17
+
18
+ logger = get_logger("oidc.bearer")
19
+
20
+ # Cache for JWKS (shared across instances)
21
+ _jwks_cache = {}
22
+ _jwks_cache_time = {}
23
+
24
+ def get_jwks_client(issuer_url: str, cache_ttl: int = 3600):
25
+ now = time.time()
26
+ if issuer_url in _jwks_cache and now - _jwks_cache_time.get(issuer_url, 0) < cache_ttl:
27
+ return _jwks_cache[issuer_url]
28
+ jwks_url = f"{issuer_url.rstrip('/')}/protocol/openid-connect/certs"
29
+ try:
30
+ with urlopen(jwks_url) as resp:
31
+ jwks = json.loads(resp.read())
32
+ _jwks_cache[issuer_url] = jwks
33
+ _jwks_cache_time[issuer_url] = now
34
+ return jwks
35
+ except Exception as e:
36
+ logger.error(f"Failed to fetch JWKS from {jwks_url}: {e}")
37
+ return None
38
+
39
+
40
+ class OIDCBearerAuthProvider(AuthProvider):
41
+ async def authenticate(self, request, config: dict):
42
+ auth_header = request.headers.get("Authorization")
43
+ if not auth_header or not auth_header.startswith("Bearer "):
44
+ if config.get("optional", False):
45
+ return None
46
+ return JSONResponse({"error": "Missing or invalid Authorization header"}, status_code=401)
47
+
48
+ token = auth_header.split(" ")[1]
49
+ issuer = config.get("issuer_url")
50
+ audience = config.get("audience")
51
+
52
+ if not issuer or not audience:
53
+ logger.error("OIDC config missing issuer_url or audience")
54
+ return JSONResponse({"error": "Provider misconfigured"}, status_code=500)
55
+
56
+ # Choose validation method
57
+ if config.get("use_introspection", False):
58
+ claims = await self._introspect_token(token, config)
59
+ else:
60
+ claims = await self._validate_jwt(token, issuer, audience, config)
61
+
62
+ if claims is None:
63
+ return JSONResponse({"error": "Invalid token"}, status_code=401)
64
+
65
+ # Store user info in request state
66
+ request.state.user = claims.get("sub")
67
+ request.state.email = claims.get("email")
68
+ request.state.claims = claims
69
+
70
+ # Role extraction
71
+ role_claim_path = config.get("role_claim", "realm_access.roles")
72
+ roles = claims
73
+ for part in role_claim_path.split("."):
74
+ if isinstance(roles, dict):
75
+ roles = roles.get(part, {})
76
+ else:
77
+ roles = {}
78
+ break
79
+ request.state.roles = roles if isinstance(roles, list) else [roles] if roles else []
80
+
81
+ # Required roles
82
+ required_roles = config.get("required_roles", [])
83
+ if required_roles:
84
+ missing = [r for r in required_roles if r not in request.state.roles]
85
+ if missing:
86
+ return JSONResponse({"error": f"Missing roles: {', '.join(missing)}"}, status_code=403)
87
+
88
+ # Required scopes
89
+ required_scopes = config.get("required_scopes", [])
90
+ if required_scopes:
91
+ token_scopes = claims.get("scope", "").split()
92
+ missing = [s for s in required_scopes if s not in token_scopes]
93
+ if missing:
94
+ return JSONResponse({"error": f"Missing scopes: {', '.join(missing)}"}, status_code=403)
95
+
96
+ return None
97
+
98
+ async def _validate_jwt(self, token, issuer, audience, config):
99
+ jwks = get_jwks_client(issuer, config.get("cache_ttl", 3600))
100
+ if not jwks:
101
+ return None
102
+ try:
103
+ unverified_header = jwt.get_unverified_header(token)
104
+ kid = unverified_header.get("kid")
105
+ if not kid:
106
+ raise JWTError("No kid in token header")
107
+ key = None
108
+ for k in jwks.get("keys", []):
109
+ if k.get("kid") == kid:
110
+ key = k
111
+ break
112
+ if not key:
113
+ raise JWTError("No matching key found")
114
+ payload = jwt.decode(
115
+ token,
116
+ key,
117
+ algorithms=["RS256", "RS384", "RS512"],
118
+ audience=audience,
119
+ issuer=issuer,
120
+ options={"verify_aud": True, "verify_iss": True}
121
+ )
122
+ return payload
123
+ except ExpiredSignatureError:
124
+ logger.debug("Token expired")
125
+ return None
126
+ except JWTError as e:
127
+ logger.debug(f"JWT validation failed: {e}")
128
+ return None
129
+
130
+ async def _introspect_token(self, token, config):
131
+ discovery = await self._get_discovery(config["issuer_url"])
132
+ if not discovery:
133
+ return None
134
+ introspect_url = discovery.get("introspection_endpoint")
135
+ if not introspect_url:
136
+ logger.error("No introspection endpoint in discovery document")
137
+ return None
138
+ client_id = config.get("client_id")
139
+ client_secret = config.get("client_secret")
140
+ if not client_id or not client_secret:
141
+ logger.error("Introspection requires client_id and client_secret")
142
+ return None
143
+ async with httpx.AsyncClient(timeout=10.0) as http_client:
144
+ resp = await http_client.post(
145
+ introspect_url,
146
+ data={"token": token},
147
+ auth=(client_id, client_secret),
148
+ )
149
+ if resp.status_code != 200:
150
+ return None
151
+ data = resp.json()
152
+ if not data.get("active"):
153
+ return None
154
+ return data
155
+
156
+ async def _get_discovery(self, issuer_url):
157
+ cache_key = f"oidc_discovery:{issuer_url}"
158
+ if storage.client:
159
+ cached = await storage.client.get(cache_key)
160
+ if cached:
161
+ return json.loads(cached)
162
+ discovery_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
163
+ async with httpx.AsyncClient(timeout=10.0) as http_client:
164
+ try:
165
+ resp = await http_client.get(discovery_url)
166
+ resp.raise_for_status()
167
+ data = resp.json()
168
+ if storage.client:
169
+ await storage.client.setex(cache_key, 3600, json.dumps(data))
170
+ return data
171
+ except Exception as e:
172
+ logger.error(f"Discovery failed: {e}")
173
+ return None
174
+
175
+ # Register the auth provider
176
+ register_auth_provider("oidc_bearer", OIDCBearerAuthProvider)