Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py: 53%

209 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1"""Client-held sealed envelope for the oauth_delegate DCR bridge. 

2 

3A DCR-bridge client holds ONE bearer that must carry BOTH a litellm identity and the 

4upstream OAuth grant, with zero server-side storage. The gateway token endpoint mints a 

5litellm-signed envelope (:func:`mint_envelope`); the MCP edge validates it, recovers the 

6identity claims and the inner upstream grant (:func:`open_envelope`), and forwards the 

7inner access token upstream. This module is pure and unwired: it imports nothing from 

8endpoint or edge code, reads no proxy globals, and takes all key material and the clock 

9as explicit parameters. 

10 

11Wire shape: ``llm_env_`` + an HS256 JWT (same signing approach as the BYOK session 

12bearer in ``byok_oauth_endpoints.py``). Registered claims are ``iss``/``iat``/``exp``; 

13custom claims are ``server_id``, ``key_hash``, and ``grant``, where ``grant`` is the 

14upstream token grant serialized to JSON, encrypted with the repo's symmetric 

15encryption helpers (``encrypt_value``/``decrypt_value`` from 

16``encrypt_decrypt_utils`` — the same family ``encrypt_value_helper`` applies to 

17persisted DCR credentials), and base64url-encoded, so the inner token never appears 

18in plaintext anywhere in the envelope. 

19 

20Failures are values: :func:`open_envelope` returns one of the frozen 

21``EnvelopeOpenError`` variants (discriminated on ``tag``) for invalid, expired, 

22tampered, or undecryptable input, and :func:`mint_envelope` returns a typed error 

23for oversized grants or an unrepresentable provider lifetime. Error values carry 

24tags and metadata only, never token material. 

25 

26The pydantic input models reject programmer errors at construction (e.g. a 

27non-positive ``expires_in`` or an empty required field). :func:`open_envelope` is 

28additionally total over hostile, attacker-controlled input: it never raises, only 

29returns an ``EnvelopeOpenError``. :func:`mint_envelope` operates on a 

30gateway-supplied grant (an upstream IdP's UTF-8 JSON token response), so it does not 

31defend against non-UTF-8 field content that cannot survive JSON parsing. 

32""" 

33 

34from __future__ import annotations 

35 

36import base64 

37from datetime import datetime, timedelta 

38from typing import Final, Literal, TypeAlias 

39 

40import jwt 

41from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError 

42 

43from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value 

44 

45ENVELOPE_PREFIX: Final = "llm_env_" 

46"""Marker prefix on every serialized ACCESS envelope so the edge can cheaply tell an envelope 

47from a raw upstream token before doing any cryptography.""" 

48 

49REFRESH_ENVELOPE_PREFIX: Final = "llm_refresh_" 

50"""Marker prefix on every serialized REFRESH envelope. A distinct prefix keeps the two credentials 

51routable without crypto and, together with the signed ``kind`` claim, stops one from being presented 

52where the other is expected: a refresh envelope carries a long-lived upstream refresh token and is only 

53ever presented back to the token endpoint, never forwarded upstream on a tool call.""" 

54 

55ENVELOPE_ISSUER: Final = "litellm-mcp-bridge" 

56"""``iss`` claim stamped into every envelope and required back on open.""" 

57 

58MAX_ENVELOPE_TTL_SECONDS: Final = 3600 

59"""Fallback ACCESS envelope lifetime when the upstream omits ``expires_in``. 

60 

61The historical exported name is retained for import compatibility. When the upstream 

62reports a positive lifetime, the envelope matches it so a renewal does not consume a 

63still-valid provider refresh grant.""" 

64 

65MAX_REFRESH_ENVELOPE_TTL_SECONDS: Final = 1209600 

