duar-auth 1.0.0__py3-none-any.whl
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.
- duar_auth/__init__.py +30 -0
- duar_auth/_utils.py +21 -0
- duar_auth/auth.py +308 -0
- duar_auth/authz.py +78 -0
- duar_auth/authz_middleware.py +254 -0
- duar_auth/dependencies.py +131 -0
- duar_auth/duar.py +401 -0
- duar_auth/middleware.py +159 -0
- duar_auth/permissions.py +472 -0
- duar_auth/proxy.py +111 -0
- duar_auth/roles.py +103 -0
- duar_auth/types.py +107 -0
- duar_auth-1.0.0.dist-info/METADATA +17 -0
- duar_auth-1.0.0.dist-info/RECORD +15 -0
- duar_auth-1.0.0.dist-info/WHEEL +4 -0
duar_auth/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from duar_auth.auth import RequestAuth, SystemAuth
|
|
4
|
+
from duar_auth.authz import AuthzClient
|
|
5
|
+
from duar_auth.authz_middleware import AuthzMiddleware
|
|
6
|
+
from duar_auth.dependencies import get_token
|
|
7
|
+
from duar_auth.duar import Duar
|
|
8
|
+
from duar_auth.middleware import JWTAuthMiddleware
|
|
9
|
+
from duar_auth.permissions import PermissionClient
|
|
10
|
+
from duar_auth.proxy import create_proxy_router
|
|
11
|
+
from duar_auth.roles import RoleClient
|
|
12
|
+
from duar_auth.types import AuthenticatedUser, DuarError, WorkspaceContext
|
|
13
|
+
|
|
14
|
+
__version__ = version("duar-auth")
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AuthenticatedUser",
|
|
17
|
+
"AuthzClient",
|
|
18
|
+
"AuthzMiddleware",
|
|
19
|
+
"JWTAuthMiddleware",
|
|
20
|
+
"PermissionClient",
|
|
21
|
+
"RequestAuth",
|
|
22
|
+
"RoleClient",
|
|
23
|
+
"Duar",
|
|
24
|
+
"DuarError",
|
|
25
|
+
"SystemAuth",
|
|
26
|
+
"WorkspaceContext",
|
|
27
|
+
"__version__",
|
|
28
|
+
"create_proxy_router",
|
|
29
|
+
"get_token",
|
|
30
|
+
]
|
duar_auth/_utils.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Internal utilities for the Duar SDK."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
_logger = logging.getLogger("duar_auth")
|
|
7
|
+
|
|
8
|
+
_SAFE_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def warn_if_insecure(url: str, context: str = "") -> None:
|
|
12
|
+
"""Log a warning if the URL uses plain HTTP on a non-localhost host."""
|
|
13
|
+
parsed = urlparse(url)
|
|
14
|
+
if parsed.scheme == "http" and parsed.hostname not in _SAFE_HOSTS:
|
|
15
|
+
label = f" ({context})" if context else ""
|
|
16
|
+
_logger.warning(
|
|
17
|
+
"Duar SDK%s is connecting over plain HTTP to %s. "
|
|
18
|
+
"Use HTTPS in production to protect tokens and credentials.",
|
|
19
|
+
label,
|
|
20
|
+
parsed.hostname,
|
|
21
|
+
)
|
duar_auth/auth.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Request-scoped auth context that bundles identity with authorization capabilities.
|
|
2
|
+
|
|
3
|
+
``RequestAuth`` is created once per request by the framework layer (e.g. a FastAPI
|
|
4
|
+
dependency) and can be passed to any layer — including DDD use cases — as a plain
|
|
5
|
+
Python object. Receivers only need to define a ``Protocol`` matching the
|
|
6
|
+
attributes/methods they use; no SDK import required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from duar_auth.types import AuthenticatedUser, DuarError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from duar_auth.permissions import PermissionClient
|
|
19
|
+
from duar_auth.roles import RoleClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RequestAuth:
|
|
24
|
+
"""Per-request auth context combining user identity with token-backed authorization.
|
|
25
|
+
|
|
26
|
+
The raw JWT token is stored privately and never exposed in ``repr``.
|
|
27
|
+
Authorization methods (``can``, ``check_action``, ``accessible``) use it
|
|
28
|
+
internally when calling Duar APIs.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
user: The authenticated user from JWT claims.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
user: AuthenticatedUser
|
|
35
|
+
_token: str = field(repr=False)
|
|
36
|
+
_permissions: PermissionClient | None = field(default=None, repr=False, compare=False)
|
|
37
|
+
_roles: RoleClient | None = field(default=None, repr=False, compare=False)
|
|
38
|
+
_request_cache: dict = field(default_factory=dict, repr=False, compare=False)
|
|
39
|
+
|
|
40
|
+
# -- Forwarded identity properties -----------------------------------------
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def user_id(self) -> uuid.UUID:
|
|
44
|
+
return self.user.user_id
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def workspace_id(self) -> uuid.UUID:
|
|
48
|
+
return self.user.workspace_id
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def workspace_role(self) -> str:
|
|
52
|
+
return self.user.workspace_role
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def email(self) -> str:
|
|
56
|
+
return self.user.email
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
return self.user.name
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def groups(self) -> list[uuid.UUID]:
|
|
64
|
+
return self.user.groups
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def org_id(self) -> uuid.UUID | None:
|
|
68
|
+
return self.user.org_id
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def org_slug(self) -> str | None:
|
|
72
|
+
return self.user.org_slug
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def org_is_public(self) -> bool:
|
|
76
|
+
return self.user.org_is_public
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_admin(self) -> bool:
|
|
80
|
+
return self.user.is_admin
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_editor(self) -> bool:
|
|
84
|
+
return self.user.is_editor
|
|
85
|
+
|
|
86
|
+
def has_role(self, minimum_role: str) -> bool:
|
|
87
|
+
"""Check workspace role hierarchy. No network call."""
|
|
88
|
+
return self.user.has_role(minimum_role)
|
|
89
|
+
|
|
90
|
+
# -- Authorization (token hidden internally) -------------------------------
|
|
91
|
+
|
|
92
|
+
async def can(
|
|
93
|
+
self,
|
|
94
|
+
resource_type: str,
|
|
95
|
+
resource_id: uuid.UUID,
|
|
96
|
+
action: str,
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Check entity-level permission via Duar's Zanzibar API.
|
|
99
|
+
|
|
100
|
+
Results are deduplicated within the same request — calling ``can()``
|
|
101
|
+
twice with the same arguments makes only one HTTP call.
|
|
102
|
+
"""
|
|
103
|
+
if self._permissions is None:
|
|
104
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
105
|
+
key = ("can", resource_type, resource_id, action)
|
|
106
|
+
if key not in self._request_cache:
|
|
107
|
+
self._request_cache[key] = await self._permissions.can(self._token, resource_type, resource_id, action)
|
|
108
|
+
return self._request_cache[key]
|
|
109
|
+
|
|
110
|
+
async def check_action(self, action: str) -> bool:
|
|
111
|
+
"""Check RBAC action via Duar's role API.
|
|
112
|
+
|
|
113
|
+
Results are deduplicated within the same request.
|
|
114
|
+
"""
|
|
115
|
+
if self._roles is None:
|
|
116
|
+
raise DuarError("RoleClient not configured on this RequestAuth")
|
|
117
|
+
key = ("check_action", action)
|
|
118
|
+
if key not in self._request_cache:
|
|
119
|
+
self._request_cache[key] = await self._roles.check_action(self._token, action, self.user.workspace_id)
|
|
120
|
+
return self._request_cache[key]
|
|
121
|
+
|
|
122
|
+
async def accessible(
|
|
123
|
+
self,
|
|
124
|
+
resource_type: str,
|
|
125
|
+
action: str,
|
|
126
|
+
limit: int | None = None,
|
|
127
|
+
) -> tuple[list[uuid.UUID], bool]:
|
|
128
|
+
"""Get accessible resource IDs for list filtering.
|
|
129
|
+
|
|
130
|
+
Returns ``(resource_ids, has_full_access)``. Results are deduplicated
|
|
131
|
+
within the same request.
|
|
132
|
+
"""
|
|
133
|
+
if self._permissions is None:
|
|
134
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
135
|
+
key = ("accessible", resource_type, action, limit)
|
|
136
|
+
if key not in self._request_cache:
|
|
137
|
+
self._request_cache[key] = await self._permissions.accessible(
|
|
138
|
+
self._token,
|
|
139
|
+
resource_type,
|
|
140
|
+
action,
|
|
141
|
+
self.user.workspace_id,
|
|
142
|
+
limit,
|
|
143
|
+
)
|
|
144
|
+
return self._request_cache[key]
|
|
145
|
+
|
|
146
|
+
async def register_resource(
|
|
147
|
+
self,
|
|
148
|
+
resource_type: str,
|
|
149
|
+
resource_id: uuid.UUID,
|
|
150
|
+
visibility: str = "workspace",
|
|
151
|
+
) -> dict:
|
|
152
|
+
"""Register a new resource ACL (uses service key + user context)."""
|
|
153
|
+
if self._permissions is None:
|
|
154
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
155
|
+
return await self._permissions.register_resource(
|
|
156
|
+
resource_type=resource_type,
|
|
157
|
+
resource_id=resource_id,
|
|
158
|
+
workspace_id=self.user.workspace_id,
|
|
159
|
+
owner_id=self.user.user_id,
|
|
160
|
+
visibility=visibility,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def share(
|
|
164
|
+
self,
|
|
165
|
+
resource_type: str,
|
|
166
|
+
resource_id: uuid.UUID,
|
|
167
|
+
grantee_type: str,
|
|
168
|
+
grantee_id: uuid.UUID,
|
|
169
|
+
permission: str = "view",
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Share a resource with a user or group."""
|
|
172
|
+
if self._permissions is None:
|
|
173
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
174
|
+
return await self._permissions.share(
|
|
175
|
+
self._token,
|
|
176
|
+
resource_type,
|
|
177
|
+
resource_id,
|
|
178
|
+
grantee_type,
|
|
179
|
+
grantee_id,
|
|
180
|
+
permission,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def unshare(
|
|
184
|
+
self,
|
|
185
|
+
resource_type: str,
|
|
186
|
+
resource_id: uuid.UUID,
|
|
187
|
+
grantee_type: str,
|
|
188
|
+
grantee_id: uuid.UUID,
|
|
189
|
+
permission: str = "view",
|
|
190
|
+
) -> dict:
|
|
191
|
+
"""Revoke a share on a resource."""
|
|
192
|
+
if self._permissions is None:
|
|
193
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
194
|
+
return await self._permissions.unshare(
|
|
195
|
+
self._token,
|
|
196
|
+
resource_type,
|
|
197
|
+
resource_id,
|
|
198
|
+
grantee_type,
|
|
199
|
+
grantee_id,
|
|
200
|
+
permission,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
async def update_visibility(
|
|
204
|
+
self,
|
|
205
|
+
resource_type: str,
|
|
206
|
+
resource_id: uuid.UUID,
|
|
207
|
+
visibility: str,
|
|
208
|
+
) -> dict:
|
|
209
|
+
"""Update resource visibility (private/workspace)."""
|
|
210
|
+
if self._permissions is None:
|
|
211
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
212
|
+
return await self._permissions.update_visibility(
|
|
213
|
+
self._token,
|
|
214
|
+
resource_type,
|
|
215
|
+
resource_id,
|
|
216
|
+
visibility,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def get_resource_acl(
|
|
220
|
+
self,
|
|
221
|
+
resource_type: str,
|
|
222
|
+
resource_id: uuid.UUID,
|
|
223
|
+
) -> dict:
|
|
224
|
+
"""Get the full ACL record for a resource, including shares."""
|
|
225
|
+
if self._permissions is None:
|
|
226
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
227
|
+
return await self._permissions.get_resource_acl(resource_type, resource_id)
|
|
228
|
+
|
|
229
|
+
async def get_enriched_resource_acl(
|
|
230
|
+
self,
|
|
231
|
+
resource_type: str,
|
|
232
|
+
resource_id: uuid.UUID,
|
|
233
|
+
) -> dict:
|
|
234
|
+
"""Get ACL with user profiles resolved inline (names, emails)."""
|
|
235
|
+
if self._permissions is None:
|
|
236
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
237
|
+
return await self._permissions.get_enriched_resource_acl(resource_type, resource_id)
|
|
238
|
+
|
|
239
|
+
# -- Workspace / group helpers (auto-inject workspace_id from JWT) --------
|
|
240
|
+
|
|
241
|
+
async def search_members(
|
|
242
|
+
self,
|
|
243
|
+
query: str | None = None,
|
|
244
|
+
limit: int | None = None,
|
|
245
|
+
) -> list[dict]:
|
|
246
|
+
"""Search workspace members by name or email."""
|
|
247
|
+
if self._permissions is None:
|
|
248
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
249
|
+
return await self._permissions.search_workspace_members(
|
|
250
|
+
self._token,
|
|
251
|
+
self.user.workspace_id,
|
|
252
|
+
query,
|
|
253
|
+
limit,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def list_members(
|
|
257
|
+
self,
|
|
258
|
+
limit: int | None = None,
|
|
259
|
+
) -> list[dict]:
|
|
260
|
+
"""List all members of the current workspace."""
|
|
261
|
+
if self._permissions is None:
|
|
262
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
263
|
+
return await self._permissions.search_workspace_members(
|
|
264
|
+
self._token,
|
|
265
|
+
self.user.workspace_id,
|
|
266
|
+
query=None,
|
|
267
|
+
limit=limit,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def list_groups(self) -> list[dict]:
|
|
271
|
+
"""List groups in the current workspace."""
|
|
272
|
+
if self._permissions is None:
|
|
273
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
274
|
+
return await self._permissions.list_groups(self._token, self.user.workspace_id)
|
|
275
|
+
|
|
276
|
+
async def get_group_members(self, group_id: uuid.UUID) -> list[dict]:
|
|
277
|
+
"""List members of a group in the current workspace."""
|
|
278
|
+
if self._permissions is None:
|
|
279
|
+
raise DuarError("PermissionClient not configured on this RequestAuth")
|
|
280
|
+
return await self._permissions.get_group_members(
|
|
281
|
+
self._token,
|
|
282
|
+
self.user.workspace_id,
|
|
283
|
+
group_id,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True)
|
|
288
|
+
class SystemAuth:
|
|
289
|
+
"""Per-request context for a no-user (machine-to-machine) in-realm call.
|
|
290
|
+
|
|
291
|
+
The no-user counterpart to :class:`RequestAuth`. It is produced by
|
|
292
|
+
``Duar.verify_m2m_token`` after a ``type=m2m`` token (``aud=duar:m2m``)
|
|
293
|
+
passes Duar's RS256 signature + realm-scope checks. It carries service
|
|
294
|
+
identity only — never a user:
|
|
295
|
+
|
|
296
|
+
- ``caller``: the realm member that minted the token (server-stamped, for audit).
|
|
297
|
+
- ``svc``: the realm slug the token is scoped to (the shared ``effective_scope``).
|
|
298
|
+
- ``actions``: granted actions. ``["*"]`` is full in-realm trust (v1); a narrowed
|
|
299
|
+
list is honored by ``can`` with no shape change when least-privilege m2m ships.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
caller: str
|
|
303
|
+
actions: list[str]
|
|
304
|
+
svc: str
|
|
305
|
+
|
|
306
|
+
def can(self, action: str) -> bool:
|
|
307
|
+
"""Whether this system caller may perform ``action``. No network call."""
|
|
308
|
+
return "*" in self.actions or action in self.actions
|
duar_auth/authz.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""AuthZ mode client — resolve IdP tokens into authorization context."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from duar_auth._utils import warn_if_insecure
|
|
8
|
+
from duar_auth.types import DuarError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthzClient:
|
|
12
|
+
"""Client for Duar's AuthZ mode endpoints.
|
|
13
|
+
|
|
14
|
+
Validates IdP tokens and retrieves authorization context
|
|
15
|
+
(workspace roles, RBAC actions, signed authz JWT).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_url: str, service_key: str):
|
|
19
|
+
self.base_url = base_url.rstrip("/")
|
|
20
|
+
self.service_key = service_key
|
|
21
|
+
self._client: httpx.AsyncClient | None = None
|
|
22
|
+
warn_if_insecure(self.base_url, "AuthzClient")
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return f"AuthzClient(base_url={self.base_url!r})"
|
|
26
|
+
|
|
27
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
28
|
+
if self._client is None or self._client.is_closed:
|
|
29
|
+
self._client = httpx.AsyncClient(timeout=10.0)
|
|
30
|
+
return self._client
|
|
31
|
+
|
|
32
|
+
def _headers(self) -> dict[str, str]:
|
|
33
|
+
return {"X-Service-Key": self.service_key}
|
|
34
|
+
|
|
35
|
+
async def resolve(
|
|
36
|
+
self,
|
|
37
|
+
idp_token: str,
|
|
38
|
+
provider: str,
|
|
39
|
+
workspace_id: uuid.UUID | str | None = None,
|
|
40
|
+
nonce: str | None = None,
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""Resolve an IdP token into authorization context.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
idp_token: Raw token from the IdP (OIDC ID token or OAuth access token).
|
|
46
|
+
provider: IdP provider name ("google", "github", "entra_id").
|
|
47
|
+
workspace_id: Optional workspace to authorize for.
|
|
48
|
+
nonce: Optional replay-protection nonce. When provided, Duar
|
|
49
|
+
requires the IdP token's ``nonce`` claim (OIDC only) to match.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dict with user info. If workspace_id was provided, includes
|
|
53
|
+
authz_token and workspace. Otherwise includes workspaces list.
|
|
54
|
+
"""
|
|
55
|
+
client = await self._get_client()
|
|
56
|
+
body: dict = {"idp_token": idp_token, "provider": provider}
|
|
57
|
+
if workspace_id:
|
|
58
|
+
body["workspace_id"] = str(workspace_id)
|
|
59
|
+
if nonce:
|
|
60
|
+
body["nonce"] = nonce
|
|
61
|
+
resp = await client.post(
|
|
62
|
+
f"{self.base_url}/authz/resolve",
|
|
63
|
+
json=body,
|
|
64
|
+
headers=self._headers(),
|
|
65
|
+
)
|
|
66
|
+
if resp.status_code != 200:
|
|
67
|
+
raise DuarError(f"Duar API error: {resp.status_code}", resp.status_code)
|
|
68
|
+
return resp.json()
|
|
69
|
+
|
|
70
|
+
async def close(self) -> None:
|
|
71
|
+
if self._client and not self._client.is_closed:
|
|
72
|
+
await self._client.aclose()
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self):
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(self, *args):
|
|
78
|
+
await self.close()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Dual-token middleware for AuthZ mode.
|
|
2
|
+
|
|
3
|
+
Validates both an IdP token (identity) and a Duar authz token
|
|
4
|
+
(authorization), checking that the idp_sub claims match.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
from jwt import PyJWKClient
|
|
15
|
+
from jwt.exceptions import PyJWKClientError
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import JSONResponse, Response
|
|
19
|
+
from starlette.types import ASGIApp
|
|
20
|
+
|
|
21
|
+
from duar_auth.types import AuthenticatedUser
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from duar_auth.duar import Duar
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthzMiddleware(BaseHTTPMiddleware):
|
|
28
|
+
"""Validates IdP token + Duar authz token on each request.
|
|
29
|
+
|
|
30
|
+
IdP token: ``Authorization: Bearer <idp_token>``
|
|
31
|
+
Authz token: ``X-Authz-Token: <authz_token>``
|
|
32
|
+
|
|
33
|
+
Both must be valid and their ``sub``/``idp_sub`` claims must match.
|
|
34
|
+
|
|
35
|
+
Required binding arguments:
|
|
36
|
+
- ``service_name``: the authz token's ``svc`` claim must equal this, so a
|
|
37
|
+
token minted for another service cannot be replayed here.
|
|
38
|
+
- ``idp_audience``: the IdP token's ``aud`` claim must equal this. In
|
|
39
|
+
OpenID Connect this is your OAuth client_id. Without this check, any
|
|
40
|
+
valid ID token from any client of the same IdP authenticates.
|
|
41
|
+
- ``idp_issuer`` (optional but recommended): the IdP token's ``iss`` claim
|
|
42
|
+
must equal this.
|
|
43
|
+
|
|
44
|
+
For IdP key material you must provide either ``idp_public_key`` (single PEM)
|
|
45
|
+
or ``idp_jwks_url`` (e.g. Google's JWKS — handles key rotation).
|
|
46
|
+
|
|
47
|
+
**Offline by design — no revocation check.** Validation is purely local
|
|
48
|
+
(signature, audience, expiry, ``idp_sub``/``svc`` bindings); the middleware
|
|
49
|
+
does NOT call Duar to consult the token denylist or the user-deactivation
|
|
50
|
+
flag. A deactivated user's already-issued authz token therefore stays accepted
|
|
51
|
+
here until it expires naturally. Authz tokens are short-lived (default 5 min)
|
|
52
|
+
to bound this window — keep ``AUTHZ_TOKEN_EXPIRE_MINUTES`` small. For
|
|
53
|
+
revocation-sensitive operations, gate them with a Duar ``PermissionClient``
|
|
54
|
+
/ ``RoleClient`` call rather than relying on this middleware alone.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
app: ASGIApp,
|
|
60
|
+
*,
|
|
61
|
+
service_name: str,
|
|
62
|
+
idp_audience: str | list[str],
|
|
63
|
+
idp_public_key: str | None = None,
|
|
64
|
+
idp_jwks_url: str | None = None,
|
|
65
|
+
idp_issuer: str | None = None,
|
|
66
|
+
duar_public_key: str | None = None,
|
|
67
|
+
duar_instance: Duar | None = None,
|
|
68
|
+
idp_algorithm: str = "RS256",
|
|
69
|
+
duar_algorithm: str = "RS256",
|
|
70
|
+
duar_audience: str = "duar:authz",
|
|
71
|
+
exclude_paths: list[str] | None = None,
|
|
72
|
+
):
|
|
73
|
+
super().__init__(app)
|
|
74
|
+
if not service_name:
|
|
75
|
+
raise ValueError("AuthzMiddleware requires service_name")
|
|
76
|
+
if not idp_audience:
|
|
77
|
+
raise ValueError("AuthzMiddleware requires idp_audience")
|
|
78
|
+
if not duar_public_key and not duar_instance:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"AuthzMiddleware requires either duar_public_key or duar_instance for authz token verification"
|
|
81
|
+
)
|
|
82
|
+
if (
|
|
83
|
+
not idp_public_key
|
|
84
|
+
and not idp_jwks_url
|
|
85
|
+
and not (duar_instance and (duar_instance.idp_jwks_url or duar_instance.idp_public_key))
|
|
86
|
+
):
|
|
87
|
+
raise ValueError("AuthzMiddleware requires idp_public_key or idp_jwks_url for IdP token verification")
|
|
88
|
+
|
|
89
|
+
self.service_name = service_name
|
|
90
|
+
self.idp_audience = idp_audience
|
|
91
|
+
self.idp_issuer = idp_issuer
|
|
92
|
+
self._idp_public_key = idp_public_key
|
|
93
|
+
self._idp_jwks_url = idp_jwks_url
|
|
94
|
+
self._duar_public_key = duar_public_key
|
|
95
|
+
self._duar_instance = duar_instance
|
|
96
|
+
self.idp_algorithm = idp_algorithm
|
|
97
|
+
self.duar_algorithm = duar_algorithm
|
|
98
|
+
self.duar_audience = duar_audience
|
|
99
|
+
self.exclude_paths = exclude_paths or ["/health", "/docs", "/openapi.json"]
|
|
100
|
+
|
|
101
|
+
jwks_url = idp_jwks_url or (duar_instance.idp_jwks_url if duar_instance else None)
|
|
102
|
+
# The fetch is sync urllib (like the Duar one): dispatch runs it via
|
|
103
|
+
# asyncio.to_thread, and the timeout bounds the worker-thread stall.
|
|
104
|
+
self._idp_jwks_client: PyJWKClient | None = PyJWKClient(jwks_url, timeout=10) if jwks_url else None
|
|
105
|
+
|
|
106
|
+
# Duar (authz token) key resolution. Static duar_public_key pins
|
|
107
|
+
# one key (air-gapped); otherwise resolve by kid via PyJWKClient against
|
|
108
|
+
# Duar's JWKS — same battle-tested path as IdP tokens, handles rotation.
|
|
109
|
+
duar_jwks_url = (
|
|
110
|
+
f"{duar_instance.base_url}/.well-known/jwks.json" if (duar_instance and not duar_public_key) else None
|
|
111
|
+
)
|
|
112
|
+
self._duar_jwk_client: PyJWKClient | None = PyJWKClient(duar_jwks_url, timeout=5) if duar_jwks_url else None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def idp_public_key(self) -> str:
|
|
116
|
+
if self._idp_public_key:
|
|
117
|
+
return self._idp_public_key
|
|
118
|
+
if self._duar_instance:
|
|
119
|
+
return self._duar_instance.idp_public_key or ""
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def duar_public_key(self) -> str:
|
|
124
|
+
key = self._duar_public_key
|
|
125
|
+
if not key and self._duar_instance:
|
|
126
|
+
key = self._duar_instance.duar_public_key or ""
|
|
127
|
+
if not key:
|
|
128
|
+
raise RuntimeError(
|
|
129
|
+
"Duar public key not available. Ensure duar_instance.lifespan() has run "
|
|
130
|
+
"or provide duar_public_key directly."
|
|
131
|
+
)
|
|
132
|
+
return key
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def effective_scope(self) -> str:
|
|
136
|
+
"""The shared scope an incoming authz token's ``svc`` must match.
|
|
137
|
+
|
|
138
|
+
A realm member resolves this from its Duar instance (discovered via
|
|
139
|
+
``whoami`` at startup); standalone services and static-key (air-gapped) mode
|
|
140
|
+
fall back to ``service_name`` — today's behavior, unchanged.
|
|
141
|
+
"""
|
|
142
|
+
if self._duar_instance is not None:
|
|
143
|
+
# ponytail: getattr because _FakeDuar in test_authz_middleware.py predates this
|
|
144
|
+
# attribute and cannot be edited (frozen test). Real Duar always has it post-A2.
|
|
145
|
+
return getattr(self._duar_instance, "effective_scope", self.service_name)
|
|
146
|
+
return self.service_name
|
|
147
|
+
|
|
148
|
+
def _decode_idp_token(self, token: str) -> dict:
|
|
149
|
+
"""Decode and validate an IdP token.
|
|
150
|
+
|
|
151
|
+
Enforces ``aud`` and ``iss`` — these are the sole defences against
|
|
152
|
+
accepting a valid-but-wrong-client token from the same IdP.
|
|
153
|
+
"""
|
|
154
|
+
decode_kwargs: dict = {
|
|
155
|
+
"algorithms": [self.idp_algorithm],
|
|
156
|
+
"audience": self.idp_audience,
|
|
157
|
+
}
|
|
158
|
+
if self.idp_issuer:
|
|
159
|
+
decode_kwargs["issuer"] = self.idp_issuer
|
|
160
|
+
|
|
161
|
+
if self._idp_jwks_client:
|
|
162
|
+
signing_key = self._idp_jwks_client.get_signing_key_from_jwt(token)
|
|
163
|
+
return jwt.decode(token, signing_key.key, **decode_kwargs)
|
|
164
|
+
return jwt.decode(token, self.idp_public_key, **decode_kwargs)
|
|
165
|
+
|
|
166
|
+
def _decode_authz(self, token: str) -> dict:
|
|
167
|
+
"""Verify a Duar authz token.
|
|
168
|
+
|
|
169
|
+
Static ``duar_public_key`` mode pins one key (air-gapped, not
|
|
170
|
+
rotation-capable). Otherwise the key is resolved by ``kid`` via
|
|
171
|
+
``PyJWKClient``, which refetches Duar's JWKS on a rotated-in kid.
|
|
172
|
+
"""
|
|
173
|
+
key = self._duar_public_key
|
|
174
|
+
if not key:
|
|
175
|
+
key = self._duar_jwk_client.get_signing_key_from_jwt(token).key
|
|
176
|
+
return jwt.decode(
|
|
177
|
+
token,
|
|
178
|
+
key,
|
|
179
|
+
algorithms=[self.duar_algorithm],
|
|
180
|
+
audience=self.duar_audience,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
184
|
+
if request.method == "OPTIONS":
|
|
185
|
+
return await call_next(request)
|
|
186
|
+
if any(request.url.path == p or request.url.path.startswith(p + "/") for p in self.exclude_paths):
|
|
187
|
+
return await call_next(request)
|
|
188
|
+
|
|
189
|
+
# 1. Extract IdP token from Authorization header
|
|
190
|
+
auth_header = request.headers.get("Authorization")
|
|
191
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
192
|
+
return JSONResponse(status_code=401, content={"detail": "Missing IdP token"})
|
|
193
|
+
idp_token = auth_header.removeprefix("Bearer ")
|
|
194
|
+
|
|
195
|
+
# 2. Extract authz token from X-Authz-Token header
|
|
196
|
+
authz_token = request.headers.get("X-Authz-Token")
|
|
197
|
+
if not authz_token:
|
|
198
|
+
return JSONResponse(status_code=401, content={"detail": "Missing authz token"})
|
|
199
|
+
|
|
200
|
+
# 3. Validate IdP token (signature + audience + optional issuer).
|
|
201
|
+
# Off-loop: the kid lookup can trigger a sync JWKS refetch.
|
|
202
|
+
try:
|
|
203
|
+
idp_payload = await asyncio.to_thread(self._decode_idp_token, idp_token)
|
|
204
|
+
except jwt.ExpiredSignatureError:
|
|
205
|
+
return JSONResponse(status_code=401, content={"detail": "IdP token expired"})
|
|
206
|
+
except (jwt.InvalidTokenError, PyJWKClientError):
|
|
207
|
+
return JSONResponse(status_code=401, content={"detail": "Invalid IdP token"})
|
|
208
|
+
|
|
209
|
+
# 4. Validate authz token (key selected by kid; supports rotation)
|
|
210
|
+
try:
|
|
211
|
+
authz_payload = await asyncio.to_thread(self._decode_authz, authz_token)
|
|
212
|
+
except jwt.ExpiredSignatureError:
|
|
213
|
+
return JSONResponse(status_code=401, content={"detail": "Authz token expired"})
|
|
214
|
+
except (jwt.InvalidTokenError, PyJWKClientError):
|
|
215
|
+
return JSONResponse(status_code=401, content={"detail": "Invalid authz token"})
|
|
216
|
+
|
|
217
|
+
# 5. Verify binding: IdP sub must match authz idp_sub, both non-empty.
|
|
218
|
+
idp_sub = idp_payload.get("sub")
|
|
219
|
+
authz_idp_sub = authz_payload.get("idp_sub")
|
|
220
|
+
if not idp_sub or not authz_idp_sub or idp_sub != authz_idp_sub:
|
|
221
|
+
return JSONResponse(
|
|
222
|
+
status_code=401,
|
|
223
|
+
content={"detail": "Token binding mismatch: idp_sub does not match"},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 6. Enforce svc binding: the authz token was minted for this service's
|
|
227
|
+
# effective scope (the realm slug for a member, else the service name).
|
|
228
|
+
token_svc = authz_payload.get("svc")
|
|
229
|
+
if not token_svc or token_svc != self.effective_scope:
|
|
230
|
+
return JSONResponse(
|
|
231
|
+
status_code=403,
|
|
232
|
+
content={"detail": "Authz token was issued for a different service"},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# 7. Set user on request state
|
|
236
|
+
try:
|
|
237
|
+
request.state.user = AuthenticatedUser(
|
|
238
|
+
user_id=uuid.UUID(authz_payload["sub"]),
|
|
239
|
+
email=idp_payload.get("email", ""),
|
|
240
|
+
name=idp_payload.get("name", ""),
|
|
241
|
+
workspace_id=uuid.UUID(authz_payload["wid"]),
|
|
242
|
+
workspace_slug=authz_payload.get("wslug", ""),
|
|
243
|
+
workspace_role=authz_payload["wrole"],
|
|
244
|
+
groups=[],
|
|
245
|
+
org_id=uuid.UUID(authz_payload["oid"]) if authz_payload.get("oid") else None,
|
|
246
|
+
org_slug=authz_payload.get("oslug"),
|
|
247
|
+
org_is_public=bool(authz_payload.get("opub", False)),
|
|
248
|
+
)
|
|
249
|
+
request.state.token = authz_token
|
|
250
|
+
request.state.idp_token = idp_token
|
|
251
|
+
except (KeyError, ValueError):
|
|
252
|
+
return JSONResponse(status_code=401, content={"detail": "Invalid token claims"})
|
|
253
|
+
|
|
254
|
+
return await call_next(request)
|