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

76 statements  

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

1"""Producer and consumer helpers for the DCR-bridge ``oauth_delegate`` envelope. 

2 

3A DCR-bridge ``oauth_delegate`` client presents ONE bearer that is a litellm-signed 

4envelope (see :mod:`.envelope`) carrying both a litellm identity and the upstream OAuth 

5token. The gateway token endpoint mints it (producer) at OAuth issuance, and at the MCP 

6admission edge the gateway derives the envelope keys from the proxy ``master_key``, opens 

7it, admits the request under the recovered identity, and forwards the inner upstream token 

8to the upstream MCP server (consumer). This module is the pure surface for both sides; the 

9token-endpoint and admission wiring live in their respective call sites. 

10""" 

11 

12import hashlib 

13from datetime import datetime 

14from functools import lru_cache 

15from typing import Final, Literal, TypeAlias 

16 

17from pydantic import BaseModel, ConfigDict, SecretStr 

18 

19from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( 

20 EnvelopeIdentity, 

21 EnvelopeKeys, 

22 EnvelopeMintError, 

23 OpenedEnvelope, 

24 OpenedRefreshEnvelope, 

25 RefreshCredential, 

26 SealedEnvelope, 

27 UpstreamTokenGrant, 

28 is_envelope, 

29 is_refresh_envelope, 

30 mint_envelope, 

31 mint_refresh_envelope, 

32 open_envelope, 

33 open_refresh_envelope, 

34) 

35 

36_SIGNING_KEY_DOMAIN: Final = b"litellm-mcp-bridge:envelope-signing:" 

37_ENCRYPTION_KEY_DOMAIN: Final = b"litellm-mcp-bridge:envelope-encryption:" 

38 

39# scrypt work factors (RFC 7914). n=2**15 with r=8/p=1 costs ~50ms and ~32MB per derivation, which 

40# makes offline guessing of a candidate master key memory-hard rather than a bare hash comparison. 

41_SCRYPT_N: Final = 2**15 

42_SCRYPT_R: Final = 8 

43_SCRYPT_P: Final = 1 

44# scrypt's working-set is ~128 * N * r * p bytes; cap at twice that so the maxmem ceiling scales 

45# with every work factor and a future p or r bump does not trip "memory limit exceeded". 

46_SCRYPT_MAXMEM: Final = 128 * _SCRYPT_N * _SCRYPT_R * _SCRYPT_P * 2 

47_DERIVED_KEY_BYTES: Final = 32 

48 

49 

50@lru_cache(maxsize=8) 

51def envelope_keys_from_master_key(master_key: str) -> EnvelopeKeys: 

52 """Derive the envelope signing and encryption keys from the proxy master key. 

53 

54 A memory-hard scrypt KDF (RFC 7914) over two distinct domain-label salts yields two 

55 independent 256-bit subkeys from the one secret, so the producer (mint) and consumer 

56 (open) agree on keys without persisting any. scrypt is used rather than a bare hash or 

57 HMAC so that a captured envelope is not a cheap offline oracle for the master key: each 

58 candidate guess costs a full memory-hard derivation, which is what protects a deployment 

59 whose master key is weaker than it should be. The result is cached (the master key is 

60 fixed for a process), so the KDF runs once per key and adds nothing to the per-request 

61 admission path. The derivation is deterministic; rotating ``master_key`` invalidates 

62 every outstanding envelope, which is the intended behavior for a signing-key change. 

63 """ 

64 signing: Final = hashlib.scrypt( 

65 master_key.encode(), 

66 salt=_SIGNING_KEY_DOMAIN, 

67 n=_SCRYPT_N, 

68 r=_SCRYPT_R, 

69 p=_SCRYPT_P, 

70 maxmem=_SCRYPT_MAXMEM, 

71 dklen=_DERIVED_KEY_BYTES, 

72 ).hex() 

73 encryption: Final = hashlib.scrypt( 

74 master_key.encode(), 

75 salt=_ENCRYPTION_KEY_DOMAIN, 

76 n=_SCRYPT_N, 

77 r=_SCRYPT_R, 

78 p=_SCRYPT_P, 

79 maxmem=_SCRYPT_MAXMEM, 

80 dklen=_DERIVED_KEY_BYTES, 

81 ).hex() 