66"""Hard ceiling on REFRESH envelope lifetime (14 days). A refresh envelope only renews the short-lived 

67access envelope, and each renewal re-validates the sealed litellm key (revocation gates it) and is 

68re-minted with a fresh window, so the practical bound is idle time, not a fixed session. ``exp`` is 

69``min(upstream refresh_expires_in, this cap)`` (the cap alone when the upstream omits it); if the 

70upstream refresh token dies first, the next renewal simply fails at the upstream and the client 

71re-authenticates. The value is deliberately far shorter than a typical upstream refresh-token lifetime 

72so a leaked refresh envelope is bounded even if the upstream would have honoured it for longer.""" 

73 

74MAX_ENVELOPE_BYTES: Final = 12288 

75"""Size cap on the final serialized envelope (prefix + JWT, in bytes). Upstream JWTs 

76commonly run 2-4KB; base64 plus encryption overhead roughly doubles that inside the 

77envelope, and common proxy/server header limits sit around 16KB total. 12288 leaves 

78comfortable headroom for a large upstream token while keeping the envelope safely 

79transmittable as a single Authorization header. Oversized grants are rejected with a 

80typed error, never truncated.""" 

81 

82_ENVELOPE_JWT_ALGORITHM: Final = "HS256" 

83 

84EnvelopeKind = Literal["access", "refresh"] 

85"""Which credential an envelope is. Stamped into the signed claims and required to match on open, so a 

86signature-valid envelope of one kind cannot be replayed as the other even if its wire prefix is swapped 

87(the prefix is not part of the signed payload; this claim is).""" 

88 

89 

90EnvelopeSubjectType: TypeAlias = Literal["key_hash", "user_id"] 

91"""Discriminator for what litellm principal the envelope binds the grant to. 

92 

93``key_hash`` is a hashed virtual key (the scripted two-header client mints under the key it 

94presents at the token endpoint); ``user_id`` is a litellm user subject (the interactive DCR 

95client mints under the SSO-authenticated user, which is the only identity that browser login 

96yields). Admission reloads a key record for the first and a user record for the second, then 

97runs both through the same live-policy gate, so team/org/budget/revocation enforcement is 

98identical either way.""" 

99 

100 

101class EnvelopeIdentity(BaseModel): 

102 """The litellm principal the envelope binds the inner grant to. 

103 

104 ``subject`` is the principal identifier and ``subject_type`` says how to resolve it: a 

105 hashed litellm key (``key_hash``) or a litellm user id (``user_id``), never a raw 

106 credential (and the edge rejects a bare hash or id presented as a bearer). Admission 

107 reloads the live record by it, so the principal's current team/org restrictions and its 

108 revocation state are enforced at use time rather than frozen at mint time. ``server_id`` 

109 binds the envelope to one MCP server so it cannot be replayed across a server boundary. 

110 """ 

111 

112 model_config = ConfigDict(frozen=True) 

113 server_id: str = Field(min_length=1) 

114 subject_type: EnvelopeSubjectType 

115 subject: str = Field(min_length=1) 

116 

117 

118def key_hash_identity(server_id: str, key_hash: str) -> EnvelopeIdentity: 

119 """The identity for the scripted client that mints under a presented virtual key.""" 

120 return EnvelopeIdentity(server_id=server_id, subject_type="key_hash", subject=key_hash) 

121 

122 

123def user_identity(server_id: str, user_id: str) -> EnvelopeIdentity: 

124 """The identity for the interactive DCR client that mints under its SSO user subject.""" 

125 return EnvelopeIdentity(server_id=server_id, subject_type="user_id", subject=user_id) 

126 

127 

128class UpstreamTokenGrant(BaseModel): 

129 """The upstream OAuth token response fields sealed inside the envelope. 

130 

131 ``expires_in`` must be positive when present; a non-positive value is a programmer 

132 error rejected at construction. Token fields are ``SecretStr`` so reprs never leak 

133 them. 

134 """ 

135 

136 model_config = ConfigDict(frozen=True) 

137 access_token: SecretStr = Field(min_length=1) 

138 token_type: str = Field(min_length=1) 

139 refresh_token: SecretStr | None = None 

140 scope: str | None = None 

141 expires_in: int | None = Field(default=None, gt=0) 

142 

143 

144class RefreshCredential(BaseModel): 

