pyoncat 2.5__tar.gz → 2.6__tar.gz
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.
- {pyoncat-2.5 → pyoncat-2.6}/PKG-INFO +1 -1
- {pyoncat-2.5 → pyoncat-2.6}/pyoncat/__init__.py +755 -74
- {pyoncat-2.5 → pyoncat-2.6}/pyproject.toml +1 -1
- {pyoncat-2.5 → pyoncat-2.6}/pyoncat/LICENSE +0 -0
- {pyoncat-2.5 → pyoncat-2.6}/pyoncat/README.md +0 -0
- {pyoncat-2.5 → pyoncat-2.6}/pyoncat/reduction.py +0 -0
|
@@ -18,7 +18,8 @@ import sys
|
|
|
18
18
|
import time
|
|
19
19
|
import warnings
|
|
20
20
|
import weakref
|
|
21
|
-
from
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, Union
|
|
22
23
|
from urllib.parse import quote
|
|
23
24
|
|
|
24
25
|
import oauthlib.oauth2
|
|
@@ -30,7 +31,10 @@ from urllib3.exceptions import InsecureRequestWarning
|
|
|
30
31
|
|
|
31
32
|
from pyoncat.reduction import ReductionFileBuilder
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
import threading
|
|
36
|
+
|
|
37
|
+
__version__ = "2.6"
|
|
34
38
|
|
|
35
39
|
# Constant for autoreduction user
|
|
36
40
|
AUTOREDUCTION_USER = "auto"
|
|
@@ -39,8 +43,19 @@ __all__ = [
|
|
|
39
43
|
"AUTOREDUCTION_USER",
|
|
40
44
|
"CLIENT_CREDENTIALS_FLOW",
|
|
41
45
|
"DEVICE_AUTHORIZATION_FLOW",
|
|
46
|
+
"REAUTH_INTERACTION_REQUIRED",
|
|
47
|
+
"REAUTH_NEVER",
|
|
48
|
+
"REAUTH_PROMPT",
|
|
42
49
|
"RESOURCE_OWNER_CREDENTIALS_FLOW",
|
|
50
|
+
"DeviceAuthorizationCancelled",
|
|
51
|
+
"DeviceAuthorizationChallenge",
|
|
52
|
+
"DeviceAuthorizationDenied",
|
|
53
|
+
"DeviceAuthorizationError",
|
|
54
|
+
"DeviceAuthorizationExpired",
|
|
55
|
+
"DeviceAuthorizationNetworkError",
|
|
56
|
+
"DeviceAuthorizationTimeout",
|
|
43
57
|
"FileSystemTokenStore",
|
|
58
|
+
"InteractionRequiredError",
|
|
44
59
|
"ONCat",
|
|
45
60
|
"ONCatObject",
|
|
46
61
|
"PyONCatError",
|
|
@@ -66,6 +81,50 @@ DEVICE_AUTHORIZATION_FLOW: FlowType = "device_authorization"
|
|
|
66
81
|
|
|
67
82
|
assert isinstance(CLIENT_CREDENTIALS_FLOW, str)
|
|
68
83
|
|
|
84
|
+
# Named `reauth_on_expired` modes selecting how the Device Authorization
|
|
85
|
+
# Grant recovers when the refresh token has expired. These string
|
|
86
|
+
# constants are the canonical, documented way to select a mode; the
|
|
87
|
+
# legacy `True`/`False` booleans remain accepted and are normalized to
|
|
88
|
+
# `REAUTH_NEVER` at the constructor boundary.
|
|
89
|
+
#
|
|
90
|
+
# - `REAUTH_PROMPT`: re-run the Device Authorization Grant (re-prompt)
|
|
91
|
+
# on expiry, then retry the original request once. Equivalent to
|
|
92
|
+
# the legacy `True`.
|
|
93
|
+
# - `REAUTH_NEVER`: raise on expiry without re-authenticating, for
|
|
94
|
+
# unattended / non-interactive callers. Equivalent to the legacy `False`.
|
|
95
|
+
# - `REAUTH_INTERACTION_REQUIRED`: attempt a silent refresh and, if the
|
|
96
|
+
# refresh token itself is dead, raise `InteractionRequiredError` rather
|
|
97
|
+
# than re-running `login()` from inside an ordinary API call. Its value
|
|
98
|
+
# stays the literal `"raise"` for backward compatibility.
|
|
99
|
+
ReauthMode = Literal["prompt", "never", "raise"]
|
|
100
|
+
ReauthOnExpired = Union[bool, ReauthMode]
|
|
101
|
+
REAUTH_PROMPT: ReauthMode = "prompt"
|
|
102
|
+
REAUTH_NEVER: ReauthMode = "never"
|
|
103
|
+
REAUTH_INTERACTION_REQUIRED: ReauthMode = "raise"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalize_reauth_on_expired(value: ReauthOnExpired) -> ReauthMode:
|
|
107
|
+
"""Map a `reauth_on_expired` argument onto a canonical `ReauthMode`.
|
|
108
|
+
|
|
109
|
+
Accepts the legacy booleans (`True` -> `REAUTH_PROMPT`, `False` ->
|
|
110
|
+
`REAUTH_NEVER`) for backward compatibility alongside the named string
|
|
111
|
+
constants, so the dispatch logic only ever compares against the
|
|
112
|
+
`REAUTH_*` constants.
|
|
113
|
+
"""
|
|
114
|
+
if value is True:
|
|
115
|
+
return REAUTH_PROMPT
|
|
116
|
+
if value is False:
|
|
117
|
+
return REAUTH_NEVER
|
|
118
|
+
if value in {REAUTH_PROMPT, REAUTH_NEVER, REAUTH_INTERACTION_REQUIRED}:
|
|
119
|
+
return value
|
|
120
|
+
msg = (
|
|
121
|
+
"reauth_on_expired must be one of REAUTH_PROMPT, REAUTH_NEVER, "
|
|
122
|
+
"REAUTH_INTERACTION_REQUIRED (or the legacy True/False), "
|
|
123
|
+
f"not {value!r}."
|
|
124
|
+
)
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
|
|
127
|
+
|
|
69
128
|
# RFC 8628 §3.5 default polling interval (seconds), used when the device
|
|
70
129
|
# authorization response does not specify one.
|
|
71
130
|
_DEFAULT_DEVICE_FLOW_INTERVAL = 5
|
|
@@ -83,6 +142,68 @@ _DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
|
83
142
|
# `oncat_cli_user` client.
|
|
84
143
|
_DEFAULT_IDP_URL = "https://extidp.ornl.gov"
|
|
85
144
|
|
|
145
|
+
# Finite default connect/read timeout (seconds) for the two
|
|
146
|
+
# device-authorization HTTP POSTs (the authorization request and the token
|
|
147
|
+
# poll). The general `self._timeout` defaults to `None` (wait
|
|
148
|
+
# indefinitely), which is the right semantics for ordinary API calls but a
|
|
149
|
+
# footgun for the device-authorization poll loop: a hung IdP connection
|
|
150
|
+
# would block the loop forever. This bounds each individual POST so a
|
|
151
|
+
# stalled connection backs off and the loop keeps polling within the
|
|
152
|
+
# overall deadline instead of hanging. It is distinct from both
|
|
153
|
+
# `self._timeout` (per-API-request) and `device_authorization_timeout`
|
|
154
|
+
# (the overall caller deadline). Override per call via the `http_timeout`
|
|
155
|
+
# argument to `login()`.
|
|
156
|
+
_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT = 30.0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class DeviceAuthorizationChallenge:
|
|
161
|
+
"""The user-facing details of a Device Authorization Grant challenge.
|
|
162
|
+
|
|
163
|
+
The Device Authorization Grant (often called the device flow) is the
|
|
164
|
+
OAuth 2.0 sign-in defined by RFC 8628. PyONCat passes one of these to
|
|
165
|
+
a configured `verification_handler`
|
|
166
|
+
whenever the user needs to approve a sign-in (RFC 8628 §3.2). The app
|
|
167
|
+
shows the user the link (and, if needed, the code) so they can approve
|
|
168
|
+
in a browser.
|
|
169
|
+
|
|
170
|
+
Attributes
|
|
171
|
+
----------
|
|
172
|
+
verification_uri: str
|
|
173
|
+
The URL the user opens to approve the sign-in. Show this (with
|
|
174
|
+
`user_code`) when `verification_uri_complete` is `None`.
|
|
175
|
+
|
|
176
|
+
user_code: str
|
|
177
|
+
The one-time code the user enters at `verification_uri`.
|
|
178
|
+
|
|
179
|
+
verification_uri_complete: str | None
|
|
180
|
+
A ready-to-use link that already embeds `user_code` (RFC 8628
|
|
181
|
+
§3.3.1; OPTIONAL). Prefer showing this when present.
|
|
182
|
+
|
|
183
|
+
expires_in: int | None
|
|
184
|
+
Seconds, from when the challenge was issued, after which the
|
|
185
|
+
device code expires (RFC 8628 §3.2). `None` if the IdP omitted
|
|
186
|
+
it.
|
|
187
|
+
|
|
188
|
+
interval: float
|
|
189
|
+
The minimum number of seconds to wait between token polls
|
|
190
|
+
(RFC 8628 §3.2).
|
|
191
|
+
|
|
192
|
+
expires_at: float | None
|
|
193
|
+
The monotonic-clock deadline (`time.monotonic()` basis) after
|
|
194
|
+
which the challenge expires, derived from `expires_in` at the
|
|
195
|
+
moment the challenge was created. `None` when `expires_in` is
|
|
196
|
+
absent.
|
|
197
|
+
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
verification_uri: str
|
|
201
|
+
user_code: str
|
|
202
|
+
verification_uri_complete: str | None
|
|
203
|
+
expires_in: int | None
|
|
204
|
+
interval: float
|
|
205
|
+
expires_at: float | None
|
|
206
|
+
|
|
86
207
|
|
|
87
208
|
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
|
|
88
209
|
"Your ONCat session has expired and could not be automatically "
|
|
@@ -98,11 +219,11 @@ _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
|
|
|
98
219
|
"\n\n"
|
|
99
220
|
"If you want PyONCat to prompt you to re-authenticate automatically "
|
|
100
221
|
"when this\n"
|
|
101
|
-
"happens, construct the client with
|
|
102
|
-
"default).\n"
|
|
222
|
+
"happens, construct the client with "
|
|
223
|
+
"`reauth_on_expired=REAUTH_PROMPT` (the default).\n"
|
|
103
224
|
"\n"
|
|
104
|
-
"Set `reauth_on_expired=
|
|
105
|
-
"in which\n"
|
|
225
|
+
"Set `reauth_on_expired=REAUTH_NEVER` for unattended / "
|
|
226
|
+
"non-interactive use, in which\n"
|
|
106
227
|
"case this exception is raised immediately on session expiry."
|
|
107
228
|
)
|
|
108
229
|
|
|
@@ -218,14 +339,46 @@ class ONCat:
|
|
|
218
339
|
hit the IdP directly. When `None`, defaults to
|
|
219
340
|
`https://extidp.ornl.gov` (ORNL's external PingFed).
|
|
220
341
|
|
|
221
|
-
reauth_on_expired:
|
|
342
|
+
reauth_on_expired: ReauthOnExpired
|
|
222
343
|
When using `DEVICE_AUTHORIZATION_FLOW` and the refresh
|
|
223
|
-
token has expired, controls how the client recovers.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
344
|
+
token has expired, controls how the client recovers. Select
|
|
345
|
+
a mode with one of the named constants (the canonical form);
|
|
346
|
+
the legacy `True`/`False` booleans are still accepted and map
|
|
347
|
+
to `REAUTH_PROMPT` / `REAUTH_NEVER` respectively.
|
|
348
|
+
`REAUTH_PROMPT` (the default) prompts on stderr for the user
|
|
349
|
+
to press Enter to re-authenticate via the browser, then
|
|
350
|
+
retries the original request once. `REAUTH_NEVER` raises
|
|
227
351
|
`InvalidRefreshTokenError` immediately, which is
|
|
228
352
|
appropriate for unattended or non-interactive use.
|
|
353
|
+
`REAUTH_INTERACTION_REQUIRED` first attempts a silent
|
|
354
|
+
refresh and, only if the refresh token itself is dead,
|
|
355
|
+
raises `InteractionRequiredError` rather than re-running
|
|
356
|
+
`login()` (and firing the verification callback) from
|
|
357
|
+
inside an ordinary API call. This lets a GUI decide *when*
|
|
358
|
+
to prompt the user instead of having a data call pop a
|
|
359
|
+
sign-in dialog from a worker thread.
|
|
360
|
+
|
|
361
|
+
verification_handler:
|
|
362
|
+
Callable[[DeviceAuthorizationChallenge], None] | None
|
|
363
|
+
A function PyONCat calls whenever the user needs to sign in -
|
|
364
|
+
on the first login and again later if the session expires.
|
|
365
|
+
It receives a single `DeviceAuthorizationChallenge` carrying the
|
|
366
|
+
verification URI, user code, complete URI, expiry, and
|
|
367
|
+
polling interval, so the app has everything it needs (e.g.
|
|
368
|
+
to show a code-expiry countdown). Your function is
|
|
369
|
+
responsible for displaying the sign-in link to the user, who
|
|
370
|
+
opens it in a browser to approve the sign-in:
|
|
371
|
+
|
|
372
|
+
- By default, show `verification_uri_complete`: a ready-to-use
|
|
373
|
+
link that already includes the code.
|
|
374
|
+
- If it is `None`, show `verification_uri` instead and have the
|
|
375
|
+
user enter `user_code` on that page.
|
|
376
|
+
|
|
377
|
+
Supplying this callback is what lets sign-in work in a GUI app
|
|
378
|
+
that has no console. When it is set, the same callback is
|
|
379
|
+
reused if the session later needs the user to sign in again, so
|
|
380
|
+
the app is never left waiting on console input (see
|
|
381
|
+
`reauth_on_expired`).
|
|
229
382
|
|
|
230
383
|
"""
|
|
231
384
|
|
|
@@ -267,7 +420,9 @@ class ONCat:
|
|
|
267
420
|
verify: bool = True,
|
|
268
421
|
timeout: int | None = None,
|
|
269
422
|
idp_url: str | None = None,
|
|
270
|
-
reauth_on_expired:
|
|
423
|
+
reauth_on_expired: ReauthOnExpired = REAUTH_PROMPT,
|
|
424
|
+
verification_handler: Callable[[DeviceAuthorizationChallenge], None]
|
|
425
|
+
| None = None,
|
|
271
426
|
):
|
|
272
427
|
if not url:
|
|
273
428
|
msg = "A URL must be provided."
|
|
@@ -306,6 +461,7 @@ class ONCat:
|
|
|
306
461
|
self._token_getter = token_getter
|
|
307
462
|
self._token_setter = token_setter
|
|
308
463
|
self._login_prompt = login_prompt
|
|
464
|
+
self._verification_handler = verification_handler
|
|
309
465
|
self._api_token = api_token
|
|
310
466
|
self._client_id = client_id
|
|
311
467
|
self._client_secret = client_secret
|
|
@@ -315,7 +471,9 @@ class ONCat:
|
|
|
315
471
|
self._verify = verify
|
|
316
472
|
self._timeout = timeout
|
|
317
473
|
self._idp_url = idp_url if idp_url is not None else _DEFAULT_IDP_URL
|
|
318
|
-
self._reauth_on_expired =
|
|
474
|
+
self._reauth_on_expired = _normalize_reauth_on_expired(
|
|
475
|
+
reauth_on_expired
|
|
476
|
+
)
|
|
319
477
|
|
|
320
478
|
self._token = None
|
|
321
479
|
self._oauth_client = None
|
|
@@ -325,7 +483,7 @@ class ONCat:
|
|
|
325
483
|
"OAuth 2.0 Resource Owner Password Credentials grant is "
|
|
326
484
|
'deprecated. RFC 9700 §2.4: "The resource owner password '
|
|
327
485
|
'credentials grant [RFC6749] MUST NOT be used." Prefer '
|
|
328
|
-
"the
|
|
486
|
+
"the Device Authorization Grant "
|
|
329
487
|
"(flow=DEVICE_AUTHORIZATION_FLOW) for human-user "
|
|
330
488
|
"authentication. ROPC remains available indefinitely as "
|
|
331
489
|
"a compatibility path.",
|
|
@@ -399,6 +557,20 @@ class ONCat:
|
|
|
399
557
|
|
|
400
558
|
try:
|
|
401
559
|
return send_request()
|
|
560
|
+
except UnauthorizedError as error:
|
|
561
|
+
# Device Authorization Grant only: a 401 on the data call
|
|
562
|
+
# means the access token is dead server-side but oauthlib did
|
|
563
|
+
# not proactively refresh -- e.g. a token persisted without
|
|
564
|
+
# `expires_at` by an older PyONCat, so the proactive gate
|
|
565
|
+
# never fired. Attempt exactly ONE silent refresh + ONE retry;
|
|
566
|
+
# if that still fails, fall through to the expiry handler. The
|
|
567
|
+
# other flows keep the 2.5 behavior: re-raise the 401
|
|
568
|
+
# untouched.
|
|
569
|
+
if self._flow != DEVICE_AUTHORIZATION_FLOW:
|
|
570
|
+
raise
|
|
571
|
+
return self._handle_device_authorization_unauthorized(
|
|
572
|
+
send_request, error
|
|
573
|
+
)
|
|
402
574
|
except rfc6749.errors.InvalidGrantError as error:
|
|
403
575
|
if (
|
|
404
576
|
self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW
|
|
@@ -441,40 +613,231 @@ class ONCat:
|
|
|
441
613
|
msg = f'Error: "{error!s}"'
|
|
442
614
|
raise PyONCatError(msg) from error
|
|
443
615
|
|
|
444
|
-
def
|
|
445
|
-
"""
|
|
616
|
+
def _handle_device_authorization_unauthorized(self, send_request, error):
|
|
617
|
+
"""Bounded reactive recovery from a Device Authorization Grant
|
|
618
|
+
data-call 401.
|
|
619
|
+
|
|
620
|
+
Attempts exactly ONE silent refresh (the `refresh_token` grant via
|
|
621
|
+
the wired `OAuth2Session`) and ONE retry of the original request.
|
|
622
|
+
This terminates by construction:
|
|
623
|
+
|
|
624
|
+
- The retried `send_request()` is called directly here, NOT through
|
|
625
|
+
`_call_method`, so a second consecutive 401 raises straight back
|
|
626
|
+
to the caller without re-entering this handler -- there is no
|
|
627
|
+
loop.
|
|
628
|
+
- If the silent refresh itself fails (the refresh token is dead),
|
|
629
|
+
control falls through to `_handle_device_flow_token_expiry`,
|
|
630
|
+
which raises `InteractionRequiredError` under
|
|
631
|
+
`reauth_on_expired=REAUTH_INTERACTION_REQUIRED` (or behaves
|
|
632
|
+
per the `REAUTH_PROMPT` / `REAUTH_NEVER` modes).
|
|
633
|
+
|
|
634
|
+
This is the backstop for a token that lacks `expires_at` (so
|
|
635
|
+
oauthlib never refreshed proactively) and for a server-side
|
|
636
|
+
revocation between proactive checks.
|
|
637
|
+
"""
|
|
638
|
+
token = self._load_token()
|
|
639
|
+
refresh_token = token.get("refresh_token") if token else None
|
|
640
|
+
oauth_client = self._oauth_client
|
|
641
|
+
|
|
642
|
+
if refresh_token and oauth_client is not None:
|
|
643
|
+
token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
|
|
644
|
+
refresh_kwargs = {
|
|
645
|
+
"client_id": self._client_id,
|
|
646
|
+
"verify": self._should_verify(),
|
|
647
|
+
}
|
|
648
|
+
try:
|
|
649
|
+
with warnings.catch_warnings():
|
|
650
|
+
warnings.filterwarnings(
|
|
651
|
+
"ignore", category=DeprecationWarning
|
|
652
|
+
)
|
|
653
|
+
refreshed = oauth_client.refresh_token(
|
|
654
|
+
token_url,
|
|
655
|
+
refresh_token=refresh_token,
|
|
656
|
+
**refresh_kwargs,
|
|
657
|
+
)
|
|
658
|
+
except requests.exceptions.RequestException as exc:
|
|
659
|
+
# A transport-layer failure is not evidence that the
|
|
660
|
+
# refresh token is dead, so the stored token is left
|
|
661
|
+
# intact and the failure surfaces as a typed network
|
|
662
|
+
# error the caller can branch on without being signed
|
|
663
|
+
# out by a blip.
|
|
664
|
+
msg = f"Silent token refresh failed: {exc}"
|
|
665
|
+
raise DeviceAuthorizationNetworkError(msg) from exc
|
|
666
|
+
except rfc6749.errors.OAuth2Error:
|
|
667
|
+
# A genuine OAuth rejection (e.g. `invalid_grant`) means
|
|
668
|
+
# the refresh token itself is dead; hand off to the
|
|
669
|
+
# expiry handler for the re-auth decision.
|
|
670
|
+
return self._handle_device_flow_token_expiry(
|
|
671
|
+
send_request, error
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
self._save_token(refreshed)
|
|
675
|
+
# The single retry runs `send_request()` directly: another 401
|
|
676
|
+
# here propagates without re-triggering a refresh (no loop).
|
|
677
|
+
return send_request()
|
|
678
|
+
|
|
679
|
+
# No usable refresh token: defer to the expiry handler.
|
|
680
|
+
return self._handle_device_flow_token_expiry(send_request, error)
|
|
446
681
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
682
|
+
def _handle_device_flow_token_expiry(self, send_request, error):
|
|
683
|
+
"""Recover from an expired Device Authorization Grant refresh
|
|
684
|
+
token.
|
|
685
|
+
|
|
686
|
+
Reached only after `requests_oauthlib`'s silent refresh has
|
|
687
|
+
already failed (the refresh token itself is dead). Clears the
|
|
688
|
+
persisted token and recovers according to `reauth_on_expired`:
|
|
689
|
+
|
|
690
|
+
- `REAUTH_PROMPT` (the default): prompts on stderr for the user
|
|
691
|
+
to re-authenticate via the browser before retrying the original
|
|
692
|
+
request once. When a `verification_handler` is configured, the
|
|
693
|
+
stdin "press Enter" gate is skipped entirely -- the callback
|
|
694
|
+
itself is the
|
|
695
|
+
console-less app's presence/notification surface, so the flow
|
|
696
|
+
re-runs (invoking the callback) and the original request is
|
|
697
|
+
retried once.
|
|
698
|
+
- `REAUTH_NEVER`: raises `InvalidRefreshTokenError` immediately so
|
|
699
|
+
callers running unattended can react without blocking on
|
|
700
|
+
`input()`.
|
|
701
|
+
- `REAUTH_INTERACTION_REQUIRED`: raises
|
|
702
|
+
`InteractionRequiredError` so a GUI can decide when and how to
|
|
703
|
+
prompt the user, rather than having an ordinary data call fire
|
|
704
|
+
the verification callback from a worker thread.
|
|
452
705
|
"""
|
|
453
706
|
self._save_token(None)
|
|
454
707
|
|
|
455
|
-
if
|
|
456
|
-
raise
|
|
708
|
+
if self._reauth_on_expired == REAUTH_INTERACTION_REQUIRED:
|
|
709
|
+
raise InteractionRequiredError(
|
|
457
710
|
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
458
711
|
) from error
|
|
459
712
|
|
|
460
|
-
|
|
461
|
-
try:
|
|
462
|
-
input()
|
|
463
|
-
except EOFError as eof_error:
|
|
464
|
-
# Non-interactive stdin (piped, closed, no human present);
|
|
465
|
-
# do not auto-recover -- the prompt's purpose was to confirm
|
|
466
|
-
# human presence.
|
|
713
|
+
if self._reauth_on_expired == REAUTH_NEVER:
|
|
467
714
|
raise InvalidRefreshTokenError(
|
|
468
715
|
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
469
|
-
) from
|
|
470
|
-
# KeyboardInterrupt propagates naturally; no special handling.
|
|
716
|
+
) from error
|
|
471
717
|
|
|
472
|
-
|
|
473
|
-
|
|
718
|
+
if self._verification_handler is None:
|
|
719
|
+
print(_DEVICE_FLOW_REAUTH_PROMPT, file=sys.stderr)
|
|
720
|
+
try:
|
|
721
|
+
input()
|
|
722
|
+
except EOFError as eof_error:
|
|
723
|
+
# Non-interactive stdin (piped, closed, no human present);
|
|
724
|
+
# do not auto-recover -- the prompt's purpose was to confirm
|
|
725
|
+
# human presence.
|
|
726
|
+
raise InvalidRefreshTokenError(
|
|
727
|
+
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
728
|
+
) from eof_error
|
|
729
|
+
# KeyboardInterrupt propagates naturally; no special handling.
|
|
730
|
+
|
|
731
|
+
# Drop any cached OAuth session so `login()` re-runs the Device
|
|
732
|
+
# Authorization Grant rather than reusing an exhausted refresh
|
|
733
|
+
# token.
|
|
474
734
|
self._oauth_client = None
|
|
475
735
|
self.login()
|
|
476
736
|
return send_request()
|
|
477
737
|
|
|
738
|
+
def logout(self, http_timeout: float | None = None):
|
|
739
|
+
"""Sign out: revoke the refresh token and clear the local token.
|
|
740
|
+
|
|
741
|
+
The public counterpart to `login()`. When a token with a
|
|
742
|
+
`refresh_token` is present, this POSTs to the IdP's RFC 7009 token
|
|
743
|
+
revocation endpoint (`<idp_url>/as/revoke_token.oauth2`, derived the
|
|
744
|
+
same way as the device-authorization and token endpoints) to revoke
|
|
745
|
+
the refresh token server-side, then always clears the locally stored
|
|
746
|
+
token so a sign-out succeeds locally even when the server call does
|
|
747
|
+
not. The cached OAuth session is torn down so a subsequent `login()`
|
|
748
|
+
starts a fresh Device Authorization Grant rather than reusing an
|
|
749
|
+
exhausted session.
|
|
750
|
+
|
|
751
|
+
Revocation uses the configured public `client_id` and no secret
|
|
752
|
+
(the device-authorization client is a public client, RFC 8628
|
|
753
|
+
/ ADR-0058).
|
|
754
|
+
|
|
755
|
+
Per RFC 7009 §2.2 the revocation endpoint returns HTTP 200 even for
|
|
756
|
+
a token that is already invalid, so a 200 confirms the request was
|
|
757
|
+
accepted -- not that a specific token was live at the time.
|
|
758
|
+
|
|
759
|
+
Arguments:
|
|
760
|
+
---------
|
|
761
|
+
http_timeout: float | None
|
|
762
|
+
The connect/read HTTP timeout (seconds) for the revocation
|
|
763
|
+
POST. `None` (the default) uses
|
|
764
|
+
`_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT`, the same
|
|
765
|
+
finite bound the Device Authorization Grant applies, so a
|
|
766
|
+
hung IdP connection backs off
|
|
767
|
+
instead of blocking the sign-out indefinitely.
|
|
768
|
+
|
|
769
|
+
Raises:
|
|
770
|
+
------
|
|
771
|
+
PyONCatError
|
|
772
|
+
If the revocation request fails (non-2xx response or a
|
|
773
|
+
transport error). The local token is cleared first, so the
|
|
774
|
+
caller is signed out locally regardless; the exception
|
|
775
|
+
surfaces that server-side revocation did not succeed.
|
|
776
|
+
|
|
777
|
+
"""
|
|
778
|
+
token = self._load_token()
|
|
779
|
+
refresh_token = token.get("refresh_token") if token else None
|
|
780
|
+
|
|
781
|
+
revocation_error: Exception | None = None
|
|
782
|
+
if refresh_token:
|
|
783
|
+
revocation_url = (
|
|
784
|
+
self._idp_url.rstrip("/") + "/as/revoke_token.oauth2"
|
|
785
|
+
)
|
|
786
|
+
post_timeout = (
|
|
787
|
+
http_timeout
|
|
788
|
+
if http_timeout is not None
|
|
789
|
+
else _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT
|
|
790
|
+
)
|
|
791
|
+
try:
|
|
792
|
+
revocation_response = requests.post(
|
|
793
|
+
revocation_url,
|
|
794
|
+
data={
|
|
795
|
+
"token": refresh_token,
|
|
796
|
+
"token_type_hint": "refresh_token",
|
|
797
|
+
"client_id": self._client_id,
|
|
798
|
+
},
|
|
799
|
+
verify=self._should_verify(),
|
|
800
|
+
timeout=post_timeout,
|
|
801
|
+
headers={
|
|
802
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
803
|
+
"Accept": "application/json",
|
|
804
|
+
},
|
|
805
|
+
)
|
|
806
|
+
except requests.exceptions.RequestException as exc:
|
|
807
|
+
revocation_error = exc
|
|
808
|
+
else:
|
|
809
|
+
if not revocation_response.ok:
|
|
810
|
+
revocation_error = PyONCatError(
|
|
811
|
+
"Token revocation request failed with status "
|
|
812
|
+
f"{revocation_response.status_code}: "
|
|
813
|
+
f"{revocation_response.text}"
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
# Drop the cached session first so the in-memory teardown holds even
|
|
817
|
+
# if the token-store write raises: a caller-supplied store can fail,
|
|
818
|
+
# and a failed store write must not leave a live session behind. The
|
|
819
|
+
# store-write exception still propagates to the caller below.
|
|
820
|
+
self._oauth_client = None
|
|
821
|
+
self._save_token(None)
|
|
822
|
+
|
|
823
|
+
if revocation_error is not None:
|
|
824
|
+
msg = (
|
|
825
|
+
"Signed out locally, but server-side token revocation "
|
|
826
|
+
f"failed: {revocation_error}"
|
|
827
|
+
)
|
|
828
|
+
raise PyONCatError(msg) from revocation_error
|
|
829
|
+
|
|
830
|
+
def has_stored_token(self) -> bool:
|
|
831
|
+
"""Return True if a (non-null) token is already persisted.
|
|
832
|
+
|
|
833
|
+
Reads through the configured token store / token_getter; does
|
|
834
|
+
NOT contact the IdP and does NOT validate the token (it may be
|
|
835
|
+
expired). Lets a GUI decide its initial signed-in vs signed-out
|
|
836
|
+
state without a network round trip. A session cleared by
|
|
837
|
+
logout() (persisted as JSON null) reads as absent.
|
|
838
|
+
"""
|
|
839
|
+
return self._load_token() is not None
|
|
840
|
+
|
|
478
841
|
def _load_oauth_client(self):
|
|
479
842
|
if not self._oauth_client:
|
|
480
843
|
self.login()
|
|
@@ -513,7 +876,14 @@ class ONCat:
|
|
|
513
876
|
and "proxy" not in self._url
|
|
514
877
|
)
|
|
515
878
|
|
|
516
|
-
def login(
|
|
879
|
+
def login(
|
|
880
|
+
self,
|
|
881
|
+
username=None,
|
|
882
|
+
password=None,
|
|
883
|
+
cancel_event: threading.Event | None = None,
|
|
884
|
+
device_authorization_timeout: float | None = None,
|
|
885
|
+
http_timeout: float | None = None,
|
|
886
|
+
):
|
|
517
887
|
"""Login to the ONCat API as part of an appropriate OAuth flow.
|
|
518
888
|
|
|
519
889
|
This method is no longer strictly necessary to call manually,
|
|
@@ -534,6 +904,37 @@ class ONCat:
|
|
|
534
904
|
password: str | None
|
|
535
905
|
The password to use when logging in.
|
|
536
906
|
|
|
907
|
+
cancel_event: threading.Event | None
|
|
908
|
+
Device Authorization Grant only. When supplied, the
|
|
909
|
+
poll loop checks it around each sleep and before each
|
|
910
|
+
token POST; if it is set, the loop raises
|
|
911
|
+
`DeviceAuthorizationCancelled`. This is how a GUI
|
|
912
|
+
"Cancel" button truly aborts a sign-in in progress: Qt
|
|
913
|
+
(or any toolkit) cannot interrupt blocking Python, so
|
|
914
|
+
cancellation must be cooperative and live in PyONCat.
|
|
915
|
+
`None` (the default) means today's behavior -- no
|
|
916
|
+
cancellation.
|
|
917
|
+
|
|
918
|
+
device_authorization_timeout: float | None
|
|
919
|
+
Device Authorization Grant only. An overall caller
|
|
920
|
+
deadline in seconds for the sign-in, distinct from the
|
|
921
|
+
RFC 8628 server-side `expires_in`. When set, polling
|
|
922
|
+
stops at the deadline with `DeviceAuthorizationTimeout`.
|
|
923
|
+
Polling also stops no later than the server's own
|
|
924
|
+
`expires_in` (the effective deadline is the earlier of
|
|
925
|
+
the two). `None` (the default) means no overall deadline
|
|
926
|
+
-- today's behavior.
|
|
927
|
+
|
|
928
|
+
http_timeout: float | None
|
|
929
|
+
Device Authorization Grant only. The connect/read HTTP
|
|
930
|
+
timeout (seconds) applied to the two device-authorization POSTs
|
|
931
|
+
(device-authorization and token poll). `None` (the
|
|
932
|
+
default) uses `_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT`, a
|
|
933
|
+
finite value so a hung IdP connection backs off and the
|
|
934
|
+
loop keeps polling within the overall deadline instead of
|
|
935
|
+
hanging forever. This is distinct from the constructor's
|
|
936
|
+
`timeout` (which applies to ordinary API requests).
|
|
937
|
+
|
|
537
938
|
"""
|
|
538
939
|
if self._flow == CLIENT_CREDENTIALS_FLOW:
|
|
539
940
|
self._login_client_credentials()
|
|
@@ -554,7 +955,11 @@ class ONCat:
|
|
|
554
955
|
else:
|
|
555
956
|
self._login_resource_owner_credentials(username, password)
|
|
556
957
|
elif self._flow == DEVICE_AUTHORIZATION_FLOW:
|
|
557
|
-
self._login_device_authorization(
|
|
958
|
+
self._login_device_authorization(
|
|
959
|
+
cancel_event=cancel_event,
|
|
960
|
+
device_authorization_timeout=device_authorization_timeout,
|
|
961
|
+
http_timeout=http_timeout,
|
|
962
|
+
)
|
|
558
963
|
else:
|
|
559
964
|
raise AssertionError
|
|
560
965
|
|
|
@@ -658,8 +1063,12 @@ class ONCat:
|
|
|
658
1063
|
|
|
659
1064
|
def _login_device_authorization(
|
|
660
1065
|
self,
|
|
661
|
-
prompt: Callable[[str, str], None] | None = None,
|
|
1066
|
+
prompt: Callable[[str, str, str | None], None] | None = None,
|
|
662
1067
|
sleep: Callable[[float], None] | None = None,
|
|
1068
|
+
cancel_event: threading.Event | None = None,
|
|
1069
|
+
device_authorization_timeout: float | None = None,
|
|
1070
|
+
http_timeout: float | None = None,
|
|
1071
|
+
monotonic: Callable[[], float] | None = None,
|
|
663
1072
|
):
|
|
664
1073
|
"""Drive the OAuth 2.0 Device Authorization Grant (RFC 8628).
|
|
665
1074
|
|
|
@@ -669,17 +1078,36 @@ class ONCat:
|
|
|
669
1078
|
`OAuth2Session` is wired with `auto_refresh_url` so subsequent
|
|
670
1079
|
API calls roll the refresh token transparently.
|
|
671
1080
|
|
|
672
|
-
`
|
|
673
|
-
|
|
1081
|
+
`cancel_event` lets a caller cooperatively abort the poll loop;
|
|
1082
|
+
`device_authorization_timeout` is the overall caller deadline (distinct
|
|
1083
|
+
from the server's `expires_in`); `http_timeout` bounds each
|
|
1084
|
+
individual device-authorization POST. See `login()` for the public
|
|
1085
|
+
contract.
|
|
1086
|
+
|
|
1087
|
+
`prompt`, `sleep`, and `monotonic` exist to make the polling loop
|
|
1088
|
+
deterministic in unit tests; production callers pass none of them.
|
|
674
1089
|
"""
|
|
675
1090
|
if self._load_token() is not None:
|
|
1091
|
+
# No need to validate or refresh the token here: once the
|
|
1092
|
+
# device-authorization token carries an absolute `expires_at`
|
|
1093
|
+
# (`_stamp_token_expires_at`), the wired `OAuth2Session` owns
|
|
1094
|
+
# expiry -- it refreshes proactively before the first call and
|
|
1095
|
+
# reactively on a 401. Do not add a redundant validate/refresh.
|
|
676
1096
|
self._setup_device_oauth_session(self._load_token())
|
|
677
1097
|
return
|
|
678
1098
|
|
|
679
1099
|
if prompt is None:
|
|
680
1100
|
prompt = _default_device_flow_prompt
|
|
681
1101
|
if sleep is None:
|
|
682
|
-
sleep =
|
|
1102
|
+
sleep = self._device_authorization_sleep(cancel_event)
|
|
1103
|
+
if monotonic is None:
|
|
1104
|
+
monotonic = time.monotonic
|
|
1105
|
+
|
|
1106
|
+
post_timeout = (
|
|
1107
|
+
http_timeout
|
|
1108
|
+
if http_timeout is not None
|
|
1109
|
+
else _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT
|
|
1110
|
+
)
|
|
683
1111
|
|
|
684
1112
|
assert self._client_id
|
|
685
1113
|
|
|
@@ -692,16 +1120,37 @@ class ONCat:
|
|
|
692
1120
|
if scope_str:
|
|
693
1121
|
device_auth_body["scope"] = scope_str
|
|
694
1122
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1123
|
+
# The caller's `device_authorization_timeout` bounds the WHOLE sign-in
|
|
1124
|
+
# (ADR-0097), so the deadline clock starts BEFORE the initial
|
|
1125
|
+
# device-authorization POST. A slow initial POST therefore counts
|
|
1126
|
+
# against the overall budget. `http_timeout` still caps each
|
|
1127
|
+
# individual POST; the caller deadline is the cumulative bound.
|
|
1128
|
+
started_at = monotonic()
|
|
1129
|
+
caller_deadline = (
|
|
1130
|
+
started_at + device_authorization_timeout
|
|
1131
|
+
if device_authorization_timeout is not None
|
|
1132
|
+
else None
|
|
704
1133
|
)
|
|
1134
|
+
|
|
1135
|
+
self._raise_if_cancelled(cancel_event)
|
|
1136
|
+
self._raise_if_past_deadline(caller_deadline, monotonic)
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
device_auth_response = requests.post(
|
|
1140
|
+
device_auth_url,
|
|
1141
|
+
data=device_auth_body,
|
|
1142
|
+
verify=self._should_verify(),
|
|
1143
|
+
timeout=post_timeout,
|
|
1144
|
+
headers={
|
|
1145
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1146
|
+
"Accept": "application/json",
|
|
1147
|
+
},
|
|
1148
|
+
)
|
|
1149
|
+
except requests.exceptions.RequestException as exc:
|
|
1150
|
+
msg = f"Device authorization request failed: {exc}"
|
|
1151
|
+
raise DeviceAuthorizationNetworkError(msg) from exc
|
|
1152
|
+
# A slow initial POST may have already exhausted the caller budget.
|
|
1153
|
+
self._raise_if_past_deadline(caller_deadline, monotonic)
|
|
705
1154
|
if not device_auth_response.ok:
|
|
706
1155
|
msg = (
|
|
707
1156
|
"Device authorization request failed with status "
|
|
@@ -710,15 +1159,56 @@ class ONCat:
|
|
|
710
1159
|
)
|
|
711
1160
|
raise DeviceAuthorizationError(msg)
|
|
712
1161
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1162
|
+
try:
|
|
1163
|
+
device_auth = device_auth_response.json()
|
|
1164
|
+
device_code = device_auth["device_code"]
|
|
1165
|
+
user_code = device_auth["user_code"]
|
|
1166
|
+
verification_uri = device_auth["verification_uri"]
|
|
1167
|
+
# RFC 8628 §3.3.1: `verification_uri_complete` is OPTIONAL; it
|
|
1168
|
+
# may be absent, in which case the prompt falls back to the
|
|
1169
|
+
# bare URI.
|
|
1170
|
+
verification_uri_complete = device_auth.get(
|
|
1171
|
+
"verification_uri_complete"
|
|
1172
|
+
)
|
|
1173
|
+
# RFC 8628 §3.2: `expires_in` is the device-code lifetime;
|
|
1174
|
+
# OPTIONAL in practice, so tolerate its absence.
|
|
1175
|
+
expires_in_raw = device_auth.get("expires_in")
|
|
1176
|
+
expires_in = (
|
|
1177
|
+
int(expires_in_raw) if expires_in_raw is not None else None
|
|
1178
|
+
)
|
|
1179
|
+
interval = float(
|
|
1180
|
+
device_auth.get("interval", _DEFAULT_DEVICE_FLOW_INTERVAL)
|
|
1181
|
+
)
|
|
1182
|
+
except (ValueError, KeyError, TypeError) as err:
|
|
1183
|
+
msg = (
|
|
1184
|
+
"Device authorization response was not a valid JSON object "
|
|
1185
|
+
"with the required fields."
|
|
1186
|
+
)
|
|
1187
|
+
raise DeviceAuthorizationError(msg) from err
|
|
1188
|
+
|
|
1189
|
+
# The effective deadline is the earlier of the caller's overall
|
|
1190
|
+
# `device_authorization_timeout` (clocked from before the
|
|
1191
|
+
# initial POST) and the server's own `expires_in` (clocked from
|
|
1192
|
+
# now, once known).
|
|
1193
|
+
server_expiry = (
|
|
1194
|
+
monotonic() + expires_in if expires_in is not None else None
|
|
1195
|
+
)
|
|
1196
|
+
deadlines = [
|
|
1197
|
+
d for d in (caller_deadline, server_expiry) if d is not None
|
|
1198
|
+
]
|
|
1199
|
+
deadline = min(deadlines) if deadlines else None
|
|
1200
|
+
|
|
1201
|
+
self._dispatch_device_authorization_challenge(
|
|
1202
|
+
prompt,
|
|
1203
|
+
DeviceAuthorizationChallenge(
|
|
1204
|
+
verification_uri=verification_uri,
|
|
1205
|
+
user_code=user_code,
|
|
1206
|
+
verification_uri_complete=verification_uri_complete,
|
|
1207
|
+
expires_in=expires_in,
|
|
1208
|
+
interval=interval,
|
|
1209
|
+
expires_at=server_expiry,
|
|
1210
|
+
),
|
|
719
1211
|
)
|
|
720
|
-
|
|
721
|
-
prompt(verification_uri, user_code)
|
|
722
1212
|
|
|
723
1213
|
token_body = {
|
|
724
1214
|
"grant_type": _DEVICE_CODE_GRANT_TYPE,
|
|
@@ -727,21 +1217,39 @@ class ONCat:
|
|
|
727
1217
|
}
|
|
728
1218
|
|
|
729
1219
|
while True:
|
|
730
|
-
|
|
1220
|
+
self._raise_if_cancelled(cancel_event)
|
|
1221
|
+
self._raise_if_past_deadline(deadline, monotonic)
|
|
1222
|
+
sleep(self._poll_wait_seconds(interval, deadline, monotonic))
|
|
1223
|
+
self._raise_if_cancelled(cancel_event)
|
|
1224
|
+
self._raise_if_past_deadline(deadline, monotonic)
|
|
731
1225
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1226
|
+
try:
|
|
1227
|
+
token_response = requests.post(
|
|
1228
|
+
token_url,
|
|
1229
|
+
data=token_body,
|
|
1230
|
+
verify=self._should_verify(),
|
|
1231
|
+
timeout=post_timeout,
|
|
1232
|
+
headers={
|
|
1233
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1234
|
+
"Accept": "application/json",
|
|
1235
|
+
},
|
|
1236
|
+
)
|
|
1237
|
+
except requests.exceptions.Timeout:
|
|
1238
|
+
# A connection/read timeout on a single poll is recoverable:
|
|
1239
|
+
# back off and keep polling within the overall deadline
|
|
1240
|
+
# rather than aborting the whole sign-in. The next loop
|
|
1241
|
+
# iteration re-checks the deadline and cancel event.
|
|
1242
|
+
continue
|
|
1243
|
+
except requests.exceptions.RequestException as exc:
|
|
1244
|
+
msg = f"Device token poll failed: {exc}"
|
|
1245
|
+
raise DeviceAuthorizationNetworkError(msg) from exc
|
|
742
1246
|
|
|
743
1247
|
if token_response.ok:
|
|
744
|
-
|
|
1248
|
+
try:
|
|
1249
|
+
token = token_response.json()
|
|
1250
|
+
except (ValueError, KeyError, TypeError) as err:
|
|
1251
|
+
msg = "Device token response was not a valid JSON object."
|
|
1252
|
+
raise DeviceAuthorizationError(msg) from err
|
|
745
1253
|
break
|
|
746
1254
|
|
|
747
1255
|
try:
|
|
@@ -755,18 +1263,125 @@ class ONCat:
|
|
|
755
1263
|
if error_code == "slow_down":
|
|
756
1264
|
interval += _DEVICE_FLOW_SLOW_DOWN_BUMP
|
|
757
1265
|
continue
|
|
758
|
-
if error_code
|
|
759
|
-
msg =
|
|
760
|
-
raise
|
|
1266
|
+
if error_code == "expired_token":
|
|
1267
|
+
msg = "Device authorization terminated: expired_token"
|
|
1268
|
+
raise DeviceAuthorizationExpired(msg)
|
|
1269
|
+
if error_code == "access_denied":
|
|
1270
|
+
msg = "Device authorization terminated: access_denied"
|
|
1271
|
+
raise DeviceAuthorizationDenied(msg)
|
|
761
1272
|
msg = (
|
|
762
1273
|
"Device token poll failed with status "
|
|
763
1274
|
f"{token_response.status_code}: {token_response.text}"
|
|
764
1275
|
)
|
|
765
1276
|
raise DeviceAuthorizationError(msg)
|
|
766
1277
|
|
|
1278
|
+
self._stamp_token_expires_at(token)
|
|
767
1279
|
self._save_token(token)
|
|
768
1280
|
self._setup_device_oauth_session(token)
|
|
769
1281
|
|
|
1282
|
+
@staticmethod
|
|
1283
|
+
def _stamp_token_expires_at(token: dict) -> None:
|
|
1284
|
+
"""Stamp an absolute `expires_at` on a device-authorization
|
|
1285
|
+
token in place.
|
|
1286
|
+
|
|
1287
|
+
PingFed's device-token response carries only the relative
|
|
1288
|
+
`expires_in`. oauthlib's proactive-refresh gate keys on the
|
|
1289
|
+
absolute `expires_at`, recomputing it as `now + expires_in` on
|
|
1290
|
+
every reload -- so a persisted token without `expires_at` looks
|
|
1291
|
+
perpetually fresh and silent refresh never fires. PyONCat's other
|
|
1292
|
+
flows go through `OAuth2Session.fetch_token()`, which stamps
|
|
1293
|
+
`expires_at` via oauthlib's parser; the manual device poll does
|
|
1294
|
+
not, so it is stamped here to the same wall-clock shape
|
|
1295
|
+
(`round(time.time()) + expires_in`, matching oauthlib) anchored at
|
|
1296
|
+
the moment the token was received.
|
|
1297
|
+
|
|
1298
|
+
RFC 6749 §5.1 makes `expires_in` only RECOMMENDED. When it is
|
|
1299
|
+
absent or non-numeric, no `expires_at` is synthesized -- the
|
|
1300
|
+
bounded reactive 401 net (`_call_method`) is the backstop for
|
|
1301
|
+
that case.
|
|
1302
|
+
"""
|
|
1303
|
+
if "expires_at" in token:
|
|
1304
|
+
return
|
|
1305
|
+
try:
|
|
1306
|
+
expires_in = int(token["expires_in"])
|
|
1307
|
+
except (KeyError, ValueError, TypeError):
|
|
1308
|
+
return
|
|
1309
|
+
token["expires_at"] = round(time.time()) + expires_in
|
|
1310
|
+
|
|
1311
|
+
@staticmethod
|
|
1312
|
+
def _device_authorization_sleep(
|
|
1313
|
+
cancel_event: threading.Event | None,
|
|
1314
|
+
) -> Callable[[float], None]:
|
|
1315
|
+
"""Resolve the inter-poll wait primitive for the Device
|
|
1316
|
+
Authorization Grant.
|
|
1317
|
+
|
|
1318
|
+
When a `cancel_event` is supplied the wait blocks on the event,
|
|
1319
|
+
so setting it returns immediately rather than after the full
|
|
1320
|
+
interval; `threading.Event.wait(timeout)` returns as soon as the
|
|
1321
|
+
event is set. Without a `cancel_event` the wait is a plain
|
|
1322
|
+
`time.sleep`.
|
|
1323
|
+
"""
|
|
1324
|
+
if cancel_event is None:
|
|
1325
|
+
return time.sleep
|
|
1326
|
+
|
|
1327
|
+
def wait(seconds: float) -> None:
|
|
1328
|
+
cancel_event.wait(seconds)
|
|
1329
|
+
|
|
1330
|
+
return wait
|
|
1331
|
+
|
|
1332
|
+
@staticmethod
|
|
1333
|
+
def _poll_wait_seconds(
|
|
1334
|
+
interval: float,
|
|
1335
|
+
deadline: float | None,
|
|
1336
|
+
monotonic: Callable[[], float],
|
|
1337
|
+
) -> float:
|
|
1338
|
+
"""Seconds to wait before the next poll, bounded by the deadline.
|
|
1339
|
+
|
|
1340
|
+
The wait is capped at the time remaining until `deadline` (the
|
|
1341
|
+
earlier of the caller and server expiries) so a pending sign-in
|
|
1342
|
+
observes the deadline without waiting a full `interval` past it.
|
|
1343
|
+
"""
|
|
1344
|
+
if deadline is None:
|
|
1345
|
+
return interval
|
|
1346
|
+
return max(0.0, min(interval, deadline - monotonic()))
|
|
1347
|
+
|
|
1348
|
+
@staticmethod
|
|
1349
|
+
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
|
1350
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1351
|
+
msg = "Device authorization was cancelled by the caller."
|
|
1352
|
+
raise DeviceAuthorizationCancelled(msg)
|
|
1353
|
+
|
|
1354
|
+
@staticmethod
|
|
1355
|
+
def _raise_if_past_deadline(
|
|
1356
|
+
deadline: float | None, monotonic: Callable[[], float]
|
|
1357
|
+
) -> None:
|
|
1358
|
+
if deadline is not None and monotonic() >= deadline:
|
|
1359
|
+
msg = (
|
|
1360
|
+
"Device authorization timed out before the user completed "
|
|
1361
|
+
"the sign-in."
|
|
1362
|
+
)
|
|
1363
|
+
raise DeviceAuthorizationTimeout(msg)
|
|
1364
|
+
|
|
1365
|
+
def _dispatch_device_authorization_challenge(
|
|
1366
|
+
self,
|
|
1367
|
+
prompt: Callable[[str, str, str | None], None],
|
|
1368
|
+
challenge: DeviceAuthorizationChallenge,
|
|
1369
|
+
) -> None:
|
|
1370
|
+
"""Notify the caller of a pending challenge.
|
|
1371
|
+
|
|
1372
|
+
A configured `verification_handler` receives the structured
|
|
1373
|
+
`DeviceAuthorizationChallenge`; otherwise the resolved 3-arg `prompt`
|
|
1374
|
+
callback is used (the console default, or a unit-test hook).
|
|
1375
|
+
"""
|
|
1376
|
+
if self._verification_handler is not None:
|
|
1377
|
+
self._verification_handler(challenge)
|
|
1378
|
+
return
|
|
1379
|
+
prompt(
|
|
1380
|
+
challenge.verification_uri,
|
|
1381
|
+
challenge.user_code,
|
|
1382
|
+
challenge.verification_uri_complete,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
770
1385
|
def _setup_device_oauth_session(self, token):
|
|
771
1386
|
token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
|
|
772
1387
|
with warnings.catch_warnings():
|
|
@@ -1343,8 +1958,21 @@ def prompt_user_for_login() -> tuple[str, str]:
|
|
|
1343
1958
|
return username, password
|
|
1344
1959
|
|
|
1345
1960
|
|
|
1346
|
-
def _default_device_flow_prompt(
|
|
1347
|
-
|
|
1961
|
+
def _default_device_flow_prompt(
|
|
1962
|
+
verification_uri: str,
|
|
1963
|
+
user_code: str,
|
|
1964
|
+
verification_uri_complete: str | None = None,
|
|
1965
|
+
) -> None:
|
|
1966
|
+
"""Print the RFC 8628 §3.3 user-confirmation prompt to stdout.
|
|
1967
|
+
|
|
1968
|
+
When the IdP supplies `verification_uri_complete` (RFC 8628 §3.3.1,
|
|
1969
|
+
the pre-filled one-click URL), render it directly; otherwise fall
|
|
1970
|
+
back to the bare verification URL plus the user code.
|
|
1971
|
+
"""
|
|
1972
|
+
if verification_uri_complete:
|
|
1973
|
+
print("Open this URL in a browser to authenticate:")
|
|
1974
|
+
print(f" {verification_uri_complete}")
|
|
1975
|
+
return
|
|
1348
1976
|
print("Open this URL in a browser to authenticate:")
|
|
1349
1977
|
print(f" {verification_uri}")
|
|
1350
1978
|
print("Enter this code:")
|
|
@@ -1456,4 +2084,57 @@ class DeviceAuthorizationError(PyONCatError):
|
|
|
1456
2084
|
finish the verification step before the device code expired, the user
|
|
1457
2085
|
explicitly denied the request, or the IdP returned an unrecognized
|
|
1458
2086
|
error response).
|
|
2087
|
+
|
|
2088
|
+
The specific failure modes are surfaced as subclasses
|
|
2089
|
+
(`DeviceAuthorizationDenied`, `DeviceAuthorizationExpired`,
|
|
2090
|
+
`DeviceAuthorizationCancelled`, `DeviceAuthorizationTimeout`,
|
|
2091
|
+
`DeviceAuthorizationNetworkError`); catching this base type still
|
|
2092
|
+
catches all of them.
|
|
2093
|
+
"""
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
# The four outcome subclasses below intentionally omit the `Error` suffix
|
|
2097
|
+
# (triggering N818): the names are the public 2.6 device-authorization
|
|
2098
|
+
# API contract
|
|
2099
|
+
# (ADR-0097), spelled to read as outcomes the caller catches by reason --
|
|
2100
|
+
# Denied / Expired / Cancelled / Timeout. They subclass
|
|
2101
|
+
# `DeviceAuthorizationError`, so the suffixed base type still catches them.
|
|
2102
|
+
class DeviceAuthorizationDenied(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
|
|
2103
|
+
"""Raised when the user explicitly denied the sign-in request
|
|
2104
|
+
(the IdP returned `access_denied`, RFC 8628 §3.5)."""
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
class DeviceAuthorizationExpired(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
|
|
2108
|
+
"""Raised when the device code expired before the user approved the
|
|
2109
|
+
sign-in (the IdP returned `expired_token`, RFC 8628 §3.5)."""
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
class DeviceAuthorizationCancelled(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
|
|
2113
|
+
"""Raised when the caller cancelled the sign-in in progress via the
|
|
2114
|
+
`cancel_event` passed to `login()`."""
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
class DeviceAuthorizationTimeout(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
|
|
2118
|
+
"""Raised when the sign-in did not complete before the overall
|
|
2119
|
+
caller deadline (`device_authorization_timeout`) or the server's own
|
|
2120
|
+
`expires_in`, whichever came first."""
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
class DeviceAuthorizationNetworkError(DeviceAuthorizationError):
|
|
2124
|
+
"""Raised when an underlying HTTP request in the Device Authorization
|
|
2125
|
+
Grant failed at the transport layer (the original
|
|
2126
|
+
`requests.RequestException` is preserved as `__cause__`)."""
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
class InteractionRequiredError(PyONCatError):
|
|
2130
|
+
"""Raised when a Device Authorization Grant session has expired and a
|
|
2131
|
+
silent refresh is not possible, so the user must interactively sign
|
|
2132
|
+
in again.
|
|
2133
|
+
|
|
2134
|
+
Raised only when the client is constructed with
|
|
2135
|
+
`reauth_on_expired=REAUTH_INTERACTION_REQUIRED`: instead of
|
|
2136
|
+
transparently re-running `login()` (and firing the verification
|
|
2137
|
+
callback) from inside an ordinary API call, PyONCat raises this so
|
|
2138
|
+
the application can decide when and how to prompt the user to sign
|
|
2139
|
+
in again.
|
|
1459
2140
|
"""
|
|
@@ -7,7 +7,7 @@ build-backend = "poetry.masonry.api"
|
|
|
7
7
|
# https://github.com/python-poetry/roadmap/issues/3
|
|
8
8
|
[tool.poetry]
|
|
9
9
|
name = "pyoncat"
|
|
10
|
-
version = "2.
|
|
10
|
+
version = "2.6"
|
|
11
11
|
description = "A Python Client for ONCat (the ORNL Neutron Catalog)."
|
|
12
12
|
authors = ["ONCat Support <oncat-support@ornl.gov>"]
|
|
13
13
|
readme = ["pyoncat/README.md", "pyoncat/LICENSE"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|