82 return EnvelopeKeys(signing_key=SecretStr(signing), encryption_key=SecretStr(encryption)) 

83 

84 

85def build_bridge_token_response( 

86 identity: EnvelopeIdentity, 

87 grant: UpstreamTokenGrant, 

88 keys: EnvelopeKeys, 

89 now: datetime, 

90) -> SealedEnvelope | EnvelopeMintError: 

91 """Seal ``grant`` for ``identity`` into the client-held bearer the token endpoint returns. 

92 

93 The producer mirror of :func:`resolve_bridge_envelope`: a thin, pure wrapper over 

94 :func:`mint_envelope` that returns the sealed envelope, or the mint error as a value 

95 for the caller to map onto an OAuth error response. 

96 """ 

97 return mint_envelope(identity, grant, keys, now) 

98 

99 

100def build_bridge_refresh_token_response( 

101 identity: EnvelopeIdentity, 

102 refresh: RefreshCredential, 

103 keys: EnvelopeKeys, 

104 now: datetime, 

105) -> SealedEnvelope | EnvelopeMintError: 

106 """Seal ``refresh`` for ``identity`` into the long-lived refresh envelope the token endpoint returns 

107 alongside the access envelope, so the client can renew without re-authenticating. A thin, pure 

108 wrapper over :func:`mint_refresh_envelope`; returns the mint error as a value for the caller to map. 

109 """ 

110 return mint_refresh_envelope(identity, refresh, keys, now) 

111 

112 

113class BridgeRefreshOpened(BaseModel): 

114 """A valid refresh envelope presented to the token endpoint: the identity to re-validate and renew 

115 under, and the upstream refresh grant to exchange.""" 

116 

117 model_config = ConfigDict(frozen=True) 

118 tag: Literal["opened"] = "opened" 

119 identity: EnvelopeIdentity 

120 refresh: RefreshCredential 

121 

122 

123class BridgeRefreshInvalid(BaseModel): 

124 """The presented refresh grant is not a valid refresh envelope for this server (not refresh-shaped, 

125 will not open, or minted for a different server); the token endpoint fails the refresh closed.""" 

126 

127 model_config = ConfigDict(frozen=True) 

128 tag: Literal["invalid"] = "invalid" 

129 

130 

131BridgeRefreshResult: TypeAlias = BridgeRefreshOpened | BridgeRefreshInvalid 

132 

133 

134def open_bridge_refresh_envelope( 

135 refresh_value: str, 

136 keys: EnvelopeKeys, 

137 now: datetime, 

138 expected_server_id: str, 

139) -> BridgeRefreshResult: 

140 """Open a refresh envelope a bridge ``oauth_delegate`` client presented on a refresh_token grant. 

141 

142 The token-endpoint mirror of :func:`resolve_bridge_envelope`: strips an optional ``Bearer`` scheme, 

143 then returns ``BridgeRefreshOpened`` with the recovered identity and upstream refresh grant, or 

144 ``BridgeRefreshInvalid`` for anything that is not a valid refresh envelope for this server. Never 

145 raises; total over hostile input via :func:`open_refresh_envelope`. ``expected_server_id`` binds the 

146 envelope to the server the request targets, so a refresh envelope minted for one server cannot renew 

147 against another. A raw upstream refresh token (not envelope-shaped) is ``BridgeRefreshInvalid``: this 

148 mode never hands the client a bare upstream refresh token, so it must never accept one. 

149 """ 

150 candidate: Final = _strip_bearer(refresh_value) 

151 if not is_refresh_envelope(candidate): 

152 return BridgeRefreshInvalid() 

153 opened: Final = open_refresh_envelope(candidate, keys, now) 

154 if not isinstance(opened, OpenedRefreshEnvelope): 

155 return BridgeRefreshInvalid() 

156 if opened.identity.server_id != expected_server_id: 

157 return BridgeRefreshInvalid() 

158 return BridgeRefreshOpened(identity=opened.identity, refresh=opened.refresh) 

159 

160 

161class NotBridgeEnvelope(BaseModel): 

162 """The bearer is not an envelope; admission continues on its normal path.""" 

163 

164 model_config = ConfigDict(frozen=True) 

165 tag: Literal["not_bridge_envelope"] = "not_bridge_envelope" 

166 

167 

168class BridgeEnvelopeAdmitted(BaseModel): 