145 """The upstream refresh grant sealed inside a refresh envelope. 

146 

147 Only the refresh token (plus the scope to re-request and the refresh token's own lifetime, when the 

148 upstream reports it) is sealed; the access token is never in a refresh envelope. ``refresh_token`` is 

149 a ``SecretStr`` so reprs never leak it, and ``expires_in`` (the refresh token's lifetime, not the 

150 access token's) must be positive when present. 

151 """ 

152 

153 model_config = ConfigDict(frozen=True) 

154 refresh_token: SecretStr = Field(min_length=1) 

155 scope: str | None = None 

156 expires_in: int | None = Field(default=None, gt=0) 

157 

158 

159class EnvelopeKeys(BaseModel): 

160 """Injected key material: the HS256 signing key and the symmetric encryption key. 

161 

162 ``signing_key`` must be at least 32 bytes: HS256's HMAC-SHA256 has a 256-bit 

163 security level, RFC 7518 requires a key of at least that size, and a shorter key 

164 makes PyJWT emit ``InsecureKeyLengthWarning``. 

165 """ 

166 

167 model_config = ConfigDict(frozen=True) 

168 signing_key: SecretStr = Field(min_length=32) 

169 encryption_key: SecretStr = Field(min_length=1) 

170 

171 

172class SealedEnvelope(BaseModel): 

173 """A minted envelope: the client-held bearer value and when it expires.""" 

174 

175 model_config = ConfigDict(frozen=True) 

176 token: SecretStr 

177 expires_at: datetime 

178 

179 

180class OpenedEnvelope(BaseModel): 

181 """A validated access envelope: the identity it was minted for and the recovered grant.""" 

182 

183 model_config = ConfigDict(frozen=True) 

184 identity: EnvelopeIdentity 

185 grant: UpstreamTokenGrant 

186 

187 

188class OpenedRefreshEnvelope(BaseModel): 

189 """A validated refresh envelope: the identity it was minted for and the recovered refresh grant.""" 

190 

191 model_config = ConfigDict(frozen=True) 

192 identity: EnvelopeIdentity 

193 refresh: RefreshCredential 

194 

195 

196class EnvelopeTooLarge(BaseModel): 

197 """The serialized envelope exceeded ``MAX_ENVELOPE_BYTES``; carries sizes only.""" 

198 

199 model_config = ConfigDict(frozen=True) 

200 tag: Literal["envelope_too_large"] = "envelope_too_large" 

201 size_bytes: int 

202 max_bytes: int 

203 

204 

205class EnvelopeLifetimeUnrepresentable(BaseModel): 

206 """A positive provider lifetime cannot be represented as a Python datetime.""" 

207 

208 model_config = ConfigDict(frozen=True) 

209 tag: Literal["envelope_lifetime_unrepresentable"] = "envelope_lifetime_unrepresentable" 

210 expires_in: int 

211 

212 

213EnvelopeMintError: TypeAlias = EnvelopeTooLarge | EnvelopeLifetimeUnrepresentable 

214 

215 

216class NotAnEnvelope(BaseModel): 

217 """The candidate does not carry the envelope prefix.""" 

218 

219 model_config = ConfigDict(frozen=True) 

220 tag: Literal["not_an_envelope"] = "not_an_envelope" 

221 

222 

223class BadSignature(BaseModel): 

224 """The JWT signature does not verify under the provided signing key.""" 

225 

226 model_config = ConfigDict(frozen=True) 

227 tag: Literal["bad_signature"] = "bad_signature" 

228 

229 

230class Expired(BaseModel): 

231 """The envelope's ``exp`` is not in the future relative to the provided ``now``.""" 

232 

233 model_config = ConfigDict(frozen=True) 

234 tag: Literal["expired"] = "expired" 

235 

236 

237class MalformedPayload(BaseModel): 

238 """The token is not a well-formed envelope: undecodable JWT, wrong issuer, missing 

239 or mistyped claims, or a decrypted grant that fails validation.""" 

240 

241 model_config = ConfigDict(frozen=True) 

