libre-devops-helpers 0.4.1__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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,91 @@
1
+ """Tokens from the Azure CLI's own sign-in (``az account get-access-token``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from libre_devops_helpers.core import brand
10
+ from libre_devops_helpers.core.auth import AccessToken
11
+ from libre_devops_helpers.core.errors import ReauthRequired
12
+ from libre_devops_helpers.microsoft.auth.lapse import lapse_reason
13
+ from libre_devops_helpers.microsoft.process import AzCliError, AzureCliRunner
14
+
15
+ # Asked when the Azure CLI's sign-in to a tenant has lapsed: (tenant id, reason). It
16
+ # returns True once the CLI is signed in again, so the token can be fetched once more.
17
+ Reauthenticate = Callable[[str, str], bool]
18
+
19
+
20
+ class AzureCliCredential:
21
+ """A token provider backed by the Azure CLI session of the person running the tool.
22
+
23
+ ``--tenant`` is passed explicitly, so this works for any signed-in tenant and does
24
+ not depend on, or change, the CLI's active account.
25
+
26
+ The Azure CLI renews tokens from its refresh token by itself. When that has lapsed
27
+ too, ``reauthenticate`` (if given) gets the chance to sign it in again, and the
28
+ token is fetched once more; otherwise, or if it declines, ReauthRequired is raised
29
+ with the reason.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ runner: AzureCliRunner | None = None,
35
+ *,
36
+ reauthenticate: Reauthenticate | None = None,
37
+ ) -> None:
38
+ self._runner = runner or AzureCliRunner()
39
+ self._reauthenticate = reauthenticate
40
+
41
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
42
+ try:
43
+ data = self._fetch(resource, tenant_id)
44
+ except AzCliError as exc:
45
+ reason = lapse_reason(str(exc))
46
+ if reason is None:
47
+ raise
48
+ if self._reauthenticate is None or not self._reauthenticate(tenant_id, reason):
49
+ raise ReauthRequired(
50
+ f"the Azure CLI's sign-in to tenant {tenant_id} has lapsed: {reason}",
51
+ hint=(
52
+ f"sign in again with {brand.command('az use <profile>')} "
53
+ f"or 'az login --tenant {tenant_id}'"
54
+ ),
55
+ tenant_id=tenant_id,
56
+ reason=reason,
57
+ ) from None
58
+ data = self._fetch(resource, tenant_id)
59
+ return self._token(data, resource, tenant_id)
60
+
61
+ def _fetch(self, resource: str, tenant_id: str) -> Any:
62
+ return self._runner.run_json(
63
+ "account", "get-access-token", "--resource", resource, "--tenant", tenant_id
64
+ )
65
+
66
+ def _token(self, data: Any, resource: str, tenant_id: str) -> AccessToken:
67
+ if not isinstance(data, dict) or not isinstance(data.get("accessToken"), str):
68
+ raise AzCliError("az account get-access-token returned no accessToken")
69
+ return AccessToken(
70
+ token=data["accessToken"],
71
+ expires_on=_expiry(data),
72
+ tenant_id=str(data.get("tenant") or tenant_id).lower(),
73
+ resource=resource,
74
+ )
75
+
76
+
77
+ def _expiry(data: Mapping[str, Any]) -> datetime:
78
+ # 'expires_on' (POSIX seconds) exists from az 2.54; older releases only have
79
+ # 'expiresOn', a local time with no offset.
80
+ epoch = data.get("expires_on")
81
+ if isinstance(epoch, str) and epoch.isdigit():
82
+ epoch = int(epoch)
83
+ if isinstance(epoch, int | float) and not isinstance(epoch, bool):
84
+ return datetime.fromtimestamp(epoch, UTC)
85
+ text = data.get("expiresOn")
86
+ if isinstance(text, str):
87
+ try:
88
+ return datetime.fromisoformat(text).astimezone(UTC)
89
+ except ValueError:
90
+ pass
91
+ raise AzCliError("az account get-access-token returned no usable expiry")
@@ -0,0 +1,391 @@
1
+ """Delegated sign-in with your own app registration: a browser, or a device code.
2
+
3
+ The Azure CLI's delegated token carries scopes Microsoft chose; a public client app
4
+ registration you own can be granted others, such as the PIM ones. Two flows:
5
+
6
+ - ``InteractiveCredential``: the authorisation code flow with PKCE. A browser opens (or
7
+ the URL is shown to open), and the redirect comes back to a one-shot listener on
8
+ 127.0.0.1. The ``state`` value is checked, so a stray redirect cannot be accepted.
9
+ - ``DeviceCodeCredential``: a code to enter at the device login page, for SSH, WSL or
10
+ anywhere a browser cannot be reached.
11
+
12
+ The first token needs a sign-in; the refresh token it comes with gets tokens for other
13
+ APIs (ARM as well as Graph) without asking again. A ``store`` (``core.token_store``)
14
+ keeps that refresh token for the next command: a profile's ``token_cache`` picks it, a
15
+ private file unless it says otherwise. Built directly, without a store, a credential
16
+ keeps it in memory for its own lifetime only. Access tokens are never kept, and nothing
17
+ is ever logged.
18
+
19
+ On a machine with no browser to open, the browser flow signs in with a device code
20
+ instead, as the Azure CLI does.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import hashlib
27
+ import http.server
28
+ import logging
29
+ import secrets
30
+ import threading
31
+ import time
32
+ import webbrowser
33
+ from collections.abc import Callable, Mapping
34
+ from datetime import datetime
35
+ from typing import Any, Protocol
36
+ from urllib.parse import parse_qs, quote, urlencode, urlsplit
37
+
38
+ import requests
39
+
40
+ from libre_devops_helpers.core.auth import AccessToken, utc_now
41
+ from libre_devops_helpers.core.browser import can_launch_browser
42
+ from libre_devops_helpers.core.errors import ApiError, AuthError, LdoError
43
+ from libre_devops_helpers.core.http import ApiClient
44
+ from libre_devops_helpers.core.token_store import MemoryStore, TokenStore
45
+ from libre_devops_helpers.microsoft.auth.entra import parse_token_response, scope_for
46
+ from libre_devops_helpers.microsoft.auth.lapse import lapse_reason
47
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
48
+
49
+ log = logging.getLogger(__name__)
50
+
51
+ DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
52
+ SIGN_IN_TIMEOUT = 300.0
53
+ SIGNED_IN_PAGE = b"<h1>Signed in</h1><p>You can close this tab and go back to the terminal.</p>"
54
+
55
+
56
+ def _notify_by_log(message: str) -> None:
57
+ log.warning("%s", message)
58
+
59
+
60
+ class _DelegatedCredential:
61
+ """What both flows share: the token endpoint, and a refresh token per tenant."""
62
+
63
+ def __init__(
64
+ self,
65
+ client_id: str,
66
+ *,
67
+ login_url: str,
68
+ session: requests.Session | None,
69
+ verify: bool | str,
70
+ clock: Callable[[], datetime],
71
+ sleep: Callable[[float], None],
72
+ notify: Callable[[str], None] | None,
73
+ store: TokenStore | None = None,
74
+ monotonic: Callable[[], float] = time.monotonic,
75
+ ) -> None:
76
+ self.client_id = client_id
77
+ self.login_url = login_url.rstrip("/")
78
+ self._endpoint = ApiClient(
79
+ login_url, None, name="Entra ID sign-in", session=session, verify=verify, sleep=sleep
80
+ )
81
+ self._clock = clock
82
+ self._sleep = sleep
83
+ self._notify = notify or _notify_by_log
84
+ self._store = store or MemoryStore()
85
+ self._monotonic = monotonic
86
+ self._lock = threading.Lock()
87
+
88
+ def __repr__(self) -> str:
89
+ return f"{type(self).__name__}(client_id={self.client_id!r})"
90
+
91
+ def get_token(self, resource: str, tenant_id: str) -> AccessToken:
92
+ with self._lock:
93
+ refresh = self._recall(tenant_id)
94
+ if refresh:
95
+ try:
96
+ return self._redeem(
97
+ tenant_id,
98
+ resource,
99
+ {"grant_type": "refresh_token", "refresh_token": refresh},
100
+ )
101
+ except AuthError as exc:
102
+ # Revoked or expired: sign in again rather than fail the command.
103
+ self._forget(tenant_id)
104
+ reason = lapse_reason(str(exc)) or "the refresh token was refused"
105
+ self._notify(f"Signing in again: {reason}.")
106
+ return self._sign_in(resource, tenant_id)
107
+
108
+ def sign_out(self, tenant_id: str) -> bool:
109
+ """Forget the kept sign-in for ``tenant_id``. True when there was one."""
110
+ with self._lock:
111
+ return self._store.delete(self._key(tenant_id))
112
+
113
+ def _key(self, tenant_id: str) -> str:
114
+ # One sign-in per cloud, app and tenant.
115
+ return f"{urlsplit(self.login_url).netloc}|{self.client_id}|{tenant_id.lower()}"
116
+
117
+ def _recall(self, tenant_id: str) -> str | None:
118
+ try:
119
+ return self._store.load(self._key(tenant_id))
120
+ except LdoError as exc:
121
+ self._notify(f"The kept sign-in cannot be used ({exc}); signing in afresh.")
122
+ return None
123
+
124
+ def _remember(self, tenant_id: str, refresh: str) -> None:
125
+ try:
126
+ self._store.save(self._key(tenant_id), refresh)
127
+ except LdoError as exc:
128
+ # The token works for this command all the same.
129
+ self._notify(f"The sign-in could not be kept for the next command: {exc}")
130
+
131
+ def _forget(self, tenant_id: str) -> None:
132
+ try:
133
+ self._store.delete(self._key(tenant_id))
134
+ except LdoError as exc:
135
+ log.warning("could not forget a refused refresh token: %s", exc)
136
+
137
+ def _sign_in(self, resource: str, tenant_id: str) -> AccessToken:
138
+ raise NotImplementedError
139
+
140
+ def _sign_in_with_device_code(self, resource: str, tenant_id: str) -> AccessToken:
141
+ """The device code flow: a code to enter at the device login page, then a poll."""
142
+ try:
143
+ flow = self._post(tenant_id, "devicecode", {"scope": self._scope(resource)})
144
+ except ApiError as exc:
145
+ raise AuthError(str(exc), hint=_hint(exc)) from None
146
+ device_code = str(flow.get("device_code") or "")
147
+ if not device_code:
148
+ raise AuthError("the device code response has no device_code")
149
+ self._notify(
150
+ str(
151
+ flow.get("message")
152
+ or f"Enter {flow.get('user_code')} at {flow.get('verification_uri')}"
153
+ )
154
+ )
155
+ interval = float(flow.get("interval") or 5)
156
+ deadline = self._monotonic() + float(flow.get("expires_in") or 900)
157
+ form = {"grant_type": DEVICE_CODE_GRANT, "device_code": device_code}
158
+ while self._monotonic() < deadline:
159
+ self._sleep(interval)
160
+ now = self._clock()
161
+ try:
162
+ data = self._post(tenant_id, "token", {**form, "scope": self._scope(resource)})
163
+ except ApiError as exc:
164
+ if exc.code == "authorization_pending":
165
+ continue
166
+ if exc.code == "slow_down":
167
+ interval += 5
168
+ continue
169
+ raise AuthError(str(exc), hint=_hint(exc)) from None
170
+ return self._keep(data, resource, tenant_id, now)
171
+ raise AuthError("the device code expired before sign-in completed; start again")
172
+
173
+ def _scope(self, resource: str) -> str:
174
+ return f"{scope_for(resource)} offline_access"
175
+
176
+ def _post(self, tenant_id: str, path: str, form: Mapping[str, str]) -> dict[str, Any]:
177
+ return self._endpoint.request(
178
+ "POST", f"/{tenant_id}/oauth2/v2.0/{path}", form={"client_id": self.client_id, **form}
179
+ )
180
+
181
+ def _redeem(self, tenant_id: str, resource: str, form: Mapping[str, str]) -> AccessToken:
182
+ now = self._clock()
183
+ try:
184
+ data = self._post(tenant_id, "token", {**form, "scope": self._scope(resource)})
185
+ except ApiError as exc:
186
+ raise AuthError(str(exc), hint=_hint(exc)) from None
187
+ return self._keep(data, resource, tenant_id, now)
188
+
189
+ def _keep(
190
+ self, data: Mapping[str, Any], resource: str, tenant_id: str, now: datetime
191
+ ) -> AccessToken:
192
+ refresh = data.get("refresh_token")
193
+ if isinstance(refresh, str) and refresh:
194
+ self._remember(tenant_id, refresh)
195
+ return parse_token_response(data, resource, tenant_id, now=now, name="Entra ID sign-in")
196
+
197
+
198
+ class CodeReceiver(Protocol):
199
+ """Where the browser's redirect lands. ``wait`` returns its query parameters."""
200
+
201
+ redirect_uri: str
202
+
203
+ def wait(self, timeout: float) -> dict[str, str]:
204
+ """The redirect's query parameters, once it arrives within ``timeout`` seconds."""
205
+
206
+ def close(self) -> None:
207
+ """Stop listening."""
208
+
209
+
210
+ class LoopbackReceiver:
211
+ """A one-shot HTTP listener on 127.0.0.1 for the sign-in redirect."""
212
+
213
+ def __init__(self, port: int = 0) -> None:
214
+ received: dict[str, str] = {}
215
+
216
+ class Handler(http.server.BaseHTTPRequestHandler):
217
+ def do_GET(self) -> None:
218
+ query = {k: v[0] for k, v in parse_qs(urlsplit(self.path).query).items()}
219
+ if "code" in query or "error" in query:
220
+ received.update(query)
221
+ body = SIGNED_IN_PAGE
222
+ self.send_response(200)
223
+ else:
224
+ body = b"Not found"
225
+ self.send_response(404)
226
+ self.send_header("Content-Type", "text/html; charset=utf-8")
227
+ self.send_header("Content-Length", str(len(body)))
228
+ self.end_headers()
229
+ self.wfile.write(body)
230
+
231
+ def log_message(self, *args: object) -> None:
232
+ # The query holds the authorisation code, so the request line is never logged.
233
+ return
234
+
235
+ self._received = received
236
+ self._server = http.server.HTTPServer(("127.0.0.1", port), Handler)
237
+ self._server.timeout = 0.5
238
+ self.redirect_uri = f"http://localhost:{self._server.server_address[1]}"
239
+
240
+ def wait(self, timeout: float) -> dict[str, str]:
241
+ deadline = time.monotonic() + timeout
242
+ while not self._received and time.monotonic() < deadline:
243
+ self._server.handle_request()
244
+ if not self._received:
245
+ raise AuthError(f"no sign-in completed within {int(timeout)}s")
246
+ return dict(self._received)
247
+
248
+ def close(self) -> None:
249
+ self._server.server_close()
250
+
251
+
252
+ class InteractiveCredential(_DelegatedCredential):
253
+ """Delegated tokens from a browser sign-in (authorisation code with PKCE)."""
254
+
255
+ def __init__(
256
+ self,
257
+ client_id: str,
258
+ *,
259
+ login_url: str = PUBLIC.login_url,
260
+ session: requests.Session | None = None,
261
+ verify: bool | str = True,
262
+ clock: Callable[[], datetime] = utc_now,
263
+ sleep: Callable[[float], None] = time.sleep,
264
+ notify: Callable[[str], None] | None = None,
265
+ store: TokenStore | None = None,
266
+ open_browser: Callable[[str], object] = webbrowser.open,
267
+ receiver: Callable[[], CodeReceiver] = LoopbackReceiver,
268
+ timeout: float = SIGN_IN_TIMEOUT,
269
+ has_browser: Callable[[], bool] | None = None,
270
+ monotonic: Callable[[], float] = time.monotonic,
271
+ ) -> None:
272
+ super().__init__(
273
+ client_id,
274
+ login_url=login_url,
275
+ session=session,
276
+ verify=verify,
277
+ clock=clock,
278
+ sleep=sleep,
279
+ notify=notify,
280
+ store=store,
281
+ monotonic=monotonic,
282
+ )
283
+ self._open_browser = open_browser
284
+ self._receiver = receiver
285
+ self._timeout = timeout
286
+ self._has_browser = has_browser or can_launch_browser
287
+
288
+ def _sign_in(self, resource: str, tenant_id: str) -> AccessToken:
289
+ if not self._has_browser():
290
+ # Headless: the browser's redirect could never reach this machine's listener.
291
+ self._notify("No web browser here, so signing in with a device code instead.")
292
+ return self._sign_in_with_device_code(resource, tenant_id)
293
+ verifier = secrets.token_urlsafe(64)
294
+ challenge = (
295
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
296
+ .rstrip(b"=")
297
+ .decode()
298
+ )
299
+ state = secrets.token_urlsafe(24)
300
+ receiver = self._receiver()
301
+ try:
302
+ params = {
303
+ "client_id": self.client_id,
304
+ "response_type": "code",
305
+ "redirect_uri": receiver.redirect_uri,
306
+ "response_mode": "query",
307
+ "scope": self._scope(resource),
308
+ "code_challenge": challenge,
309
+ "code_challenge_method": "S256",
310
+ "state": state,
311
+ "prompt": "select_account",
312
+ }
313
+ url = f"{self.login_url}/{quote(tenant_id)}/oauth2/v2.0/authorize?" + urlencode(
314
+ params, quote_via=quote
315
+ )
316
+ self._notify(f"Sign in to continue (opening your browser): {url}")
317
+ try:
318
+ self._open_browser(url)
319
+ except Exception:
320
+ log.debug("could not open a browser", exc_info=True)
321
+ query = receiver.wait(self._timeout)
322
+ finally:
323
+ receiver.close()
324
+ if query.get("state") != state:
325
+ raise AuthError("the sign-in response did not match this request; start again")
326
+ if "error" in query:
327
+ detail = query.get("error_description") or query["error"]
328
+ raise AuthError(f"sign-in failed: {detail.splitlines()[0]}", hint=_hint_text(detail))
329
+ code = query.get("code")
330
+ if not code:
331
+ raise AuthError("the sign-in response carried no authorisation code")
332
+ return self._redeem(
333
+ tenant_id,
334
+ resource,
335
+ {
336
+ "grant_type": "authorization_code",
337
+ "code": code,
338
+ "redirect_uri": receiver.redirect_uri,
339
+ "code_verifier": verifier,
340
+ },
341
+ )
342
+
343
+
344
+ class DeviceCodeCredential(_DelegatedCredential):
345
+ """Delegated tokens from a device code entered at the device login page."""
346
+
347
+ def __init__(
348
+ self,
349
+ client_id: str,
350
+ *,
351
+ login_url: str = PUBLIC.login_url,
352
+ session: requests.Session | None = None,
353
+ verify: bool | str = True,
354
+ clock: Callable[[], datetime] = utc_now,
355
+ sleep: Callable[[float], None] = time.sleep,
356
+ monotonic: Callable[[], float] = time.monotonic,
357
+ notify: Callable[[str], None] | None = None,
358
+ store: TokenStore | None = None,
359
+ ) -> None:
360
+ super().__init__(
361
+ client_id,
362
+ login_url=login_url,
363
+ session=session,
364
+ verify=verify,
365
+ clock=clock,
366
+ sleep=sleep,
367
+ notify=notify,
368
+ store=store,
369
+ monotonic=monotonic,
370
+ )
371
+
372
+ def _sign_in(self, resource: str, tenant_id: str) -> AccessToken:
373
+ return self._sign_in_with_device_code(resource, tenant_id)
374
+
375
+
376
+ def _hint(exc: ApiError) -> str | None:
377
+ return _hint_text(str(exc))
378
+
379
+
380
+ def _hint_text(text: str) -> str | None:
381
+ if "AADSTS65001" in text or "consent" in text.lower():
382
+ return "the app needs consent for these permissions; an admin can grant it in Entra ID"
383
+ if "AADSTS700016" in text:
384
+ return "no app with this client_id exists in the tenant; check the profile's client_id"
385
+ if "AADSTS7000218" in text:
386
+ return "the app must allow public client flows (Authentication > Allow public client flows)"
387
+ if "AADSTS50011" in text:
388
+ return "add http://localhost as a 'Mobile and desktop' redirect URI on the app"
389
+ if "AADSTS70043" in text or "AADSTS700082" in text or "invalid_grant" in text:
390
+ return "the sign-in expired; run the command again to sign in"
391
+ return None