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.
- swarmauri_tokens_remoteoidc-0.3.0.dev4/PKG-INFO +39 -0
- swarmauri_tokens_remoteoidc-0.3.0.dev4/README.md +13 -0
- swarmauri_tokens_remoteoidc-0.3.0.dev4/pyproject.toml +71 -0
- swarmauri_tokens_remoteoidc-0.3.0.dev4/swarmauri_tokens_remoteoidc/RemoteOIDCTokenService.py +365 -0
- swarmauri_tokens_remoteoidc-0.3.0.dev4/swarmauri_tokens_remoteoidc/__init__.py +3 -0
|
@@ -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()
|