graphplug 0.2.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.
- graphplug/__init__.py +378 -0
- graphplug/_auth.py +390 -0
- graphplug/_errors.py +176 -0
- graphplug/_http.py +198 -0
- graphplug/_log.py +104 -0
- graphplug/_operations.py +310 -0
- graphplug/_request.py +135 -0
- graphplug/_resources/__init__.py +10 -0
- graphplug/_resources/base.py +70 -0
- graphplug/_resources/calendar.py +194 -0
- graphplug/_resources/files.py +149 -0
- graphplug/_resources/mail.py +196 -0
- graphplug/_resources/teams.py +146 -0
- graphplug/_resources/users.py +121 -0
- graphplug/_scopes.py +56 -0
- graphplug/py.typed +0 -0
- graphplug-0.2.0.dist-info/METADATA +234 -0
- graphplug-0.2.0.dist-info/RECORD +21 -0
- graphplug-0.2.0.dist-info/WHEEL +5 -0
- graphplug-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphplug-0.2.0.dist-info/top_level.txt +1 -0
graphplug/_auth.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Credentials, the sign-in flows, and the loopback redirect listener.
|
|
2
|
+
|
|
3
|
+
No token handling is written here: acquisition, caching, expiry and refresh are azure-identity's
|
|
4
|
+
job. What this module does is choose the right credential and, for the browser flow, keep the PKCE
|
|
5
|
+
verifier inside the process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import secrets
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import webbrowser
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
18
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
19
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
20
|
+
|
|
21
|
+
from azure.core.credentials import AccessToken
|
|
22
|
+
from azure.identity.aio import ClientSecretCredential
|
|
23
|
+
|
|
24
|
+
from ._errors import GraphError, as_graph_error, code_for_text
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"app_only_credential",
|
|
28
|
+
"device_code_begin",
|
|
29
|
+
"authorization_url",
|
|
30
|
+
"exchange_code",
|
|
31
|
+
"open_browser",
|
|
32
|
+
"wait_for_redirect",
|
|
33
|
+
"SIGN_IN_WINDOW_SECONDS",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
|
|
37
|
+
DEFAULT_AUTHORITY = "https://login.microsoftonline.com"
|
|
38
|
+
|
|
39
|
+
#: Matches a device code's own lifetime.
|
|
40
|
+
SIGN_IN_WINDOW_SECONDS = 15 * 60
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def require_delegated_scopes(scopes: Optional[Sequence[str]]) -> tuple:
|
|
44
|
+
"""Delegated flows will not guess.
|
|
45
|
+
|
|
46
|
+
There is no safe default: ``.default`` on a delegated flow silently requests every scope ever
|
|
47
|
+
consented for that client, which is the opposite of least privilege.
|
|
48
|
+
"""
|
|
49
|
+
if not scopes:
|
|
50
|
+
raise GraphError(0, "invalidRequest", "'scopes' is required for delegated authentication")
|
|
51
|
+
return tuple(scopes)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── application-level ────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def app_only_credential(
|
|
58
|
+
tenant_id: str, client_id: str, client_secret: str, authority: Optional[str] = None
|
|
59
|
+
) -> ClientSecretCredential:
|
|
60
|
+
"""The app acts as itself, with the application permissions an administrator consented to."""
|
|
61
|
+
for name, value in (("tenant_id", tenant_id), ("client_id", client_id),
|
|
62
|
+
("client_secret", client_secret)):
|
|
63
|
+
if not value:
|
|
64
|
+
raise GraphError(0, "invalidRequest", f"'{name}' is required")
|
|
65
|
+
|
|
66
|
+
options: Dict[str, Any] = {}
|
|
67
|
+
if authority:
|
|
68
|
+
options["authority"] = authority
|
|
69
|
+
return ClientSecretCredential(tenant_id, client_id, client_secret, **options)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── device code ──────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _DeviceCodeSignIn:
|
|
76
|
+
"""Device code as two phases: the code is available at once, the token when the user finishes.
|
|
77
|
+
|
|
78
|
+
azure.identity.aio has no DeviceCodeCredential -- only the synchronous one exists -- so it runs
|
|
79
|
+
on a worker thread. `authenticate()` blocks until the person completes the sign-in, which is
|
|
80
|
+
exactly what the second phase is waiting for.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
#: The credential class, overridable so the two-phase orchestration can be tested without Entra.
|
|
84
|
+
credential_class: Any = None
|
|
85
|
+
|
|
86
|
+
def __init__(self, tenant_id: str, client_id: str, scopes: Sequence[str],
|
|
87
|
+
authority: Optional[str] = None) -> None:
|
|
88
|
+
self.scopes = require_delegated_scopes(scopes)
|
|
89
|
+
# get_running_loop, not get_event_loop: the latter is deprecated and, outside a running
|
|
90
|
+
# loop, creates one nobody will ever run.
|
|
91
|
+
self._loop = asyncio.get_running_loop()
|
|
92
|
+
self._issued: "asyncio.Future[Dict[str, Any]]" = self._loop.create_future()
|
|
93
|
+
self._signed_in: Optional[asyncio.Task] = None
|
|
94
|
+
|
|
95
|
+
options: Dict[str, Any] = {}
|
|
96
|
+
if authority:
|
|
97
|
+
options["authority"] = authority
|
|
98
|
+
|
|
99
|
+
if self.credential_class is None:
|
|
100
|
+
from azure.identity import DeviceCodeCredential
|
|
101
|
+
type(self).credential_class = DeviceCodeCredential
|
|
102
|
+
|
|
103
|
+
self._credential = self.credential_class(
|
|
104
|
+
client_id=client_id,
|
|
105
|
+
tenant_id=tenant_id,
|
|
106
|
+
prompt_callback=self._on_code,
|
|
107
|
+
# Once signed in, a session that can no longer refresh must fail as
|
|
108
|
+
# interactionRequired -- not start a second device code nobody is shown.
|
|
109
|
+
disable_automatic_authentication=True,
|
|
110
|
+
**options,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _on_code(self, verification_uri: str, user_code: str, expires_on: Any) -> None:
|
|
114
|
+
"""Called on the worker thread the moment Microsoft issues the code."""
|
|
115
|
+
payload = {
|
|
116
|
+
"userCode": user_code,
|
|
117
|
+
"verificationUri": verification_uri,
|
|
118
|
+
"message": f"To sign in, open {verification_uri} and enter the code {user_code}",
|
|
119
|
+
"expiresInSeconds": SIGN_IN_WINDOW_SECONDS,
|
|
120
|
+
}
|
|
121
|
+
self._loop.call_soon_threadsafe(
|
|
122
|
+
lambda: None if self._issued.done() else self._issued.set_result(payload)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def begin(self) -> Dict[str, Any]:
|
|
126
|
+
self._signed_in = asyncio.create_task(
|
|
127
|
+
asyncio.to_thread(self._credential.authenticate, scopes=list(self.scopes))
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Wait for the code, but not past a sign-in that fails outright: a bad tenant never
|
|
131
|
+
# reaches the callback, and waiting on it alone would hang until the caller's timeout.
|
|
132
|
+
done, _ = await asyncio.wait(
|
|
133
|
+
{self._issued, self._signed_in}, return_when=asyncio.FIRST_COMPLETED
|
|
134
|
+
)
|
|
135
|
+
if self._issued in done:
|
|
136
|
+
return self._issued.result()
|
|
137
|
+
|
|
138
|
+
await self._signed_in # completed without a code: re-raise the real cause
|
|
139
|
+
raise GraphError(0, "authenticationFailed", "sign-in finished without issuing a device code")
|
|
140
|
+
|
|
141
|
+
async def complete(self) -> Any:
|
|
142
|
+
if self._signed_in is None:
|
|
143
|
+
raise GraphError(0, "invalidRequest", "the sign-in was never begun")
|
|
144
|
+
try:
|
|
145
|
+
await asyncio.wait_for(self._signed_in, timeout=SIGN_IN_WINDOW_SECONDS)
|
|
146
|
+
except asyncio.TimeoutError:
|
|
147
|
+
raise GraphError(
|
|
148
|
+
0, "signInTimeout", "the user did not complete sign-in within the window"
|
|
149
|
+
) from None
|
|
150
|
+
except Exception as exception:
|
|
151
|
+
raise as_graph_error(exception, "device_code") from exception
|
|
152
|
+
|
|
153
|
+
return _SyncCredentialAdapter(self._credential)
|
|
154
|
+
|
|
155
|
+
async def cancel(self) -> None:
|
|
156
|
+
if self._signed_in is not None and not self._signed_in.done():
|
|
157
|
+
self._signed_in.cancel()
|
|
158
|
+
try:
|
|
159
|
+
await self._signed_in
|
|
160
|
+
except (asyncio.CancelledError, Exception):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def device_code_begin(
|
|
165
|
+
tenant_id: str, client_id: str, scopes: Sequence[str], authority: Optional[str] = None
|
|
166
|
+
) -> "tuple[_DeviceCodeSignIn, Dict[str, Any]]":
|
|
167
|
+
flow = _DeviceCodeSignIn(tenant_id, client_id, scopes, authority)
|
|
168
|
+
return flow, await flow.begin()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _SyncCredentialAdapter:
|
|
172
|
+
"""Presents a synchronous azure-identity credential as an async one."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, credential: Any) -> None:
|
|
175
|
+
self._credential = credential
|
|
176
|
+
|
|
177
|
+
async def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken:
|
|
178
|
+
return await asyncio.to_thread(self._credential.get_token, *scopes, **kwargs)
|
|
179
|
+
|
|
180
|
+
async def close(self) -> None:
|
|
181
|
+
closer = getattr(self._credential, "close", None)
|
|
182
|
+
if closer is not None:
|
|
183
|
+
await asyncio.to_thread(closer)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── authorization code with PKCE ─────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def pkce_pair() -> "tuple[str, str]":
|
|
190
|
+
"""An RFC 7636 verifier and its S256 challenge.
|
|
191
|
+
|
|
192
|
+
The verifier never leaves this process. It is credential material for the duration of the
|
|
193
|
+
exchange, and keeping it here means nothing can leak it by logging a return value.
|
|
194
|
+
"""
|
|
195
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
196
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
197
|
+
return verifier, challenge
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def new_state() -> str:
|
|
201
|
+
"""The anti-forgery value. This one does travel -- that is what it is for."""
|
|
202
|
+
return _b64url(secrets.token_bytes(32))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _b64url(raw: bytes) -> str:
|
|
206
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def authorization_url(
|
|
210
|
+
tenant_id: str,
|
|
211
|
+
client_id: str,
|
|
212
|
+
scopes: Sequence[str],
|
|
213
|
+
redirect_uri: str,
|
|
214
|
+
challenge: str,
|
|
215
|
+
state: str,
|
|
216
|
+
authority: Optional[str] = None,
|
|
217
|
+
) -> str:
|
|
218
|
+
# offline_access is requested here so the session can refresh silently for the life of the
|
|
219
|
+
# client. It is deliberately NOT passed to MSAL's token call, which rejects reserved scopes.
|
|
220
|
+
requested = " ".join([*scopes, "offline_access"])
|
|
221
|
+
query = urlencode({
|
|
222
|
+
"client_id": client_id,
|
|
223
|
+
"response_type": "code",
|
|
224
|
+
"redirect_uri": redirect_uri,
|
|
225
|
+
"response_mode": "query",
|
|
226
|
+
"scope": requested,
|
|
227
|
+
"state": state,
|
|
228
|
+
"code_challenge": challenge,
|
|
229
|
+
"code_challenge_method": "S256",
|
|
230
|
+
})
|
|
231
|
+
return f"{(authority or DEFAULT_AUTHORITY).rstrip('/')}/{tenant_id}/oauth2/v2.0/authorize?{query}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def exchange_code(
|
|
235
|
+
tenant_id: str,
|
|
236
|
+
client_id: str,
|
|
237
|
+
scopes: Sequence[str],
|
|
238
|
+
redirect_uri: str,
|
|
239
|
+
code: str,
|
|
240
|
+
verifier: str,
|
|
241
|
+
authority: Optional[str] = None,
|
|
242
|
+
) -> Any:
|
|
243
|
+
"""Redeem the authorization code for a token, proving possession with the PKCE verifier.
|
|
244
|
+
|
|
245
|
+
Uses MSAL rather than azure-identity's AuthorizationCodeCredential, which has no
|
|
246
|
+
``code_verifier`` parameter. MSAL is synchronous, so the exchange runs on a worker thread.
|
|
247
|
+
"""
|
|
248
|
+
import msal
|
|
249
|
+
|
|
250
|
+
application = msal.PublicClientApplication(
|
|
251
|
+
client_id, authority=f"{(authority or DEFAULT_AUTHORITY).rstrip('/')}/{tenant_id}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
result = await asyncio.to_thread(
|
|
255
|
+
application.acquire_token_by_authorization_code,
|
|
256
|
+
code,
|
|
257
|
+
scopes=list(scopes),
|
|
258
|
+
redirect_uri=redirect_uri,
|
|
259
|
+
code_verifier=verifier,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if "access_token" not in result:
|
|
263
|
+
detail = result.get("error_description") or result.get("error") or "sign-in failed"
|
|
264
|
+
# Anything unrecognised is still a sign-in failure.
|
|
265
|
+
raise GraphError(0, code_for_text(detail) or "authenticationFailed", detail)
|
|
266
|
+
|
|
267
|
+
return _MsalCredential(application, result, tuple(scopes))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class _MsalCredential:
|
|
271
|
+
"""A credential over MSAL's token cache.
|
|
272
|
+
|
|
273
|
+
No token handling is written here either: the cache was seeded by the code exchange, and
|
|
274
|
+
``acquire_token_silent`` refreshes from it.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(self, application: Any, result: Dict[str, Any], scopes: Sequence[str]) -> None:
|
|
278
|
+
self._application = application
|
|
279
|
+
self._scopes = list(scopes)
|
|
280
|
+
|
|
281
|
+
async def get_token(self, *scopes: str, **_: Any) -> AccessToken:
|
|
282
|
+
wanted = list(scopes) or self._scopes
|
|
283
|
+
accounts = self._application.get_accounts()
|
|
284
|
+
result = await asyncio.to_thread(
|
|
285
|
+
self._application.acquire_token_silent, wanted, accounts[0] if accounts else None
|
|
286
|
+
)
|
|
287
|
+
if not result or "access_token" not in result:
|
|
288
|
+
raise GraphError(
|
|
289
|
+
0,
|
|
290
|
+
"interactionRequired",
|
|
291
|
+
"the signed-in session can no longer be refreshed; sign in again",
|
|
292
|
+
)
|
|
293
|
+
return AccessToken(result["access_token"], int(time.time()) + int(result.get("expires_in", 3600)))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ── the loopback redirect listener ───────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
_PAGE = b"""<!doctype html>
|
|
299
|
+
<html><head><meta charset="utf-8"><title>Signed in</title></head>
|
|
300
|
+
<body style="font-family:system-ui;padding:3rem;text-align:center">
|
|
301
|
+
<h1>Signed in</h1><p>You may close this window and return to your terminal.</p>
|
|
302
|
+
</body></html>
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _RedirectHandler(BaseHTTPRequestHandler):
|
|
307
|
+
received: Optional[Dict[str, str]] = None
|
|
308
|
+
#: Seconds to wait for a request line on an accepted connection.
|
|
309
|
+
timeout = 5
|
|
310
|
+
|
|
311
|
+
def do_GET(self) -> None: # noqa: N802 - the name is fixed by BaseHTTPRequestHandler
|
|
312
|
+
query = parse_qs(urlsplit(self.path).query)
|
|
313
|
+
type(self).received = {k: v[0] for k, v in query.items() if v}
|
|
314
|
+
|
|
315
|
+
self.send_response(200)
|
|
316
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
317
|
+
self.send_header("Content-Length", str(len(_PAGE)))
|
|
318
|
+
self.end_headers()
|
|
319
|
+
self.wfile.write(_PAGE)
|
|
320
|
+
|
|
321
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
322
|
+
"""Silence the default access log; a library should not narrate."""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def open_browser(url: str) -> bool:
|
|
326
|
+
"""Best effort. A headless box has no browser, and the caller can still open the URL."""
|
|
327
|
+
try:
|
|
328
|
+
return webbrowser.open(url)
|
|
329
|
+
except Exception:
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def wait_for_redirect(
|
|
334
|
+
redirect_uri: str,
|
|
335
|
+
timeout_seconds: float,
|
|
336
|
+
on_listening: Optional[Callable[[], None]] = None,
|
|
337
|
+
) -> Dict[str, str]:
|
|
338
|
+
"""Serve the redirect URI's port until a request arrives, and return its query parameters.
|
|
339
|
+
|
|
340
|
+
Binds to loopback only, whatever host the redirect URI names. ``on_listening`` runs once the
|
|
341
|
+
socket is bound -- open the browser there, or a fast single sign-on redirect can arrive
|
|
342
|
+
before anything is listening.
|
|
343
|
+
"""
|
|
344
|
+
port = urlsplit(redirect_uri).port
|
|
345
|
+
if port is None:
|
|
346
|
+
raise GraphError(0, "invalidRequest", f"the redirect URI {redirect_uri!r} must name a port")
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
server = HTTPServer(("127.0.0.1", port), _RedirectHandler)
|
|
350
|
+
except OSError as exception:
|
|
351
|
+
raise GraphError(
|
|
352
|
+
0, "invalidRequest", f"cannot listen on port {port} for the redirect: {exception}"
|
|
353
|
+
) from exception
|
|
354
|
+
deadline = time.monotonic() + timeout_seconds
|
|
355
|
+
abandoned = threading.Event()
|
|
356
|
+
|
|
357
|
+
def serve() -> Optional[Dict[str, str]]:
|
|
358
|
+
# A browser may open a speculative connection and send nothing on it. The handler's
|
|
359
|
+
# read timeout drops it, and the loop goes back to waiting for the real redirect.
|
|
360
|
+
try:
|
|
361
|
+
while _RedirectHandler.received is None and not abandoned.is_set():
|
|
362
|
+
remaining = deadline - time.monotonic()
|
|
363
|
+
if remaining <= 0:
|
|
364
|
+
return None
|
|
365
|
+
# Short slices: a worker thread cannot be cancelled, so it checks back often and
|
|
366
|
+
# an abandoned sign-in releases the port within half a second.
|
|
367
|
+
server.timeout = min(remaining, 0.5)
|
|
368
|
+
server.handle_request()
|
|
369
|
+
return _RedirectHandler.received
|
|
370
|
+
finally:
|
|
371
|
+
_RedirectHandler.received = None
|
|
372
|
+
server.server_close()
|
|
373
|
+
|
|
374
|
+
_RedirectHandler.received = None
|
|
375
|
+
try:
|
|
376
|
+
if on_listening is not None:
|
|
377
|
+
on_listening()
|
|
378
|
+
except BaseException:
|
|
379
|
+
server.server_close()
|
|
380
|
+
raise
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
received = await asyncio.to_thread(serve)
|
|
384
|
+
finally:
|
|
385
|
+
abandoned.set()
|
|
386
|
+
if received is None:
|
|
387
|
+
raise GraphError(
|
|
388
|
+
0, "signInTimeout", f"no redirect arrived on {redirect_uri} within {timeout_seconds:.0f}s"
|
|
389
|
+
)
|
|
390
|
+
return received
|
graphplug/_errors.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""One error shape for every failure.
|
|
2
|
+
|
|
3
|
+
Ported from the C# core's `GraphErrorInfo`, `EntraFailure` and `ErrorEnvelope`. The codes are the
|
|
4
|
+
same strings, deliberately: they are what callers branch on and what the documentation promises.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import email.utils
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Dict, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import _log
|
|
17
|
+
|
|
18
|
+
__all__ = ["GraphError", "from_response", "code_for_exception", "code_for_text", "flatten"]
|
|
19
|
+
|
|
20
|
+
_MAX_BODY_CHARS = 2048
|
|
21
|
+
_MAX_CHAIN_DEPTH = 8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GraphError(Exception):
|
|
25
|
+
"""Every failure, in one shape.
|
|
26
|
+
|
|
27
|
+
One exception type rather than a hierarchy: callers branch on ``status`` and ``code``, which is
|
|
28
|
+
more precise than any class tree and does not require importing eight names to write an
|
|
29
|
+
``except`` clause.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
status: int,
|
|
35
|
+
code: str,
|
|
36
|
+
message: str,
|
|
37
|
+
request_id: Optional[str] = None,
|
|
38
|
+
retry_after: Optional[int] = None,
|
|
39
|
+
inner: Optional[Dict[str, Any]] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(f"[{status} {code}] {message}")
|
|
42
|
+
#: HTTP status, or 0 when there was no HTTP response at all.
|
|
43
|
+
self.status = status
|
|
44
|
+
#: A Graph error code, or one of the core codes below.
|
|
45
|
+
self.code = code
|
|
46
|
+
self.message = message
|
|
47
|
+
#: Quote this to Microsoft support.
|
|
48
|
+
self.request_id = request_id
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
#: Graph's own inner error, preserved verbatim -- often the only actionable part.
|
|
51
|
+
self.inner = inner
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_retry_after(value: Optional[str]) -> Optional[int]:
|
|
55
|
+
"""Accepts both forms RFC 9110 allows: delta-seconds and an HTTP date."""
|
|
56
|
+
if not value:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
return max(0, int(value))
|
|
61
|
+
except ValueError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
# Raises rather than returning None on Python 3.10+. This runs while already handling a
|
|
68
|
+
# failure, so a malformed header must not become a second, worse one.
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
if when is None:
|
|
72
|
+
return None
|
|
73
|
+
if when.tzinfo is None:
|
|
74
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
75
|
+
return max(0, int((when - datetime.now(timezone.utc)).total_seconds()))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def from_response(
|
|
79
|
+
status: int,
|
|
80
|
+
headers: Mapping[str, str],
|
|
81
|
+
body: Any,
|
|
82
|
+
reason: Optional[str] = None,
|
|
83
|
+
) -> GraphError:
|
|
84
|
+
"""Build the error for a request that reached Graph.
|
|
85
|
+
|
|
86
|
+
``headers`` is already allow-listed by the caller, so nothing sensitive can arrive here.
|
|
87
|
+
"""
|
|
88
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
89
|
+
if not isinstance(error, dict):
|
|
90
|
+
error = {}
|
|
91
|
+
|
|
92
|
+
fallback = body if isinstance(body, str) else ""
|
|
93
|
+
return GraphError(
|
|
94
|
+
status=status,
|
|
95
|
+
code=str(error.get("code") or reason or status),
|
|
96
|
+
message=str(error.get("message") or fallback[:_MAX_BODY_CHARS] or reason or str(status)),
|
|
97
|
+
request_id=headers.get("request-id"),
|
|
98
|
+
retry_after=parse_retry_after(headers.get("Retry-After")),
|
|
99
|
+
inner=error.get("innerError") if isinstance(error.get("innerError"), dict) else None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def flatten(exception: BaseException) -> str:
|
|
104
|
+
"""Every message in the exception chain.
|
|
105
|
+
|
|
106
|
+
The detail is never on the outermost exception: azure-identity raises with a message like
|
|
107
|
+
"DeviceCodeCredential authentication failed: " and puts the AADSTS number underneath. Reading
|
|
108
|
+
only the top message silently collapses every sign-in failure into one generic code.
|
|
109
|
+
"""
|
|
110
|
+
messages, seen, current, depth = [], set(), exception, 0
|
|
111
|
+
while current is not None and depth < _MAX_CHAIN_DEPTH and id(current) not in seen:
|
|
112
|
+
seen.add(id(current))
|
|
113
|
+
text = str(current).strip()
|
|
114
|
+
if text and text not in messages:
|
|
115
|
+
messages.append(text)
|
|
116
|
+
current = current.__cause__ or current.__context__
|
|
117
|
+
depth += 1
|
|
118
|
+
|
|
119
|
+
return " -- ".join(messages) or type(exception).__name__
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def code_for_text(text: str) -> Optional[str]:
|
|
123
|
+
"""Match an AADSTS number or OAuth error string, or return None.
|
|
124
|
+
|
|
125
|
+
Matched on the numbers and machine-readable strings rather than on prose, which is localised
|
|
126
|
+
and reworded over time. Split out from ``code_for_exception`` because the MSAL token exchange
|
|
127
|
+
reports a failure as a string rather than as an exception, and wrapping that string in a
|
|
128
|
+
GraphError just to classify it made the classifier return the wrapper's own placeholder code.
|
|
129
|
+
"""
|
|
130
|
+
# 65004 is an active refusal; 65001 is merely the absence of a consent grant.
|
|
131
|
+
if "AADSTS65004" in text:
|
|
132
|
+
return "signInDeclined"
|
|
133
|
+
if "AADSTS65001" in text or "consent_required" in text:
|
|
134
|
+
return "consentRequired"
|
|
135
|
+
if "AADSTS70016" in text or "expired_token" in text or "code_expired" in text:
|
|
136
|
+
return "signInTimeout"
|
|
137
|
+
if "authorization_declined" in text or "access_denied" in text:
|
|
138
|
+
return "signInDeclined"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def code_for_exception(exception: BaseException) -> str:
|
|
143
|
+
"""Map a non-HTTP failure onto one of the defined codes."""
|
|
144
|
+
if isinstance(exception, GraphError):
|
|
145
|
+
return exception.code
|
|
146
|
+
|
|
147
|
+
text = flatten(exception)
|
|
148
|
+
matched = code_for_text(text)
|
|
149
|
+
if matched:
|
|
150
|
+
return matched
|
|
151
|
+
|
|
152
|
+
if isinstance(exception, (httpx.TimeoutException, TimeoutError, asyncio.TimeoutError)):
|
|
153
|
+
return "timeout"
|
|
154
|
+
if isinstance(exception, httpx.TransportError):
|
|
155
|
+
return "transportError"
|
|
156
|
+
|
|
157
|
+
name = type(exception).__name__
|
|
158
|
+
if name == "AuthenticationRequiredError":
|
|
159
|
+
# azure-identity's word for a session that needs the person back.
|
|
160
|
+
return "interactionRequired"
|
|
161
|
+
if "Authentication" in name or "Credential" in name or "AADSTS" in text:
|
|
162
|
+
return "authenticationFailed"
|
|
163
|
+
if isinstance(exception, (ValueError, TypeError, KeyError)):
|
|
164
|
+
return "invalidRequest"
|
|
165
|
+
return "internalError"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def as_graph_error(exception: BaseException, operation: str) -> GraphError:
|
|
169
|
+
"""Turn any exception into the one shape, and say so in the log."""
|
|
170
|
+
if isinstance(exception, GraphError):
|
|
171
|
+
return exception
|
|
172
|
+
|
|
173
|
+
code = code_for_exception(exception)
|
|
174
|
+
message = flatten(exception)
|
|
175
|
+
_log.failure(operation, code, message)
|
|
176
|
+
return GraphError(0, code, message)
|