169 """A valid envelope: the identity to admit under and the full upstream ``Authorization`` 

170 value (``token_type access_token``) to forward to the upstream MCP server.""" 

171 

172 model_config = ConfigDict(frozen=True) 

173 tag: Literal["admitted"] = "admitted" 

174 identity: EnvelopeIdentity 

175 upstream_authorization: SecretStr 

176 

177 

178class BridgeEnvelopeInvalid(BaseModel): 

179 """The bearer is envelope-shaped but did not open (expired, tampered, wrong key); 

180 admission must fail closed rather than fall through to normal validation.""" 

181 

182 model_config = ConfigDict(frozen=True) 

183 tag: Literal["invalid"] = "invalid" 

184 

185 

186BridgeEnvelopeResult: TypeAlias = NotBridgeEnvelope | BridgeEnvelopeAdmitted | BridgeEnvelopeInvalid 

187 

188 

189def _strip_bearer(value: str) -> str: 

190 parts: Final = value.split(None, 1) 

191 if len(parts) == 2 and parts[0].lower() == "bearer": 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 return parts[1] 

193 return value 

194 

195 

196def is_bridge_envelope_shaped(authorization_value: str) -> bool: 

197 """Cheap, keyless test that an ``Authorization`` value carries an envelope of either kind (optional 

198 ``Bearer`` scheme stripped). The admission edge engages the bridge arm for an access envelope (to 

199 admit) and for a refresh envelope (to reject it explicitly, since a refresh credential is never 

200 usable at the tool-call edge); a plain upstream bearer falls through to normal oauth2 admission.""" 

201 candidate: Final = _strip_bearer(authorization_value) 

202 return is_envelope(candidate) or is_refresh_envelope(candidate) 

203 

204 

205def resolve_bridge_envelope( 

206 authorization_value: str, 

207 keys: EnvelopeKeys, 

208 now: datetime, 

209 expected_server_id: str, 

210) -> BridgeEnvelopeResult: 

211 """Classify an ``Authorization`` value presented to a bridge ``oauth_delegate`` server. 

212 

213 Strips an optional ``Bearer`` scheme, then returns ``NotBridgeEnvelope`` for a 

214 non-envelope bearer (normal admission continues), ``BridgeEnvelopeAdmitted`` with the 

215 recovered identity and the upstream ``Authorization`` value to forward for a valid 

216 envelope, and ``BridgeEnvelopeInvalid`` for an envelope-shaped bearer that will not 

217 open. Never raises: it is total over hostile input via :func:`open_envelope`. 

218 

219 A refresh envelope is ``BridgeEnvelopeInvalid`` here: it is a valid gateway credential but only ever 

220 presented back to the token endpoint, never usable to authenticate a tool call, so admission must 

221 fail it closed rather than let it fall through to another arm. 

222 

223 ``expected_server_id`` is the ``server_id`` of the MCP server the request targets; an 

224 opened envelope whose sealed ``server_id`` does not match is rejected as 

225 ``BridgeEnvelopeInvalid``. Binding here (rather than leaving it to the caller) prevents 

226 replaying an envelope minted for one server against another, which would forward the 

227 first server's upstream credential across a server boundary. ``server_id`` is not a 

228 secret (the caller targets that server), so a plain equality check is sufficient and, 

229 unlike ``hmac.compare_digest`` on ``str``, does not raise on a non-ASCII server_id. 

230 """ 

231 candidate: Final = _strip_bearer(authorization_value) 

232 if is_refresh_envelope(candidate): 

233 return BridgeEnvelopeInvalid() 

234 if not is_envelope(candidate): 

235 return NotBridgeEnvelope() 

236 opened: Final = open_envelope(candidate, keys, now) 

237 if not isinstance(opened, OpenedEnvelope): 

238 return BridgeEnvelopeInvalid() 

239 if opened.identity.server_id != expected_server_id: 

240 return BridgeEnvelopeInvalid() 

241 grant: Final = opened.grant 

242 authorization_scheme: Final = "Bearer" if grant.token_type.lower() == "bearer" else grant.token_type 

243 upstream_authorization: Final = f"{authorization_scheme} {grant.access_token.get_secret_value()}" 

244 return BridgeEnvelopeAdmitted(identity=opened.identity, upstream_authorization=SecretStr(upstream_authorization))