242 tag: Literal["malformed_payload"] = "malformed_payload" 

243 

244 

245class DecryptFailed(BaseModel): 

246 """The signed ``grant`` blob could not be decrypted under the provided key.""" 

247 

248 model_config = ConfigDict(frozen=True) 

249 tag: Literal["decrypt_failed"] = "decrypt_failed" 

250 

251 

252EnvelopeOpenError: TypeAlias = NotAnEnvelope | BadSignature | Expired | MalformedPayload | DecryptFailed 

253 

254 

255class _EnvelopeClaims(BaseModel): 

256 """Decoded-claims boundary that pins the exact shape :func:`mint_envelope` emits. 

257 

258 ``server_id``/``key_hash`` mirror the ``min_length`` constraints of 

259 :class:`EnvelopeIdentity` so any claim set that validates here also constructs an 

260 identity, keeping :func:`open_envelope` raise-free: a correctly signed JWT with an 

261 empty identity claim fails here and maps to ``MalformedPayload``. 

262 

263 ``strict`` rejects coerced types (``exp: "123"``, ``exp: 123.0``) rather than opening 

264 on them, and ``extra="forbid"`` rejects any claim the gateway never mints (a hostile 

265 ``nbf``/``aud``/... rides along on a re-signed token). Since PyJWT's own ``iat``/ 

266 ``nbf``/``exp`` validators are disabled at decode (they raise on hostile claim types 

267 and, for ``iat``/``nbf``, compare against the wall clock rather than the injected 

268 ``now``), this model is the sole, total type gate for every registered claim. 

269 """ 

270 

271 model_config = ConfigDict(frozen=True, strict=True, extra="forbid") 

272 iss: str 

273 iat: int 

274 exp: int 

275 kind: EnvelopeKind 

276 server_id: str = Field(min_length=1) 

277 subject_type: EnvelopeSubjectType 

278 subject: str = Field(min_length=1) 

279 grant: str = Field(min_length=1) 

280 

281 

282class _GrantWire(BaseModel): 

283 model_config = ConfigDict(frozen=True) 

284 access_token: str 

285 token_type: str 

286 refresh_token: str | None = None 

287 scope: str | None = None 

288 expires_in: int | None = None 

289 

290 

291class _RefreshWire(BaseModel): 

292 model_config = ConfigDict(frozen=True) 

293 refresh_token: str 

294 scope: str | None = None 

295 expires_in: int | None = None 

296 

297 

298def is_envelope(candidate: str) -> bool: 

299 """Cheap prefix check for an ACCESS envelope so the edge can route envelopes vs raw tokens without 

300 crypto. A refresh envelope has a different prefix and is not an access envelope.""" 

301 return candidate.startswith(ENVELOPE_PREFIX) 

302 

303 

304def is_refresh_envelope(candidate: str) -> bool: 

305 """Cheap prefix check for a REFRESH envelope so the token endpoint can route a refresh grant that 

306 carries an envelope vs a raw upstream refresh token without crypto.""" 

307 return candidate.startswith(REFRESH_ENVELOPE_PREFIX) 

308 

309 

310def mint_envelope( 

311 identity: EnvelopeIdentity, 

312 grant: UpstreamTokenGrant, 

313 keys: EnvelopeKeys, 

314 now: datetime, 

315) -> SealedEnvelope | EnvelopeMintError: 

316 """Seal ``grant`` for ``identity`` into a client-held envelope. 

317 

318 ``exp`` is ``grant.expires_in`` seconds from ``now`` when the upstream reports a 

319 lifetime, or ``MAX_ENVELOPE_TTL_SECONDS`` when it does not. Returns 

320 ``EnvelopeLifetimeUnrepresentable`` when that positive lifetime cannot be represented 

321 as a Python datetime, or ``EnvelopeTooLarge`` when the serialized envelope exceeds 

322 ``MAX_ENVELOPE_BYTES``. 

323 """ 

324 ttl_seconds: Final = _envelope_ttl_seconds(grant.expires_in) 

325 try: 

