swarmauri_tokens_remoteoidc 0.3.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.3
2
+ Name: swarmauri_tokens_remoteoidc
3
+ Version: 0.3.0.dev4
4
+ Summary: Remote OIDC token verification service
5
+ License: Apache-2.0
6
+ Author: Swarmauri
7
+ Author-email: opensource@swarmauri.com
8
+ Requires-Python: >=3.10,<3.13
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Natural Language :: English
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Development Status :: 3 - Alpha
16
+ Classifier: Topic :: Security :: Cryptography
17
+ Classifier: Intended Audience :: Developers
18
+ Provides-Extra: cbor
19
+ Requires-Dist: cbor2 ; extra == "cbor"
20
+ Requires-Dist: cryptography
21
+ Requires-Dist: pyjwt (>=2.8.0)
22
+ Requires-Dist: swarmauri_base
23
+ Requires-Dist: swarmauri_core
24
+ Description-Content-Type: text/markdown
25
+
26
+ # swarmauri_tokens_remoteoidc
27
+
28
+ Remote OIDC token verification service for Swarmauri.
29
+
30
+ This package provides a verification-only token service that retrieves
31
+ JSON Web Key Sets (JWKS) from a remote OpenID Connect (OIDC) issuer and
32
+ validates JWTs in accordance with RFC 7517 and RFC 7519.
33
+
34
+ ## Features
35
+ - Remote OIDC discovery with JWKS caching.
36
+ - Audience and issuer validation.
37
+ - Optional extras for additional canonicalisation formats.
38
+
39
+
@@ -0,0 +1,13 @@
1
+ # swarmauri_tokens_remoteoidc
2
+
3
+ Remote OIDC token verification service for Swarmauri.
4
+
5
+ This package provides a verification-only token service that retrieves
6
+ JSON Web Key Sets (JWKS) from a remote OpenID Connect (OIDC) issuer and
7
+ validates JWTs in accordance with RFC 7517 and RFC 7519.
8
+
9
+ ## Features
10
+ - Remote OIDC discovery with JWKS caching.
11
+ - Audience and issuer validation.
12
+ - Optional extras for additional canonicalisation formats.
13
+
@@ -0,0 +1,71 @@
1
+ [project]
2
+ name = "swarmauri_tokens_remoteoidc"
3
+ version = "0.3.0.dev4"
4
+ description = "Remote OIDC token verification service"
5
+ license = "Apache-2.0"
6
+ readme = "README.md"
7
+ requires-python = ">=3.10,<3.13"
8
+ authors = [{ name = "Swarmauri", email = "opensource@swarmauri.com" }]
9
+ classifiers = [
10
+ "License :: OSI Approved :: Apache Software License",
11
+ "Natural Language :: English",
12
+ "Programming Language :: Python :: 3.10",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Development Status :: 3 - Alpha",
17
+ "Topic :: Security :: Cryptography",
18
+ "Intended Audience :: Developers",
19
+ ]
20
+ dependencies = [
21
+ "swarmauri_core",
22
+ "swarmauri_base",
23
+ "pyjwt>=2.8.0",
24
+ "cryptography",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ cbor = ["cbor2"]
29
+
30
+ [tool.uv.sources]
31
+ swarmauri_core = { workspace = true }
32
+ swarmauri_base = { workspace = true }
33
+
34
+ [tool.pytest.ini_options]
35
+ norecursedirs = ["combined", "scripts"]
36
+ markers = [
37
+ "test: standard test",
38
+ "unit: Unit tests",
39
+ "i9n: Integration tests",
40
+ "r8n: Regression tests",
41
+ "acceptance: Acceptance tests",
42
+ "perf: Performance tests",
43
+ ]
44
+ timeout = 300
45
+ log_cli = true
46
+ log_cli_level = "INFO"
47
+ log_cli_format = "%(asctime)s [%(levelname)s] %(message)s"
48
+ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
49
+ asyncio_default_fixture_loop_scope = "function"
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "pytest>=8.0",
54
+ "pytest-asyncio>=0.24.0",
55
+ "pytest-xdist>=3.6.1",
56
+ "pytest-json-report>=1.5.0",
57
+ "pytest-timeout>=2.3.1",
58
+ "pytest-benchmark>=4.0.0",
59
+ "flake8>=7.0",
60
+ "ruff>=0.9.9",
61
+ ]
62
+
63
+ [build-system]
64
+ requires = ["poetry-core>=1.0.0"]
65
+ build-backend = "poetry.core.masonry.api"
66
+
67
+ [project.entry-points.'swarmauri.tokens']
68
+ RemoteOIDCTokenService = "swarmauri_tokens_remoteoidc:RemoteOIDCTokenService"
69
+
70
+ [project.entry-points."peagen.plugins.tokens"]
71
+ remoteoidc = "swarmauri_tokens_remoteoidc:RemoteOIDCTokenService"
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from typing import Any, Dict, Iterable, Literal, Mapping, Optional, Tuple
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.parse import urljoin
9
+ from urllib.request import Request, urlopen
10
+
11
+ import jwt
12
+ from jwt import algorithms
13
+ from pydantic import PrivateAttr
14
+
15
+ try:
16
+ from swarmauri_base.tokens.TokenServiceBase import TokenServiceBase
17
+ except Exception: # pragma: no cover - fallback for test envs
18
+
19
+ class TokenServiceBase: # type: ignore
20
+ """Minimal fallback TokenServiceBase."""
21
+
22
+ def __init__(self) -> None:
23
+ pass
24
+
25
+
26
+ try:
27
+ from swarmauri_core.tokens.ITokenService import ITokenService
28
+ except Exception: # pragma: no cover - fallback for test envs
29
+
30
+ class ITokenService: # type: ignore
31
+ pass
32
+
33
+
34
+ def _now() -> float:
35
+ return time.time()
36
+
37
+
38
+ def _http_get_json(
39
+ url: str,
40
+ *,
41
+ timeout_s: int,
42
+ user_agent: str,
43
+ etag: Optional[str] = None,
44
+ last_modified: Optional[str] = None,
45
+ ) -> tuple[dict, Optional[str], Optional[str], bool]:
46
+ """
47
+ Minimal dependency HTTP GET with conditional headers.
48
+ Returns: (json_obj, new_etag, new_last_modified, not_modified)
49
+ """
50
+ headers = {"User-Agent": user_agent, "Accept": "application/json"}
51
+ if etag:
52
+ headers["If-None-Match"] = etag
53
+ if last_modified:
54
+ headers["If-Modified-Since"] = last_modified
55
+
56
+ req = Request(url, headers=headers, method="GET")
57
+ try:
58
+ with urlopen(req, timeout=timeout_s) as resp:
59
+ status = getattr(resp, "status", 200)
60
+ if status == 304:
61
+ return {}, etag, last_modified, True
62
+ data = resp.read()
63
+ obj = json.loads(data.decode("utf-8"))
64
+ new_etag = resp.headers.get("ETag") or etag
65
+ new_last_mod = resp.headers.get("Last-Modified") or last_modified
66
+ return obj, new_etag, new_last_mod, False
67
+ except HTTPError as e: # pragma: no cover - network errors
68
+ if e.code == 304:
69
+ return {}, etag, last_modified, True
70
+ raise
71
+ except URLError as e: # pragma: no cover - network errors
72
+ raise RuntimeError(f"Failed to fetch {url}: {e}") from e
73
+
74
+
75
+ class RemoteOIDCTokenService(TokenServiceBase):
76
+ """
77
+ Verify-only OIDC token service backed by remote discovery + JWKS.
78
+
79
+ Features
80
+ --------
81
+ - Resolves OIDC discovery: <issuer>/.well-known/openid-configuration → jwks_uri.
82
+ (You may also pass jwks_url directly; that bypasses discovery.)
83
+ - Caches discovery + JWKS in-memory with TTL; thread-safe refresh; honors
84
+ ETag / Last-Modified for conditional GETs.
85
+ - Strict issuer check (iss must equal configured issuer).
86
+ - Audience validation (optional); clock skew leeway configurable.
87
+ - Supports any JWS algs announced by the issuer; falls back to common algs.
88
+ - Exposes jwks() for debugging/inspection.
89
+ - No mint() (raises NotImplementedError).
90
+
91
+ Constructor
92
+ -----------
93
+ RemoteOIDCTokenService(
94
+ issuer: str,
95
+ *,
96
+ jwks_url: Optional[str] = None,
97
+ cache_ttl_s: int = 300,
98
+ request_timeout_s: int = 5,
99
+ user_agent: str = "RemoteOIDCTokenService/1.0",
100
+ expected_alg_whitelist: Optional[Iterable[str]] = None,
101
+ accept_unsigned: bool = False, # for test envs only; strongly discouraged
102
+ )
103
+ """
104
+
105
+ type: Literal["RemoteOIDCTokenService"] = "RemoteOIDCTokenService"
106
+ _lock: threading.RLock = PrivateAttr(default_factory=threading.RLock)
107
+
108
+ def __init__(
109
+ self,
110
+ issuer: str,
111
+ *,
112
+ jwks_url: Optional[str] = None,
113
+ cache_ttl_s: int = 300,
114
+ request_timeout_s: int = 5,
115
+ user_agent: str = "RemoteOIDCTokenService/1.0",
116
+ expected_alg_whitelist: Optional[Iterable[str]] = None,
117
+ accept_unsigned: bool = False,
118
+ ) -> None:
119
+ super().__init__()
120
+ if not issuer:
121
+ raise ValueError("issuer is required")
122
+ self._issuer = issuer.rstrip("/")
123
+ self._jwks_url_config = jwks_url
124
+ self._cache_ttl_s = int(cache_ttl_s)
125
+ self._timeout_s = int(request_timeout_s)
126
+ self._ua = user_agent
127
+ self._accept_unsigned = bool(accept_unsigned)
128
+
129
+ # Discovery cache
130
+ self._disc_obj: Optional[dict] = None
131
+ self._disc_at: float = 0.0
132
+ self._disc_etag: Optional[str] = None
133
+ self._disc_lastmod: Optional[str] = None
134
+
135
+ # JWKS cache
136
+ self._jwks_obj: Optional[dict] = None
137
+ self._jwks_at: float = 0.0
138
+ self._jwks_etag: Optional[str] = None
139
+ self._jwks_lastmod: Optional[str] = None
140
+
141
+ # accepted algs
142
+ self._allowed_algs: Optional[Tuple[str, ...]] = (
143
+ tuple(expected_alg_whitelist) if expected_alg_whitelist else None
144
+ )
145
+
146
+ # Pre-resolve discovery on init (best-effort)
147
+ try:
148
+ self._ensure_discovery_locked(force=False)
149
+ except Exception:
150
+ # non-fatal at construction; will retry on first verify()
151
+ pass
152
+
153
+ def supports(self) -> Mapping[str, Iterable[str]]:
154
+ algs = self._allowed_algs or (
155
+ # Sensible defaults; may be narrowed by discovery later
156
+ "RS256",
157
+ "PS256",
158
+ "ES256",
159
+ "EdDSA",
160
+ )
161
+ fmts = ("JWT", "JWS")
162
+ return {"formats": fmts, "algs": algs}
163
+
164
+ async def mint(
165
+ self,
166
+ claims: Dict[str, Any],
167
+ *,
168
+ alg: str,
169
+ kid: str | None = None,
170
+ key_version: int | None = None,
171
+ headers: Optional[Dict[str, Any]] = None,
172
+ lifetime_s: Optional[int] = 3600,
173
+ issuer: Optional[str] = None,
174
+ subject: Optional[str] = None,
175
+ audience: Optional[str | list[str]] = None,
176
+ scope: Optional[str] = None,
177
+ ) -> str: # pragma: no cover - mint not implemented
178
+ raise NotImplementedError(
179
+ "RemoteOIDCTokenService is verification-only (no mint)"
180
+ )
181
+
182
+ async def verify(
183
+ self,
184
+ token: str,
185
+ *,
186
+ issuer: Optional[str] = None,
187
+ audience: Optional[str | list[str]] = None,
188
+ leeway_s: int = 60,
189
+ max_age_s: Optional[int] = None, # optional OIDC 'max_age' enforcement
190
+ nonce: Optional[str] = None, # if you flow through /authorize with nonce
191
+ ) -> Dict[str, Any]:
192
+ """
193
+ Verify a JWT/JWS against the configured OIDC issuer and remote JWKS.
194
+
195
+ Checks performed:
196
+ - JWS signature using remote JWKS (by 'kid' header).
197
+ - 'iss' must equal configured issuer (or explicit 'issuer' arg if provided).
198
+ - 'aud' validated if provided by caller.
199
+ - 'exp','nbf','iat' validated with 'leeway_s'.
200
+ - Optional 'nonce' and 'auth_time'/'max_age' checks if provided.
201
+ """
202
+ # Refresh caches if stale
203
+ with self._lock:
204
+ self._ensure_discovery_locked(force=False)
205
+ self._ensure_jwks_locked(force=False)
206
+
207
+ iss_expected = issuer or self._issuer
208
+
209
+ # Choose allowed algorithms
210
+ allowed = self._derive_allowed_algs_locked()
211
+
212
+ # Build a key resolver that picks verification key from cached JWKS by kid
213
+ jwks = self._jwks_obj or {"keys": []}
214
+
215
+ def _resolve_key(header, payload): # pragma: no cover - internal
216
+ kid = header.get("kid")
217
+ if header.get("alg") == "none":
218
+ return (
219
+ None
220
+ if self._accept_unsigned
221
+ else jwt.InvalidAlgorithmError("Unsigned tokens are not accepted")
222
+ )
223
+ if not kid:
224
+ return None
225
+ for jwk in jwks.get("keys", []):
226
+ if jwk.get("kid") == kid:
227
+ kty = jwk.get("kty")
228
+ if kty == "RSA":
229
+ return algorithms.RSAAlgorithm.from_jwk(jwk)
230
+ if kty == "EC":
231
+ return algorithms.ECAlgorithm.from_jwk(jwk)
232
+ if kty == "OKP":
233
+ return algorithms.Ed25519Algorithm.from_jwk(jwk)
234
+ if kty == "oct":
235
+ return algorithms.HMACAlgorithm.from_jwk(jwk)
236
+ return None
237
+
238
+ options = {
239
+ "verify_aud": audience is not None,
240
+ "require": ["exp", "iat"],
241
+ }
242
+
243
+ header = jwt.get_unverified_header(token)
244
+ key_obj = _resolve_key(header, None)
245
+ claims = jwt.decode(
246
+ token,
247
+ key=key_obj,
248
+ algorithms=list(allowed),
249
+ audience=audience,
250
+ issuer=iss_expected,
251
+ leeway=leeway_s,
252
+ options=options,
253
+ )
254
+
255
+ if nonce is not None:
256
+ if claims.get("nonce") != nonce:
257
+ raise jwt.InvalidTokenError("OIDC nonce mismatch")
258
+
259
+ if max_age_s is not None:
260
+ auth_time = claims.get("auth_time")
261
+ now = int(_now())
262
+ if isinstance(auth_time, int):
263
+ if now > (auth_time + int(max_age_s) + int(leeway_s)):
264
+ raise jwt.ExpiredSignatureError("OIDC max_age exceeded")
265
+
266
+ return claims
267
+
268
+ async def jwks(self) -> dict:
269
+ with self._lock:
270
+ self._ensure_discovery_locked(force=False)
271
+ self._ensure_jwks_locked(force=False)
272
+ return dict(self._jwks_obj or {"keys": []})
273
+
274
+ def refresh(self, *, force: bool = False) -> None:
275
+ """
276
+ Synchronous refresh of discovery + JWKS caches. Safe to call
277
+ at process start or when you receive a rotation signal.
278
+ """
279
+ with self._lock:
280
+ self._ensure_discovery_locked(force=force)
281
+ self._ensure_jwks_locked(force=force)
282
+
283
+ def _derive_allowed_algs_locked(self) -> Tuple[str, ...]:
284
+ if self._allowed_algs:
285
+ return self._allowed_algs
286
+ algs = ()
287
+ if isinstance(self._disc_obj, dict):
288
+ vals = self._disc_obj.get(
289
+ "id_token_signing_alg_values_supported"
290
+ ) or self._disc_obj.get("token_endpoint_auth_signing_alg_values_supported")
291
+ if isinstance(vals, list) and vals:
292
+ algs = tuple(a for a in vals if isinstance(a, str))
293
+ if not algs:
294
+ algs = ("RS256", "PS256", "ES256", "EdDSA")
295
+ return algs
296
+
297
+ def _ensure_discovery_locked(self, *, force: bool) -> None:
298
+ if self._jwks_url_config:
299
+ if self._disc_obj is None:
300
+ self._disc_obj = {"jwks_uri": self._jwks_url_config}
301
+ self._disc_at = _now()
302
+ return
303
+
304
+ ttl_ok = (
305
+ (not force)
306
+ and self._disc_obj is not None
307
+ and ((_now() - self._disc_at) < self._cache_ttl_s)
308
+ )
309
+ if ttl_ok:
310
+ return
311
+
312
+ url = urljoin(self._issuer + "/", ".well-known/openid-configuration")
313
+ obj, etag, lastmod, not_modified = _http_get_json(
314
+ url,
315
+ timeout_s=self._timeout_s,
316
+ user_agent=self._ua,
317
+ etag=self._disc_etag,
318
+ last_modified=self._disc_lastmod,
319
+ )
320
+ if not_modified and self._disc_obj is not None:
321
+ self._disc_at = _now()
322
+ return
323
+ if not isinstance(obj, dict) or "jwks_uri" not in obj:
324
+ raise RuntimeError(f"OIDC discovery did not return jwks_uri: {url}")
325
+ self._disc_obj = obj
326
+ self._disc_etag = etag
327
+ self._disc_lastmod = lastmod
328
+ self._disc_at = _now()
329
+
330
+ def _ensure_jwks_locked(self, *, force: bool) -> None:
331
+ ttl_ok = (
332
+ (not force)
333
+ and self._jwks_obj is not None
334
+ and ((_now() - self._jwks_at) < self._cache_ttl_s)
335
+ )
336
+ if ttl_ok:
337
+ return
338
+
339
+ jwks_url = self._jwks_url_config
340
+ if not jwks_url:
341
+ if not self._disc_obj or "jwks_uri" not in self._disc_obj:
342
+ self._ensure_discovery_locked(force=False)
343
+ jwks_url = self._disc_obj.get("jwks_uri") # type: ignore[assignment]
344
+ if not jwks_url:
345
+ raise RuntimeError("No JWKS URL available")
346
+
347
+ obj, etag, lastmod, not_modified = _http_get_json(
348
+ jwks_url,
349
+ timeout_s=self._timeout_s,
350
+ user_agent=self._ua,
351
+ etag=self._jwks_etag,
352
+ last_modified=self._jwks_lastmod,
353
+ )
354
+ if not_modified and self._jwks_obj is not None:
355
+ self._jwks_at = _now()
356
+ return
357
+ if not isinstance(obj, dict) or "keys" not in obj:
358
+ raise RuntimeError(
359
+ f"JWKS fetch did not return an object with 'keys': {jwks_url}"
360
+ )
361
+
362
+ self._jwks_obj = obj
363
+ self._jwks_etag = etag
364
+ self._jwks_lastmod = lastmod
365
+ self._jwks_at = _now()
@@ -0,0 +1,3 @@
1
+ from .RemoteOIDCTokenService import RemoteOIDCTokenService
2
+
3
+ __all__ = ["RemoteOIDCTokenService"]