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.
- doorin-0.1.0/.gitignore +162 -0
- doorin-0.1.0/PKG-INFO +20 -0
- doorin-0.1.0/README.md +0 -0
- doorin-0.1.0/addons/__init__.py +0 -0
- doorin-0.1.0/app/doorin.yaml +32 -0
- doorin-0.1.0/app/services/placeholder.yaml +55 -0
- doorin-0.1.0/doorin/__init__.py +17 -0
- doorin-0.1.0/doorin/addons/__init__.py +0 -0
- doorin-0.1.0/doorin/addons/base/__init__.py +7 -0
- doorin-0.1.0/doorin/addons/base/auth/__init__.py +0 -0
- doorin-0.1.0/doorin/addons/base/auth/api_key.py +19 -0
- doorin-0.1.0/doorin/addons/base/middlewares/__init__.py +0 -0
- doorin-0.1.0/doorin/addons/base/middlewares/gateway.py +66 -0
- doorin-0.1.0/doorin/addons/base/transformers/__init__.py +0 -0
- doorin-0.1.0/doorin/addons/base/transformers/request_transformers.py +40 -0
- doorin-0.1.0/doorin/addons/oidc/__init__.py +12 -0
- doorin-0.1.0/doorin/addons/oidc/auth/__init__.py +1 -0
- doorin-0.1.0/doorin/addons/oidc/auth/bearer.py +176 -0
- doorin-0.1.0/doorin/addons/oidc/auth/web.py +154 -0
- doorin-0.1.0/doorin/addons/oidc/middlewares/__init__.py +1 -0
- doorin-0.1.0/doorin/addons/rate_limit/__init__.py +7 -0
- doorin-0.1.0/doorin/addons/rate_limit/sliding_window.py +41 -0
- doorin-0.1.0/doorin/app.py +138 -0
- doorin-0.1.0/doorin/cli.py +141 -0
- doorin-0.1.0/doorin/core/__init__.py +2 -0
- doorin-0.1.0/doorin/core/addon_manager.py +141 -0
- doorin-0.1.0/doorin/core/banner.py +48 -0
- doorin-0.1.0/doorin/core/exceptions.py +23 -0
- doorin-0.1.0/doorin/core/lifecycle.py +47 -0
- doorin-0.1.0/doorin/core/loader.py +107 -0
- doorin-0.1.0/doorin/core/logger.py +11 -0
- doorin-0.1.0/doorin/core/models.py +45 -0
- doorin-0.1.0/doorin/core/proxy.py +133 -0
- doorin-0.1.0/doorin/core/registry.py +77 -0
- doorin-0.1.0/doorin/core/requests.py +8 -0
- doorin-0.1.0/doorin/core/responses.py +26 -0
- doorin-0.1.0/doorin/core/shutdown.py +35 -0
- doorin-0.1.0/doorin/core/storage.py +181 -0
- doorin-0.1.0/doorin/core/transformers.py +47 -0
- doorin-0.1.0/doorin/core/types.py +44 -0
- doorin-0.1.0/doorin/main.py +21 -0
- doorin-0.1.0/doorin/settings.py +46 -0
- doorin-0.1.0/pyproject.toml +30 -0
doorin-0.1.0/.gitignore
ADDED
|
@@ -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
|
|
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)
|
|
File without changes
|
|
@@ -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)
|
|
File without changes
|
|
@@ -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)
|