326 expires_at: Final = now + timedelta(seconds=ttl_seconds) 

327 except OverflowError: 

328 return EnvelopeLifetimeUnrepresentable(expires_in=ttl_seconds) 

329 return _seal( 

330 kind="access", 

331 prefix=ENVELOPE_PREFIX, 

332 identity=identity, 

333 grant_blob=_encrypt_grant_blob(_grant_plaintext(grant), keys.encryption_key), 

334 expires_at=expires_at, 

335 signing_key=keys.signing_key, 

336 now=now, 

337 ) 

338 

339 

340def open_envelope( 

341 candidate: str, 

342 keys: EnvelopeKeys, 

343 now: datetime, 

344) -> OpenedEnvelope | EnvelopeOpenError: 

345 """Validate ``candidate`` and recover the identity and inner grant. 

346 

347 Never raises for bad input: every invalid, expired, tampered, or undecryptable 

348 candidate maps to a distinct ``EnvelopeOpenError`` variant. The recovered 

349 ``grant.expires_in`` is the value the upstream reported at mint time and is not 

350 re-derived, so it is stale by up to the envelope's lifetime; callers that need a 

351 live remaining lifetime should use ``now`` against the upstream, not this field. 

352 """ 

353 claims: Final = _open_claims(candidate, prefix=ENVELOPE_PREFIX, expected_kind="access", keys=keys, now=now) 

354 if not isinstance(claims, _EnvelopeClaims): 

355 return claims 

356 grant: Final = _decrypt_grant(claims.grant, keys.encryption_key) 

357 if not isinstance(grant, UpstreamTokenGrant): 

358 return grant 

359 return OpenedEnvelope( 

360 identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject), 

361 grant=grant, 

362 ) 

363 

364 

365def mint_refresh_envelope( 

366 identity: EnvelopeIdentity, 

367 refresh: RefreshCredential, 

368 keys: EnvelopeKeys, 

369 now: datetime, 

370) -> SealedEnvelope | EnvelopeMintError: 

371 """Seal ``refresh`` for ``identity`` into a long-lived, client-held refresh envelope. 

372 

373 ``exp`` is ``min(refresh.expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS)`` seconds from ``now`` (the 

374 cap alone when the upstream omits the refresh lifetime). Sealing a distinct ``kind="refresh"`` claim 

375 is what keeps a refresh envelope from ever opening as an access credential at the MCP edge. Returns 

376 ``EnvelopeTooLarge`` when the serialized envelope exceeds ``MAX_ENVELOPE_BYTES``. 

377 """ 

378 expires_at: Final = now + timedelta(seconds=_refresh_ttl_seconds(refresh.expires_in)) 

379 return _seal( 

380 kind="refresh", 

381 prefix=REFRESH_ENVELOPE_PREFIX, 

382 identity=identity, 

383 grant_blob=_encrypt_grant_blob(_refresh_plaintext(refresh), keys.encryption_key), 

384 expires_at=expires_at, 

385 signing_key=keys.signing_key, 

386 now=now, 

387 ) 

388 

389 

390def open_refresh_envelope( 

391 candidate: str, 

392 keys: EnvelopeKeys, 

393 now: datetime, 

394) -> OpenedRefreshEnvelope | EnvelopeOpenError: 

395 """Validate a refresh ``candidate`` and recover the identity and inner refresh grant. 

396 

397 Total over hostile input exactly like :func:`open_envelope`: every invalid, expired, tampered, 

398 wrong-kind, or undecryptable candidate maps to a distinct ``EnvelopeOpenError`` variant, never a 

399 raise. The ``kind="refresh"`` claim is required, so an access envelope re-prefixed as a refresh one 

400 is rejected as ``MalformedPayload``. 

401 """ 

402 claims: Final = _open_claims(candidate, prefix=REFRESH_ENVELOPE_PREFIX, expected_kind="refresh", keys=keys, now=now) 

403 if not isinstance(claims, _EnvelopeClaims): 

404 return claims 

405 refresh: Final = _decrypt_refresh(claims.grant, keys.encryption_key) 

