virtual-key-mapper 0.1.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.
- virtual_key_mapper/__init__.py +3 -0
- virtual_key_mapper/__main__.py +3 -0
- virtual_key_mapper/adapters.py +115 -0
- virtual_key_mapper/auth/__init__.py +1 -0
- virtual_key_mapper/auth/forwarded.py +155 -0
- virtual_key_mapper/auth/litellm_oauth.py +404 -0
- virtual_key_mapper/auth/litellm_sso.py +296 -0
- virtual_key_mapper/cli.py +875 -0
- virtual_key_mapper/errors.py +2 -0
- virtual_key_mapper/gateways/__init__.py +1 -0
- virtual_key_mapper/gateways/litellm.py +92 -0
- virtual_key_mapper/integrations/__init__.py +1 -0
- virtual_key_mapper/integrations/claude.py +371 -0
- virtual_key_mapper/integrations/codex.py +190 -0
- virtual_key_mapper/integrations/common.py +115 -0
- virtual_key_mapper/integrations/opencode.py +320 -0
- virtual_key_mapper/labels.py +742 -0
- virtual_key_mapper/profiles.py +339 -0
- virtual_key_mapper/registry.py +74 -0
- virtual_key_mapper/repositories.py +263 -0
- virtual_key_mapper/resolve.py +238 -0
- virtual_key_mapper/rules.py +297 -0
- virtual_key_mapper/shells.py +116 -0
- virtual_key_mapper/storage.py +234 -0
- virtual_key_mapper/transport.py +130 -0
- virtual_key_mapper-0.1.0.dist-info/METADATA +450 -0
- virtual_key_mapper-0.1.0.dist-info/RECORD +31 -0
- virtual_key_mapper-0.1.0.dist-info/WHEEL +5 -0
- virtual_key_mapper-0.1.0.dist-info/entry_points.txt +2 -0
- virtual_key_mapper-0.1.0.dist-info/licenses/LICENSE +202 -0
- virtual_key_mapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from typing import Callable
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
13
|
+
|
|
14
|
+
from ..errors import CredentialError
|
|
15
|
+
from ..profiles import Profile, gateway_origin
|
|
16
|
+
from ..transport import JsonTransport, printable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def team_id(profile: Profile) -> str:
|
|
20
|
+
"""The LiteLLM team chosen at browser consent; validated by the litellm-sso auth kind."""
|
|
21
|
+
return profile.auth["team_id"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def required_text(data: dict, name: str) -> str:
|
|
25
|
+
value = data.get(name)
|
|
26
|
+
if not isinstance(value, str) or not value or len(value) > 65536:
|
|
27
|
+
raise CredentialError(f"Gateway response is missing a valid {name}.")
|
|
28
|
+
if any(ord(char) < 32 for char in value):
|
|
29
|
+
raise CredentialError(f"Gateway returned an invalid {name}.")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Discovery:
|
|
35
|
+
"""Validated version-1 native LiteLLM OAuth endpoints on the configured gateway origin."""
|
|
36
|
+
|
|
37
|
+
issuer: str
|
|
38
|
+
authorization_endpoint: str
|
|
39
|
+
registration_endpoint: str
|
|
40
|
+
token_endpoint: str
|
|
41
|
+
revocation_endpoint: str
|
|
42
|
+
resource: str
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def parse(cls, data: dict, profile: Profile) -> "Discovery":
|
|
46
|
+
if type(data.get("contract_version")) is not int or data["contract_version"] != 1:
|
|
47
|
+
raise CredentialError("Gateway does not expose the supported LiteLLM native login contract v1.")
|
|
48
|
+
for key, required in (
|
|
49
|
+
("code_challenge_methods_supported", {"S256"}),
|
|
50
|
+
("response_types_supported", {"code"}),
|
|
51
|
+
("grant_types_supported", {"authorization_code", "refresh_token"}),
|
|
52
|
+
("token_endpoint_auth_methods_supported", {"none"}),
|
|
53
|
+
("revocation_endpoint_auth_methods_supported", {"none"}),
|
|
54
|
+
):
|
|
55
|
+
values = data.get(key)
|
|
56
|
+
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
|
57
|
+
raise CredentialError(f"Gateway discovery has invalid {key}.")
|
|
58
|
+
if not required.issubset(values):
|
|
59
|
+
raise CredentialError(f"Gateway does not support required {key}.")
|
|
60
|
+
endpoints = {}
|
|
61
|
+
for key in ("issuer", "resource"):
|
|
62
|
+
endpoints[key] = _require_gateway_root(required_text(data, key), key, profile)
|
|
63
|
+
for key in ("authorization_endpoint", "registration_endpoint", "token_endpoint", "revocation_endpoint"):
|
|
64
|
+
value = required_text(data, key)
|
|
65
|
+
_require_gateway_url(value, profile)
|
|
66
|
+
endpoints[key] = value
|
|
67
|
+
return cls(**endpoints)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IdentityChanged(CredentialError):
|
|
71
|
+
"""A renewal returned a grant for another employee or team; the session must not be used any more."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_SESSION_FIELDS = (
|
|
75
|
+
"gateway", "team_id", "user_id", "client_id", "access_token", "refresh_token",
|
|
76
|
+
"issued_at", "expires_at", "token_endpoint", "revocation_endpoint", "resource",
|
|
77
|
+
"needs_login", "blocked", "retry_after", "refresh_failures",
|
|
78
|
+
)
|
|
79
|
+
_ENDPOINT_FIELDS = ("token_endpoint", "revocation_endpoint", "resource")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Session:
|
|
84
|
+
"""A single employee/team OAuth grant and the endpoints that renew or revoke it; credentials are excluded from repr.
|
|
85
|
+
|
|
86
|
+
Renewal state: ``needs_login`` means the gateway refused the refresh grant, so no further renewal is
|
|
87
|
+
attempted while the access token is still released until it nearly expires. ``blocked`` means a renewal
|
|
88
|
+
changed identity; nothing is released. ``retry_after``/``refresh_failures`` back off temporary failures.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
gateway: str
|
|
92
|
+
team_id: str
|
|
93
|
+
user_id: str
|
|
94
|
+
client_id: str
|
|
95
|
+
access_token: str = field(repr=False)
|
|
96
|
+
refresh_token: str = field(repr=False)
|
|
97
|
+
issued_at: float
|
|
98
|
+
expires_at: float
|
|
99
|
+
token_endpoint: str
|
|
100
|
+
revocation_endpoint: str
|
|
101
|
+
resource: str
|
|
102
|
+
needs_login: bool = False
|
|
103
|
+
blocked: bool = False
|
|
104
|
+
retry_after: float | None = None
|
|
105
|
+
refresh_failures: int = 0
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_response(cls, data: dict, profile: Profile, client_id: str, endpoints: "Discovery | Session") -> "Session":
|
|
109
|
+
if required_text(data, "token_type").lower() != "bearer":
|
|
110
|
+
raise CredentialError("Gateway returned an unsupported token type.")
|
|
111
|
+
expiry = data.get("expires_in")
|
|
112
|
+
if type(expiry) not in (int, float) or not math.isfinite(expiry) or expiry <= 0:
|
|
113
|
+
raise CredentialError("Gateway returned an invalid token expiry.")
|
|
114
|
+
now = time.time()
|
|
115
|
+
return cls(
|
|
116
|
+
profile.gateway,
|
|
117
|
+
required_text(data, "team_id"),
|
|
118
|
+
required_text(data, "user_id"),
|
|
119
|
+
client_id,
|
|
120
|
+
required_text(data, "access_token"),
|
|
121
|
+
required_text(data, "refresh_token"),
|
|
122
|
+
now,
|
|
123
|
+
now + expiry,
|
|
124
|
+
endpoints.token_endpoint,
|
|
125
|
+
endpoints.revocation_endpoint,
|
|
126
|
+
endpoints.resource,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def check_binding(self, profile: Profile) -> None:
|
|
130
|
+
"""Require this grant to belong to the profile's gateway and team, with endpoints on that gateway."""
|
|
131
|
+
if self.gateway != profile.gateway or self.team_id != team_id(profile):
|
|
132
|
+
raise CredentialError(
|
|
133
|
+
f"Credential team does not match profile '{profile.name}'. "
|
|
134
|
+
"Log in again and choose its configured LiteLLM team."
|
|
135
|
+
)
|
|
136
|
+
for name in _ENDPOINT_FIELDS:
|
|
137
|
+
_require_gateway_url(getattr(self, name), profile)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def lifetime(self) -> float:
|
|
141
|
+
return self.expires_at - self.issued_at
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
return {"version": 1, **self.__dict__}
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_dict(cls, data: dict) -> "Session":
|
|
148
|
+
if set(data) != {"version", *_SESSION_FIELDS} or type(data["version"]) is not int or data["version"] != 1:
|
|
149
|
+
raise CredentialError("Unsupported stored session; log in again.")
|
|
150
|
+
for name in _SESSION_FIELDS[:-4]:
|
|
151
|
+
if name not in ("issued_at", "expires_at", "needs_login"):
|
|
152
|
+
required_text(data, name)
|
|
153
|
+
|
|
154
|
+
def number(value) -> bool:
|
|
155
|
+
return type(value) in (float, int) and math.isfinite(value)
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
not all(number(data[name]) for name in ("issued_at", "expires_at"))
|
|
159
|
+
or data["issued_at"] > data["expires_at"]
|
|
160
|
+
or any(type(data[name]) is not bool for name in ("needs_login", "blocked"))
|
|
161
|
+
or (data["retry_after"] is not None and not number(data["retry_after"]))
|
|
162
|
+
or type(data["refresh_failures"]) is not int or data["refresh_failures"] < 0
|
|
163
|
+
):
|
|
164
|
+
raise CredentialError("Invalid stored session expiry/state; log in again.")
|
|
165
|
+
return cls(**{name: data[name] for name in _SESSION_FIELDS})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _require_gateway_root(value: str, key: str, profile: Profile) -> str:
|
|
169
|
+
"""The issuer and resource name the gateway itself; LiteLLM derives them from PROXY_BASE_URL or the request."""
|
|
170
|
+
try:
|
|
171
|
+
origin = gateway_origin(value, profile.allow_http)
|
|
172
|
+
except CredentialError:
|
|
173
|
+
origin = None
|
|
174
|
+
if origin != profile.gateway:
|
|
175
|
+
raise CredentialError(
|
|
176
|
+
f"Gateway advertises {key} '{printable(value, 200)}', not the configured {profile.gateway}. "
|
|
177
|
+
f"The gateway must be served at the host root with PROXY_BASE_URL={profile.gateway} "
|
|
178
|
+
"(or trusted X-Forwarded-* headers); ask the gateway administrator."
|
|
179
|
+
)
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _require_gateway_url(value: str, profile: Profile) -> None:
|
|
184
|
+
try:
|
|
185
|
+
parsed = urlsplit(value)
|
|
186
|
+
origin = gateway_origin(f"{parsed.scheme}://{parsed.netloc}", profile.allow_http)
|
|
187
|
+
except (ValueError, CredentialError):
|
|
188
|
+
raise CredentialError("Gateway endpoint is invalid.") from None
|
|
189
|
+
if origin != profile.gateway or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
190
|
+
raise CredentialError("Gateway endpoint is outside the trusted gateway origin.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class LoopbackCallback:
|
|
194
|
+
"""Receive one state-verified OAuth response on an ephemeral IPv4 loopback listener."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, state: str, notify: Callable[[str], None] | None = None) -> None:
|
|
197
|
+
self.result: str | CredentialError | None = None
|
|
198
|
+
owner = self
|
|
199
|
+
|
|
200
|
+
class CallbackServer(ThreadingHTTPServer):
|
|
201
|
+
def handle_error(self, request, client_address) -> None:
|
|
202
|
+
message = "Warning: callback connection failed; retry login if it does not complete."
|
|
203
|
+
if notify is not None:
|
|
204
|
+
notify(message)
|
|
205
|
+
else:
|
|
206
|
+
logging.getLogger(__name__).warning(message)
|
|
207
|
+
|
|
208
|
+
class Handler(BaseHTTPRequestHandler):
|
|
209
|
+
def setup(self) -> None:
|
|
210
|
+
self.request.settimeout(3)
|
|
211
|
+
super().setup()
|
|
212
|
+
|
|
213
|
+
def log_message(self, *args) -> None:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
def do_GET(self) -> None:
|
|
217
|
+
parsed = urlsplit(self.path)
|
|
218
|
+
query = parse_qs(parsed.query, keep_blank_values=True)
|
|
219
|
+
states = query.get("state", [])
|
|
220
|
+
valid = (
|
|
221
|
+
self.headers.get("Host") == f"127.0.0.1:{self.server.server_port}"
|
|
222
|
+
and parsed.path == "/callback"
|
|
223
|
+
and len(states) == 1
|
|
224
|
+
and hmac.compare_digest(states[0].encode(), state.encode())
|
|
225
|
+
)
|
|
226
|
+
if not valid:
|
|
227
|
+
self.send_error(400, "Invalid callback")
|
|
228
|
+
return
|
|
229
|
+
if "error" in query:
|
|
230
|
+
owner.result = CredentialError("Authorization was denied in the browser.")
|
|
231
|
+
elif len(query.get("code", [])) == 1 and query["code"][0]:
|
|
232
|
+
owner.result = query["code"][0]
|
|
233
|
+
else:
|
|
234
|
+
self.send_error(400, "Missing authorization code")
|
|
235
|
+
return
|
|
236
|
+
message = b"Login response received. Return to your terminal."
|
|
237
|
+
self.send_response(200)
|
|
238
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
239
|
+
self.send_header("Cache-Control", "no-store")
|
|
240
|
+
self.send_header("Content-Length", str(len(message)))
|
|
241
|
+
self.end_headers()
|
|
242
|
+
self.wfile.write(message)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
self.server = CallbackServer(("127.0.0.1", 0), Handler)
|
|
246
|
+
except OSError:
|
|
247
|
+
raise CredentialError("Cannot open the local login callback listener.") from None
|
|
248
|
+
self.server.timeout = 0.5
|
|
249
|
+
self.redirect_uri = f"http://127.0.0.1:{self.server.server_port}/callback"
|
|
250
|
+
|
|
251
|
+
def wait(self, timeout: float) -> str:
|
|
252
|
+
deadline = time.monotonic() + timeout
|
|
253
|
+
while self.result is None and time.monotonic() < deadline:
|
|
254
|
+
self.server.handle_request()
|
|
255
|
+
if isinstance(self.result, CredentialError):
|
|
256
|
+
raise self.result
|
|
257
|
+
if self.result is None:
|
|
258
|
+
raise CredentialError("Login timed out; run login again.")
|
|
259
|
+
return self.result
|
|
260
|
+
|
|
261
|
+
def close(self) -> None:
|
|
262
|
+
self.server.server_close()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class LiteLLMClient:
|
|
266
|
+
"""Consume native LiteLLM SSO; no admin keys, legacy sk-key mapping or team override."""
|
|
267
|
+
|
|
268
|
+
def __init__(self, profile: Profile, transport: JsonTransport | None = None) -> None:
|
|
269
|
+
self.profile = profile
|
|
270
|
+
self.transport = transport or JsonTransport()
|
|
271
|
+
|
|
272
|
+
def discover(self) -> Discovery:
|
|
273
|
+
data = self.transport.request(
|
|
274
|
+
"GET", self.profile.gateway + "/.well-known/litellm-cli-auth"
|
|
275
|
+
)
|
|
276
|
+
return Discovery.parse(data, self.profile)
|
|
277
|
+
|
|
278
|
+
def login(
|
|
279
|
+
self,
|
|
280
|
+
notify: Callable[[str], None],
|
|
281
|
+
*,
|
|
282
|
+
no_browser: bool = False,
|
|
283
|
+
timeout: float = 180,
|
|
284
|
+
) -> Session:
|
|
285
|
+
"""Open SSO and return an employee/team grant; browser consent still selects the team."""
|
|
286
|
+
discovery = self.discover()
|
|
287
|
+
verifier = secrets.token_urlsafe(64)
|
|
288
|
+
challenge = base64.urlsafe_b64encode(
|
|
289
|
+
hashlib.sha256(verifier.encode()).digest()
|
|
290
|
+
).rstrip(b"=").decode()
|
|
291
|
+
state = secrets.token_urlsafe(32)
|
|
292
|
+
callback = LoopbackCallback(state, notify)
|
|
293
|
+
try:
|
|
294
|
+
registration = self.transport.request(
|
|
295
|
+
"POST",
|
|
296
|
+
discovery.registration_endpoint,
|
|
297
|
+
{
|
|
298
|
+
"client_name": "Virtual Key Mapper",
|
|
299
|
+
"redirect_uris": [callback.redirect_uri],
|
|
300
|
+
"token_endpoint_auth_method": "none",
|
|
301
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
302
|
+
"response_types": ["code"],
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
client_id = required_text(registration, "client_id")
|
|
306
|
+
if (
|
|
307
|
+
registration.get("token_endpoint_auth_method") != "none"
|
|
308
|
+
or registration.get("redirect_uris") != [callback.redirect_uri]
|
|
309
|
+
):
|
|
310
|
+
raise CredentialError("Gateway registered an unexpected OAuth client.")
|
|
311
|
+
url = discovery.authorization_endpoint + "?" + urlencode({
|
|
312
|
+
"response_type": "code",
|
|
313
|
+
"client_id": client_id,
|
|
314
|
+
"redirect_uri": callback.redirect_uri,
|
|
315
|
+
"state": state,
|
|
316
|
+
"code_challenge": challenge,
|
|
317
|
+
"code_challenge_method": "S256",
|
|
318
|
+
"resource": discovery.resource,
|
|
319
|
+
})
|
|
320
|
+
notify(
|
|
321
|
+
f"Choose the LiteLLM team for '{self.profile.name}' "
|
|
322
|
+
f"(expected ID: {team_id(self.profile)}) in browser consent."
|
|
323
|
+
)
|
|
324
|
+
if no_browser:
|
|
325
|
+
notify(url)
|
|
326
|
+
elif not webbrowser.open(url):
|
|
327
|
+
raise CredentialError("Cannot open a browser; rerun login with --no-browser.")
|
|
328
|
+
code = callback.wait(timeout)
|
|
329
|
+
response = self.transport.request(
|
|
330
|
+
"POST",
|
|
331
|
+
discovery.token_endpoint,
|
|
332
|
+
{
|
|
333
|
+
"grant_type": "authorization_code",
|
|
334
|
+
"code": code,
|
|
335
|
+
"redirect_uri": callback.redirect_uri,
|
|
336
|
+
"client_id": client_id,
|
|
337
|
+
"code_verifier": verifier,
|
|
338
|
+
"resource": discovery.resource,
|
|
339
|
+
},
|
|
340
|
+
form=True,
|
|
341
|
+
)
|
|
342
|
+
try:
|
|
343
|
+
if "team_id" in response and response["team_id"] is None:
|
|
344
|
+
# LiteLLM issues a grant without a team to a user who belongs to none.
|
|
345
|
+
raise CredentialError(
|
|
346
|
+
f"Your LiteLLM user is not a member of any team. Ask the gateway administrator to add you "
|
|
347
|
+
f"to team '{team_id(self.profile)}', then log in again and choose it at consent."
|
|
348
|
+
)
|
|
349
|
+
return Session.from_response(response, self.profile, client_id, discovery)
|
|
350
|
+
except CredentialError:
|
|
351
|
+
# The gateway already issued this grant; keep no unsaved grant alive.
|
|
352
|
+
self._revoke_unparsed(response, client_id, discovery, notify)
|
|
353
|
+
raise
|
|
354
|
+
finally:
|
|
355
|
+
callback.close()
|
|
356
|
+
|
|
357
|
+
def refresh(self, session: Session) -> Session:
|
|
358
|
+
"""Rotate the refresh grant at the endpoint stored at login; never rediscover or follow redirects.
|
|
359
|
+
|
|
360
|
+
Raise GatewayError for transport/HTTP failures (see GatewayError.retryable), CredentialError
|
|
361
|
+
for an unusable response and IdentityChanged for a changed employee/team, after revoking that
|
|
362
|
+
unexpected grant.
|
|
363
|
+
"""
|
|
364
|
+
response = self.transport.request(
|
|
365
|
+
"POST",
|
|
366
|
+
session.token_endpoint,
|
|
367
|
+
{
|
|
368
|
+
"grant_type": "refresh_token",
|
|
369
|
+
"refresh_token": session.refresh_token,
|
|
370
|
+
"client_id": session.client_id,
|
|
371
|
+
"resource": session.resource,
|
|
372
|
+
},
|
|
373
|
+
form=True,
|
|
374
|
+
)
|
|
375
|
+
renewed = Session.from_response(response, self.profile, session.client_id, session)
|
|
376
|
+
try:
|
|
377
|
+
renewed.check_binding(self.profile)
|
|
378
|
+
if renewed.user_id != session.user_id:
|
|
379
|
+
raise CredentialError("Renewed credential changed employee identity; log in again.")
|
|
380
|
+
except CredentialError as error:
|
|
381
|
+
try:
|
|
382
|
+
self.revoke(renewed)
|
|
383
|
+
except CredentialError:
|
|
384
|
+
raise IdentityChanged(
|
|
385
|
+
f"{error} Unexpected refresh grant could not be revoked; contact the gateway administrator."
|
|
386
|
+
) from None
|
|
387
|
+
raise IdentityChanged(str(error)) from None
|
|
388
|
+
return renewed
|
|
389
|
+
|
|
390
|
+
def revoke(self, session: Session) -> None:
|
|
391
|
+
self._revoke(session.revocation_endpoint, session.refresh_token, session.client_id)
|
|
392
|
+
|
|
393
|
+
def _revoke(self, endpoint: str, refresh_token: str, client_id: str) -> None:
|
|
394
|
+
self.transport.request("POST", endpoint, {"token": refresh_token, "client_id": client_id}, form=True)
|
|
395
|
+
|
|
396
|
+
def _revoke_unparsed(self, response: dict, client_id: str, discovery: Discovery, notify: Callable[[str], None]) -> None:
|
|
397
|
+
"""Revoke a grant from a token response that was rejected before it became a Session."""
|
|
398
|
+
refresh_token = response.get("refresh_token")
|
|
399
|
+
if not isinstance(refresh_token, str) or not refresh_token:
|
|
400
|
+
return
|
|
401
|
+
try:
|
|
402
|
+
self._revoke(discovery.revocation_endpoint, refresh_token, client_id)
|
|
403
|
+
except CredentialError:
|
|
404
|
+
notify("Warning: the unsaved grant could not be revoked; its access token expires naturally.")
|