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 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)