406 if not isinstance(refresh, RefreshCredential): 

407 return refresh 

408 return OpenedRefreshEnvelope( 

409 identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject), 

410 refresh=refresh, 

411 ) 

412 

413 

414def _seal( 

415 kind: EnvelopeKind, 

416 prefix: str, 

417 identity: EnvelopeIdentity, 

418 grant_blob: str, 

419 expires_at: datetime, 

420 signing_key: SecretStr, 

421 now: datetime, 

422) -> SealedEnvelope | EnvelopeTooLarge: 

423 """Sign the claims for either envelope kind and enforce the size cap. Shared by both mints so the 

424 JWT shape, issuer, and size guard cannot drift between access and refresh envelopes.""" 

425 claims: Final = _EnvelopeClaims( 

426 iss=ENVELOPE_ISSUER, 

427 iat=int(now.timestamp()), 

428 exp=int(expires_at.timestamp()), 

429 kind=kind, 

430 server_id=identity.server_id, 

431 subject_type=identity.subject_type, 

432 subject=identity.subject, 

433 grant=grant_blob, 

434 ) 

435 token = prefix + jwt.encode(claims.model_dump(), signing_key.get_secret_value(), algorithm=_ENVELOPE_JWT_ALGORITHM) 

436 size_bytes: Final = len(token.encode("utf-8")) 

437 if size_bytes > MAX_ENVELOPE_BYTES: 

438 return EnvelopeTooLarge(size_bytes=size_bytes, max_bytes=MAX_ENVELOPE_BYTES) 

439 return SealedEnvelope(token=SecretStr(token), expires_at=expires_at) 

440 

441 

442def _open_claims( 

443 candidate: str, 

444 prefix: str, 

445 expected_kind: EnvelopeKind, 

446 keys: EnvelopeKeys, 

447 now: datetime, 

448) -> _EnvelopeClaims | EnvelopeOpenError: 

449 """Prefix-route, size-bound, signature-verify, kind-check, and expiry-check an attacker-controlled 

450 candidate, shared by both openers so the security gate is identical for access and refresh. Returns 

451 the validated claims or a distinct ``EnvelopeOpenError``; never raises.""" 

452 if not candidate.startswith(prefix): 

453 return NotAnEnvelope() 

454 # UTF-8 byte length is never below character length, so a character count already over the cap 

455 # rejects an oversize candidate in O(1) without encoding it; the exact byte check then runs only on 

456 # candidates already bounded to <= MAX_ENVELOPE_BYTES characters. 

457 if len(candidate) > MAX_ENVELOPE_BYTES: 

458 return MalformedPayload() 

459 if len(candidate.encode("utf-8", "surrogatepass")) > MAX_ENVELOPE_BYTES: 

460 return MalformedPayload() 

461 claims: Final = _decode_claims(candidate.removeprefix(prefix), keys.signing_key) 

462 if not isinstance(claims, _EnvelopeClaims): 

463 return claims 

464 if claims.kind != expected_kind: 

465 return MalformedPayload() 

466 if now.timestamp() >= claims.exp: 

467 return Expired() 

468 return claims 

469 

470 

471def _envelope_ttl_seconds(upstream_expires_in: int | None) -> int: 

472 if upstream_expires_in is None: 

473 return MAX_ENVELOPE_TTL_SECONDS 

474 return upstream_expires_in 

475 

476 

477def _refresh_ttl_seconds(upstream_refresh_expires_in: int | None) -> int: 

478 if upstream_refresh_expires_in is None: 

479 return MAX_REFRESH_ENVELOPE_TTL_SECONDS 

480 return min(upstream_refresh_expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS) 

481 

482 

483def _grant_plaintext(grant: UpstreamTokenGrant) -> str: 

484 wire: Final = _GrantWire( 

485 access_token=grant.access_token.get_secret_value(), 

486 token_type=grant.token_type, 

487 refresh_token=None if grant.refresh_token is None else grant.refresh_token.get_secret_value(), 

488 scope=grant.scope, 

489 expires_in=grant.expires_in, 

490 ) 

