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/_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)