491 return wire.model_dump_json(exclude_none=True) 

492 

493 

494def _refresh_plaintext(refresh: RefreshCredential) -> str: 

495 wire: Final = _RefreshWire( 

496 refresh_token=refresh.refresh_token.get_secret_value(), 

497 scope=refresh.scope, 

498 expires_in=refresh.expires_in, 

499 ) 

500 return wire.model_dump_json(exclude_none=True) 

501 

502 

503def _decode_claims( 

504 compact: str, 

505 signing_key: SecretStr, 

506) -> _EnvelopeClaims | BadSignature | MalformedPayload: 

507 """Verify the HS256 signature and shape of an attacker-controlled compact JWT. 

508 

509 ``compact`` is fully hostile and bounded to ``MAX_ENVELOPE_BYTES`` by the caller. 

510 PyJWT's ``iat``/``nbf``/``exp`` validators are disabled: they raise on hostile claim 

511 types and, for ``iat``/``nbf``, compare against the wall clock rather than the 

512 injected ``now`` (``exp`` is checked by the caller against ``now``). Apart from a 

513 signature mismatch (``BadSignature``), every decode failure is ``MalformedPayload``: 

514 a non-UTF-8 candidate surfaces as ``UnicodeEncodeError`` (a ``ValueError``), a 

515 non-string registered claim such as ``iss`` as a ``TypeError`` from PyJWT's claim 

516 validators, and a wrong issuer or structurally invalid token as an 

517 ``InvalidTokenError``. ``_EnvelopeClaims`` is the total type gate for the payload. 

518 """ 

519 try: 

520 payload: Final = jwt.decode( 

521 compact, 

522 signing_key.get_secret_value(), 

523 algorithms=[_ENVELOPE_JWT_ALGORITHM], 

524 issuer=ENVELOPE_ISSUER, 

525 options={ 

526 "verify_exp": False, 

527 "verify_iat": False, 

528 "verify_nbf": False, 

529 "require": ["iss", "iat", "exp"], 

530 }, 

531 ) 

532 except jwt.InvalidSignatureError: 

533 return BadSignature() 

534 except (jwt.InvalidTokenError, ValueError, TypeError): 

535 return MalformedPayload() 

536 try: 

537 return _EnvelopeClaims.model_validate(payload) 

538 except ValidationError: 

539 return MalformedPayload() 

540 

541 

542def _encrypt_grant_blob(plaintext: str, encryption_key: SecretStr) -> str: 

543 ciphertext: Final = bytes(encrypt_value(value=plaintext, signing_key=encryption_key.get_secret_value())) 

544 return base64.urlsafe_b64encode(ciphertext).decode("ascii") 

545 

546 

547def _decrypt_grant( 

548 blob: str, 

549 encryption_key: SecretStr, 

550) -> UpstreamTokenGrant | DecryptFailed | MalformedPayload: 

551 from nacl.exceptions import CryptoError 

552 

553 try: 

554 plaintext: Final = decrypt_value( 

555 value=base64.urlsafe_b64decode(blob), 

556 signing_key=encryption_key.get_secret_value(), 

557 ) 

558 except (CryptoError, ValueError): 

559 return DecryptFailed() 

560 try: 

561 return UpstreamTokenGrant.model_validate_json(plaintext) 

562 except ValidationError: 

563 return MalformedPayload() 

564 

565 

566def _decrypt_refresh( 

567 blob: str, 

568 encryption_key: SecretStr, 

569) -> RefreshCredential | DecryptFailed | MalformedPayload: 

570 from nacl.exceptions import CryptoError 

571 

572 try: 

573 plaintext: Final = decrypt_value( 

574 value=base64.urlsafe_b64decode(blob), 

575 signing_key=encryption_key.get_secret_value(), 

576 ) 

577 except (CryptoError, ValueError): 

578 return DecryptFailed() 

579 try: 

580 return RefreshCredential.model_validate_json(plaintext) 

581 except ValidationError: 

582 return MalformedPayload()