pyoncat 2.5__tar.gz → 2.7__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.7}/PKG-INFO +7 -4
- {pyoncat-2.5 → pyoncat-2.7}/pyoncat/README.md +4 -0
- {pyoncat-2.5 → pyoncat-2.7}/pyoncat/__init__.py +786 -129
- {pyoncat-2.5 → pyoncat-2.7}/pyoncat/reduction.py +1 -1
- {pyoncat-2.5 → pyoncat-2.7}/pyproject.toml +2 -2
- {pyoncat-2.5 → pyoncat-2.7}/pyoncat/LICENSE +0 -0
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyoncat
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7
|
|
4
4
|
Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
|
|
5
5
|
Keywords: ornl,neutron,metadata,catalog
|
|
6
6
|
Author: ONCat Support
|
|
7
7
|
Author-email: oncat-support@ornl.gov
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.9,<4
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
12
10
|
Classifier: Programming Language :: Python :: 3.9
|
|
13
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
17
|
Requires-Dist: requests
|
|
19
18
|
Project-URL: Homepage, https://oncat.ornl.gov
|
|
20
19
|
Description-Content-Type: text/markdown
|
|
@@ -59,6 +58,10 @@ Alternatively, a Conda package is available up on [Anaconda](https://anaconda.or
|
|
|
59
58
|
|
|
60
59
|
To use the authenticated version of the API, you need to obtain credentials from an ONCat administrator. Please contact ONCat Support at [oncat-support@ornl.gov](mailto:oncat-support@ornl.gov) to request credentials.
|
|
61
60
|
|
|
61
|
+
### Request headers
|
|
62
|
+
|
|
63
|
+
Every request PyONCat makes carries two headers: `User-Agent: pyoncat/<version> requests/<version> Python/<major.minor>`, naming PyONCat, the `requests` library, and the Python major.minor version, and `API-Client: v1 pyoncat/<version>`. They let server logs tell PyONCat traffic from other clients.
|
|
64
|
+
|
|
62
65
|
### Examples
|
|
63
66
|
|
|
64
67
|
For usage examples, please refer to the API documentation at [oncat.ornl.gov](https://oncat.ornl.gov).
|
|
@@ -38,6 +38,10 @@ Alternatively, a Conda package is available up on [Anaconda](https://anaconda.or
|
|
|
38
38
|
|
|
39
39
|
To use the authenticated version of the API, you need to obtain credentials from an ONCat administrator. Please contact ONCat Support at [oncat-support@ornl.gov](mailto:oncat-support@ornl.gov) to request credentials.
|
|
40
40
|
|
|
41
|
+
### Request headers
|
|
42
|
+
|
|
43
|
+
Every request PyONCat makes carries two headers: `User-Agent: pyoncat/<version> requests/<version> Python/<major.minor>`, naming PyONCat, the `requests` library, and the Python major.minor version, and `API-Client: v1 pyoncat/<version>`. They let server logs tell PyONCat traffic from other clients.
|
|
44
|
+
|
|
41
45
|
### Examples
|
|
42
46
|
|
|
43
47
|
For usage examples, please refer to the API documentation at [oncat.ornl.gov](https://oncat.ornl.gov).
|
|
@@ -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,21 @@ 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.7"
|
|
38
|
+
|
|
39
|
+
# Sent on every request PyONCat makes, API calls and token requests alike,
|
|
40
|
+
# so server logs can tell PyONCat traffic from other clients.
|
|
41
|
+
_USER_AGENT = (
|
|
42
|
+
f"pyoncat/{__version__} requests/{requests.__version__} "
|
|
43
|
+
f"Python/{sys.version_info.major}.{sys.version_info.minor}"
|
|
44
|
+
)
|
|
45
|
+
_CLIENT_HEADERS = {
|
|
46
|
+
"User-Agent": _USER_AGENT,
|
|
47
|
+
"API-Client": f"v1 pyoncat/{__version__}",
|
|
48
|
+
}
|
|
34
49
|
|
|
35
50
|
# Constant for autoreduction user
|
|
36
51
|
AUTOREDUCTION_USER = "auto"
|
|
@@ -39,8 +54,19 @@ __all__ = [
|
|
|
39
54
|
"AUTOREDUCTION_USER",
|
|
40
55
|
"CLIENT_CREDENTIALS_FLOW",
|
|
41
56
|
"DEVICE_AUTHORIZATION_FLOW",
|
|
57
|
+
"REAUTH_INTERACTION_REQUIRED",
|
|
58
|
+
"REAUTH_NEVER",
|
|
59
|
+
"REAUTH_PROMPT",
|
|
42
60
|
"RESOURCE_OWNER_CREDENTIALS_FLOW",
|
|
61
|
+
"DeviceAuthorizationCancelled",
|
|
62
|
+
"DeviceAuthorizationChallenge",
|
|
63
|
+
"DeviceAuthorizationDenied",
|
|
64
|
+
"DeviceAuthorizationError",
|
|
65
|
+
"DeviceAuthorizationExpired",
|
|
66
|
+
"DeviceAuthorizationNetworkError",
|
|
67
|
+
"DeviceAuthorizationTimeout",
|
|
43
68
|
"FileSystemTokenStore",
|
|
69
|
+
"InteractionRequiredError",
|
|
44
70
|
"ONCat",
|
|
45
71
|
"ONCatObject",
|
|
46
72
|
"PyONCatError",
|
|
@@ -66,6 +92,50 @@ DEVICE_AUTHORIZATION_FLOW: FlowType = "device_authorization"
|
|
|
66
92
|
|
|
67
93
|
assert isinstance(CLIENT_CREDENTIALS_FLOW, str)
|
|
68
94
|
|
|
95
|
+
# Named `reauth_on_expired` modes selecting how the Device Authorization
|
|
96
|
+
# Grant recovers when the refresh token has expired. These string
|
|
97
|
+
# constants are the canonical, documented way to select a mode; the
|
|
98
|
+
# legacy `True`/`False` booleans remain accepted and are normalized to
|
|
99
|
+
# `REAUTH_NEVER` at the constructor boundary.
|
|
100
|
+
#
|
|
101
|
+
# - `REAUTH_PROMPT`: re-run the Device Authorization Grant (re-prompt)
|
|
102
|
+
# on expiry, then retry the original request once. Equivalent to
|
|
103
|
+
# the legacy `True`.
|
|
104
|
+
# - `REAUTH_NEVER`: raise on expiry without re-authenticating, for
|
|
105
|
+
# unattended / non-interactive callers. Equivalent to the legacy `False`.
|
|
106
|
+
# - `REAUTH_INTERACTION_REQUIRED`: attempt a silent refresh and, if the
|
|
107
|
+
# refresh token itself is dead, raise `InteractionRequiredError` rather
|
|
108
|
+
# than re-running `login()` from inside an ordinary API call. Its value
|
|
109
|
+
# stays the literal `"raise"` for backward compatibility.
|
|
110
|
+
ReauthMode = Literal["prompt", "never", "raise"]
|
|
111
|
+
ReauthOnExpired = Union[bool, ReauthMode]
|
|
112
|
+
REAUTH_PROMPT: ReauthMode = "prompt"
|
|
113
|
+
REAUTH_NEVER: ReauthMode = "never"
|
|
114
|
+
REAUTH_INTERACTION_REQUIRED: ReauthMode = "raise"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_reauth_on_expired(value: ReauthOnExpired) -> ReauthMode:
|
|
118
|
+
"""Map a `reauth_on_expired` argument onto a canonical `ReauthMode`.
|
|
119
|
+
|
|
120
|
+
Accepts the legacy booleans (`True` -> `REAUTH_PROMPT`, `False` ->
|
|
121
|
+
`REAUTH_NEVER`) for backward compatibility alongside the named string
|
|
122
|
+
constants, so the dispatch logic only ever compares against the
|
|
123
|
+
`REAUTH_*` constants.
|
|
124
|
+
"""
|
|
125
|
+
if value is True:
|
|
126
|
+
return REAUTH_PROMPT
|
|
127
|
+
if value is False:
|
|
128
|
+
return REAUTH_NEVER
|
|
129
|
+
if value in {REAUTH_PROMPT, REAUTH_NEVER, REAUTH_INTERACTION_REQUIRED}:
|
|
130
|
+
return value
|
|
131
|
+
msg = (
|
|
132
|
+
"reauth_on_expired must be one of REAUTH_PROMPT, REAUTH_NEVER, "
|
|
133
|
+
"REAUTH_INTERACTION_REQUIRED (or the legacy True/False), "
|
|
134
|
+
f"not {value!r}."
|
|
135
|
+
)
|
|
136
|
+
raise ValueError(msg)
|
|
137
|
+
|
|
138
|
+
|
|
69
139
|
# RFC 8628 §3.5 default polling interval (seconds), used when the device
|
|
70
140
|
# authorization response does not specify one.
|
|
71
141
|
_DEFAULT_DEVICE_FLOW_INTERVAL = 5
|
|
@@ -83,6 +153,68 @@ _DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
|
83
153
|
# `oncat_cli_user` client.
|
|
84
154
|
_DEFAULT_IDP_URL = "https://extidp.ornl.gov"
|
|
85
155
|
|
|
156
|
+
# Finite default connect/read timeout (seconds) for the two
|
|
157
|
+
# device-authorization HTTP POSTs (the authorization request and the token
|
|
158
|
+
# poll). The general `self._timeout` defaults to `None` (wait
|
|
159
|
+
# indefinitely), which is the right semantics for ordinary API calls but a
|
|
160
|
+
# footgun for the device-authorization poll loop: a hung IdP connection
|
|
161
|
+
# would block the loop forever. This bounds each individual POST so a
|
|
162
|
+
# stalled connection backs off and the loop keeps polling within the
|
|
163
|
+
# overall deadline instead of hanging. It is distinct from both
|
|
164
|
+
# `self._timeout` (per-API-request) and `device_authorization_timeout`
|
|
165
|
+
# (the overall caller deadline). Override per call via the `http_timeout`
|
|
166
|
+
# argument to `login()`.
|
|
167
|
+
_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT = 30.0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class DeviceAuthorizationChallenge:
|
|
172
|
+
"""The user-facing details of a Device Authorization Grant challenge.
|
|
173
|
+
|
|
174
|
+
The Device Authorization Grant (often called the device flow) is the
|
|
175
|
+
OAuth 2.0 sign-in defined by RFC 8628. PyONCat passes one of these to
|
|
176
|
+
a configured `verification_handler`
|
|
177
|
+
whenever the user needs to approve a sign-in (RFC 8628 §3.2). The app
|
|
178
|
+
shows the user the link (and, if needed, the code) so they can approve
|
|
179
|
+
in a browser.
|
|
180
|
+
|
|
181
|
+
Attributes
|
|
182
|
+
----------
|
|
183
|
+
verification_uri: str
|
|
184
|
+
The URL the user opens to approve the sign-in. Show this (with
|
|
185
|
+
`user_code`) when `verification_uri_complete` is `None`.
|
|
186
|
+
|
|
187
|
+
user_code: str
|
|
188
|
+
The one-time code the user enters at `verification_uri`.
|
|
189
|
+
|
|
190
|
+
verification_uri_complete: str | None
|
|
191
|
+
A ready-to-use link that already embeds `user_code` (RFC 8628
|
|
192
|
+
§3.3.1; OPTIONAL). Prefer showing this when present.
|
|
193
|
+
|
|
194
|
+
expires_in: int | None
|
|
195
|
+
Seconds, from when the challenge was issued, after which the
|
|
196
|
+
device code expires (RFC 8628 §3.2). `None` if the IdP omitted
|
|
197
|
+
it.
|
|
198
|
+
|
|
199
|
+
interval: float
|
|
200
|
+
The minimum number of seconds to wait between token polls
|
|
201
|
+
(RFC 8628 §3.2).
|
|
202
|
+
|
|
203
|
+
expires_at: float | None
|
|
204
|
+
The monotonic-clock deadline (`time.monotonic()` basis) after
|
|
205
|
+
which the challenge expires, derived from `expires_in` at the
|
|
206
|
+
moment the challenge was created. `None` when `expires_in` is
|
|
207
|
+
absent.
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
verification_uri: str
|
|
212
|
+
user_code: str
|
|
213
|
+
verification_uri_complete: str | None
|
|
214
|
+
expires_in: int | None
|
|
215
|
+
interval: float
|
|
216
|
+
expires_at: float | None
|
|
217
|
+
|
|
86
218
|
|
|
87
219
|
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
|
|
88
220
|
"Your ONCat session has expired and could not be automatically "
|
|
@@ -98,11 +230,11 @@ _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
|
|
|
98
230
|
"\n\n"
|
|
99
231
|
"If you want PyONCat to prompt you to re-authenticate automatically "
|
|
100
232
|
"when this\n"
|
|
101
|
-
"happens, construct the client with
|
|
102
|
-
"default).\n"
|
|
233
|
+
"happens, construct the client with "
|
|
234
|
+
"`reauth_on_expired=REAUTH_PROMPT` (the default).\n"
|
|
103
235
|
"\n"
|
|
104
|
-
"Set `reauth_on_expired=
|
|
105
|
-
"in which\n"
|
|
236
|
+
"Set `reauth_on_expired=REAUTH_NEVER` for unattended / "
|
|
237
|
+
"non-interactive use, in which\n"
|
|
106
238
|
"case this exception is raised immediately on session expiry."
|
|
107
239
|
)
|
|
108
240
|
|
|
@@ -218,14 +350,46 @@ class ONCat:
|
|
|
218
350
|
hit the IdP directly. When `None`, defaults to
|
|
219
351
|
`https://extidp.ornl.gov` (ORNL's external PingFed).
|
|
220
352
|
|
|
221
|
-
reauth_on_expired:
|
|
353
|
+
reauth_on_expired: ReauthOnExpired
|
|
222
354
|
When using `DEVICE_AUTHORIZATION_FLOW` and the refresh
|
|
223
|
-
token has expired, controls how the client recovers.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
355
|
+
token has expired, controls how the client recovers. Select
|
|
356
|
+
a mode with one of the named constants (the canonical form);
|
|
357
|
+
the legacy `True`/`False` booleans are still accepted and map
|
|
358
|
+
to `REAUTH_PROMPT` / `REAUTH_NEVER` respectively.
|
|
359
|
+
`REAUTH_PROMPT` (the default) prompts on stderr for the user
|
|
360
|
+
to press Enter to re-authenticate via the browser, then
|
|
361
|
+
retries the original request once. `REAUTH_NEVER` raises
|
|
227
362
|
`InvalidRefreshTokenError` immediately, which is
|
|
228
363
|
appropriate for unattended or non-interactive use.
|
|
364
|
+
`REAUTH_INTERACTION_REQUIRED` first attempts a silent
|
|
365
|
+
refresh and, only if the refresh token itself is dead,
|
|
366
|
+
raises `InteractionRequiredError` rather than re-running
|
|
367
|
+
`login()` (and firing the verification callback) from
|
|
368
|
+
inside an ordinary API call. This lets a GUI decide *when*
|
|
369
|
+
to prompt the user instead of having a data call pop a
|
|
370
|
+
sign-in dialog from a worker thread.
|
|
371
|
+
|
|
372
|
+
verification_handler:
|
|
373
|
+
Callable[[DeviceAuthorizationChallenge], None] | None
|
|
374
|
+
A function PyONCat calls whenever the user needs to sign in -
|
|
375
|
+
on the first login and again later if the session expires.
|
|
376
|
+
It receives a single `DeviceAuthorizationChallenge` carrying the
|
|
377
|
+
verification URI, user code, complete URI, expiry, and
|
|
378
|
+
polling interval, so the app has everything it needs (e.g.
|
|
379
|
+
to show a code-expiry countdown). Your function is
|
|
380
|
+
responsible for displaying the sign-in link to the user, who
|
|
381
|
+
opens it in a browser to approve the sign-in:
|
|
382
|
+
|
|
383
|
+
- By default, show `verification_uri_complete`: a ready-to-use
|
|
384
|
+
link that already includes the code.
|
|
385
|
+
- If it is `None`, show `verification_uri` instead and have the
|
|
386
|
+
user enter `user_code` on that page.
|
|
387
|
+
|
|
388
|
+
Supplying this callback is what lets sign-in work in a GUI app
|
|
389
|
+
that has no console. When it is set, the same callback is
|
|
390
|
+
reused if the session later needs the user to sign in again, so
|
|
391
|
+
the app is never left waiting on console input (see
|
|
392
|
+
`reauth_on_expired`).
|
|
229
393
|
|
|
230
394
|
"""
|
|
231
395
|
|
|
@@ -250,7 +414,6 @@ class ONCat:
|
|
|
250
414
|
Task: Task
|
|
251
415
|
Info: Info
|
|
252
416
|
Cache: Cache
|
|
253
|
-
Usage: Usage
|
|
254
417
|
Template: Template
|
|
255
418
|
|
|
256
419
|
def __init__(
|
|
@@ -267,7 +430,9 @@ class ONCat:
|
|
|
267
430
|
verify: bool = True,
|
|
268
431
|
timeout: int | None = None,
|
|
269
432
|
idp_url: str | None = None,
|
|
270
|
-
reauth_on_expired:
|
|
433
|
+
reauth_on_expired: ReauthOnExpired = REAUTH_PROMPT,
|
|
434
|
+
verification_handler: Callable[[DeviceAuthorizationChallenge], None]
|
|
435
|
+
| None = None,
|
|
271
436
|
):
|
|
272
437
|
if not url:
|
|
273
438
|
msg = "A URL must be provided."
|
|
@@ -306,6 +471,7 @@ class ONCat:
|
|
|
306
471
|
self._token_getter = token_getter
|
|
307
472
|
self._token_setter = token_setter
|
|
308
473
|
self._login_prompt = login_prompt
|
|
474
|
+
self._verification_handler = verification_handler
|
|
309
475
|
self._api_token = api_token
|
|
310
476
|
self._client_id = client_id
|
|
311
477
|
self._client_secret = client_secret
|
|
@@ -315,7 +481,9 @@ class ONCat:
|
|
|
315
481
|
self._verify = verify
|
|
316
482
|
self._timeout = timeout
|
|
317
483
|
self._idp_url = idp_url if idp_url is not None else _DEFAULT_IDP_URL
|
|
318
|
-
self._reauth_on_expired =
|
|
484
|
+
self._reauth_on_expired = _normalize_reauth_on_expired(
|
|
485
|
+
reauth_on_expired
|
|
486
|
+
)
|
|
319
487
|
|
|
320
488
|
self._token = None
|
|
321
489
|
self._oauth_client = None
|
|
@@ -325,7 +493,7 @@ class ONCat:
|
|
|
325
493
|
"OAuth 2.0 Resource Owner Password Credentials grant is "
|
|
326
494
|
'deprecated. RFC 9700 §2.4: "The resource owner password '
|
|
327
495
|
'credentials grant [RFC6749] MUST NOT be used." Prefer '
|
|
328
|
-
"the
|
|
496
|
+
"the Device Authorization Grant "
|
|
329
497
|
"(flow=DEVICE_AUTHORIZATION_FLOW) for human-user "
|
|
330
498
|
"authentication. ROPC remains available indefinitely as "
|
|
331
499
|
"a compatibility path.",
|
|
@@ -367,14 +535,17 @@ class ONCat:
|
|
|
367
535
|
timeout=self._timeout,
|
|
368
536
|
)
|
|
369
537
|
else:
|
|
538
|
+
auth_headers = (
|
|
539
|
+
{"Authorization": "Bearer " + self._api_token}
|
|
540
|
+
if self._api_token
|
|
541
|
+
else {}
|
|
542
|
+
)
|
|
370
543
|
response = getattr(requests, method)(
|
|
371
544
|
full_url,
|
|
372
545
|
params=kwargs,
|
|
373
546
|
json=data,
|
|
374
547
|
verify=self._should_verify(),
|
|
375
|
-
headers={
|
|
376
|
-
if self._api_token
|
|
377
|
-
else None,
|
|
548
|
+
headers={**_CLIENT_HEADERS, **auth_headers},
|
|
378
549
|
timeout=self._timeout,
|
|
379
550
|
)
|
|
380
551
|
try:
|
|
@@ -399,6 +570,20 @@ class ONCat:
|
|
|
399
570
|
|
|
400
571
|
try:
|
|
401
572
|
return send_request()
|
|
573
|
+
except UnauthorizedError as error:
|
|
574
|
+
# Device Authorization Grant only: a 401 on the data call
|
|
575
|
+
# means the access token is dead server-side but oauthlib did
|
|
576
|
+
# not proactively refresh -- e.g. a token persisted without
|
|
577
|
+
# `expires_at` by an older PyONCat, so the proactive gate
|
|
578
|
+
# never fired. Attempt exactly ONE silent refresh + ONE retry;
|
|
579
|
+
# if that still fails, fall through to the expiry handler. The
|
|
580
|
+
# other flows keep the 2.5 behavior: re-raise the 401
|
|
581
|
+
# untouched.
|
|
582
|
+
if self._flow != DEVICE_AUTHORIZATION_FLOW:
|
|
583
|
+
raise
|
|
584
|
+
return self._handle_device_authorization_unauthorized(
|
|
585
|
+
send_request, error
|
|
586
|
+
)
|
|
402
587
|
except rfc6749.errors.InvalidGrantError as error:
|
|
403
588
|
if (
|
|
404
589
|
self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW
|
|
@@ -441,40 +626,232 @@ class ONCat:
|
|
|
441
626
|
msg = f'Error: "{error!s}"'
|
|
442
627
|
raise PyONCatError(msg) from error
|
|
443
628
|
|
|
444
|
-
def
|
|
445
|
-
"""
|
|
629
|
+
def _handle_device_authorization_unauthorized(self, send_request, error):
|
|
630
|
+
"""Bounded reactive recovery from a Device Authorization Grant
|
|
631
|
+
data-call 401.
|
|
632
|
+
|
|
633
|
+
Attempts exactly ONE silent refresh (the `refresh_token` grant via
|
|
634
|
+
the wired `OAuth2Session`) and ONE retry of the original request.
|
|
635
|
+
This terminates by construction:
|
|
636
|
+
|
|
637
|
+
- The retried `send_request()` is called directly here, NOT through
|
|
638
|
+
`_call_method`, so a second consecutive 401 raises straight back
|
|
639
|
+
to the caller without re-entering this handler -- there is no
|
|
640
|
+
loop.
|
|
641
|
+
- If the silent refresh itself fails (the refresh token is dead),
|
|
642
|
+
control falls through to `_handle_device_flow_token_expiry`,
|
|
643
|
+
which raises `InteractionRequiredError` under
|
|
644
|
+
`reauth_on_expired=REAUTH_INTERACTION_REQUIRED` (or behaves
|
|
645
|
+
per the `REAUTH_PROMPT` / `REAUTH_NEVER` modes).
|
|
646
|
+
|
|
647
|
+
This is the backstop for a token that lacks `expires_at` (so
|
|
648
|
+
oauthlib never refreshed proactively) and for a server-side
|
|
649
|
+
revocation between proactive checks.
|
|
650
|
+
"""
|
|
651
|
+
token = self._load_token()
|
|
652
|
+
refresh_token = token.get("refresh_token") if token else None
|
|
653
|
+
oauth_client = self._oauth_client
|
|
654
|
+
|
|
655
|
+
if refresh_token and oauth_client is not None:
|
|
656
|
+
token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
|
|
657
|
+
refresh_kwargs = {
|
|
658
|
+
"client_id": self._client_id,
|
|
659
|
+
"verify": self._should_verify(),
|
|
660
|
+
}
|
|
661
|
+
try:
|
|
662
|
+
with warnings.catch_warnings():
|
|
663
|
+
warnings.filterwarnings(
|
|
664
|
+
"ignore", category=DeprecationWarning
|
|
665
|
+
)
|
|
666
|
+
refreshed = oauth_client.refresh_token(
|
|
667
|
+
token_url,
|
|
668
|
+
refresh_token=refresh_token,
|
|
669
|
+
**refresh_kwargs,
|
|
670
|
+
)
|
|
671
|
+
except requests.exceptions.RequestException as exc:
|
|
672
|
+
# A transport-layer failure is not evidence that the
|
|
673
|
+
# refresh token is dead, so the stored token is left
|
|
674
|
+
# intact and the failure surfaces as a typed network
|
|
675
|
+
# error the caller can branch on without being signed
|
|
676
|
+
# out by a blip.
|
|
677
|
+
msg = f"Silent token refresh failed: {exc}"
|
|
678
|
+
raise DeviceAuthorizationNetworkError(msg) from exc
|
|
679
|
+
except rfc6749.errors.OAuth2Error:
|
|
680
|
+
# A genuine OAuth rejection (e.g. `invalid_grant`) means
|
|
681
|
+
# the refresh token itself is dead; hand off to the
|
|
682
|
+
# expiry handler for the re-auth decision.
|
|
683
|
+
return self._handle_device_flow_token_expiry(
|
|
684
|
+
send_request, error
|
|
685
|
+
)
|
|
446
686
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
687
|
+
self._save_token(refreshed)
|
|
688
|
+
# The single retry runs `send_request()` directly: another 401
|
|
689
|
+
# here propagates without re-triggering a refresh (no loop).
|
|
690
|
+
return send_request()
|
|
691
|
+
|
|
692
|
+
# No usable refresh token: defer to the expiry handler.
|
|
693
|
+
return self._handle_device_flow_token_expiry(send_request, error)
|
|
694
|
+
|
|
695
|
+
def _handle_device_flow_token_expiry(self, send_request, error):
|
|
696
|
+
"""Recover from an expired Device Authorization Grant refresh
|
|
697
|
+
token.
|
|
698
|
+
|
|
699
|
+
Reached only after `requests_oauthlib`'s silent refresh has
|
|
700
|
+
already failed (the refresh token itself is dead). Clears the
|
|
701
|
+
persisted token and recovers according to `reauth_on_expired`:
|
|
702
|
+
|
|
703
|
+
- `REAUTH_PROMPT` (the default): prompts on stderr for the user
|
|
704
|
+
to re-authenticate via the browser before retrying the original
|
|
705
|
+
request once. When a `verification_handler` is configured, the
|
|
706
|
+
stdin "press Enter" gate is skipped entirely -- the callback
|
|
707
|
+
itself is the
|
|
708
|
+
console-less app's presence/notification surface, so the flow
|
|
709
|
+
re-runs (invoking the callback) and the original request is
|
|
710
|
+
retried once.
|
|
711
|
+
- `REAUTH_NEVER`: raises `InvalidRefreshTokenError` immediately so
|
|
712
|
+
callers running unattended can react without blocking on
|
|
713
|
+
`input()`.
|
|
714
|
+
- `REAUTH_INTERACTION_REQUIRED`: raises
|
|
715
|
+
`InteractionRequiredError` so a GUI can decide when and how to
|
|
716
|
+
prompt the user, rather than having an ordinary data call fire
|
|
717
|
+
the verification callback from a worker thread.
|
|
452
718
|
"""
|
|
453
719
|
self._save_token(None)
|
|
454
720
|
|
|
455
|
-
if
|
|
456
|
-
raise
|
|
721
|
+
if self._reauth_on_expired == REAUTH_INTERACTION_REQUIRED:
|
|
722
|
+
raise InteractionRequiredError(
|
|
457
723
|
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
458
724
|
) from error
|
|
459
725
|
|
|
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.
|
|
726
|
+
if self._reauth_on_expired == REAUTH_NEVER:
|
|
467
727
|
raise InvalidRefreshTokenError(
|
|
468
728
|
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
469
|
-
) from
|
|
470
|
-
# KeyboardInterrupt propagates naturally; no special handling.
|
|
729
|
+
) from error
|
|
471
730
|
|
|
472
|
-
|
|
473
|
-
|
|
731
|
+
if self._verification_handler is None:
|
|
732
|
+
print(_DEVICE_FLOW_REAUTH_PROMPT, file=sys.stderr)
|
|
733
|
+
try:
|
|
734
|
+
input()
|
|
735
|
+
except EOFError as eof_error:
|
|
736
|
+
# Non-interactive stdin (piped, closed, no human present);
|
|
737
|
+
# do not auto-recover -- the prompt's purpose was to confirm
|
|
738
|
+
# human presence.
|
|
739
|
+
raise InvalidRefreshTokenError(
|
|
740
|
+
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
741
|
+
) from eof_error
|
|
742
|
+
# KeyboardInterrupt propagates naturally; no special handling.
|
|
743
|
+
|
|
744
|
+
# Drop any cached OAuth session so `login()` re-runs the Device
|
|
745
|
+
# Authorization Grant rather than reusing an exhausted refresh
|
|
746
|
+
# token.
|
|
474
747
|
self._oauth_client = None
|
|
475
748
|
self.login()
|
|
476
749
|
return send_request()
|
|
477
750
|
|
|
751
|
+
def logout(self, http_timeout: float | None = None):
|
|
752
|
+
"""Sign out: revoke the refresh token and clear the local token.
|
|
753
|
+
|
|
754
|
+
The public counterpart to `login()`. When a token with a
|
|
755
|
+
`refresh_token` is present, this POSTs to the IdP's RFC 7009 token
|
|
756
|
+
revocation endpoint (`<idp_url>/as/revoke_token.oauth2`, derived the
|
|
757
|
+
same way as the device-authorization and token endpoints) to revoke
|
|
758
|
+
the refresh token server-side, then always clears the locally stored
|
|
759
|
+
token so a sign-out succeeds locally even when the server call does
|
|
760
|
+
not. The cached OAuth session is torn down so a subsequent `login()`
|
|
761
|
+
starts a fresh Device Authorization Grant rather than reusing an
|
|
762
|
+
exhausted session.
|
|
763
|
+
|
|
764
|
+
Revocation uses the configured public `client_id` and no secret
|
|
765
|
+
(the device-authorization client is a public client, RFC 8628
|
|
766
|
+
/ ADR-0058).
|
|
767
|
+
|
|
768
|
+
Per RFC 7009 §2.2 the revocation endpoint returns HTTP 200 even for
|
|
769
|
+
a token that is already invalid, so a 200 confirms the request was
|
|
770
|
+
accepted -- not that a specific token was live at the time.
|
|
771
|
+
|
|
772
|
+
Arguments:
|
|
773
|
+
---------
|
|
774
|
+
http_timeout: float | None
|
|
775
|
+
The connect/read HTTP timeout (seconds) for the revocation
|
|
776
|
+
POST. `None` (the default) uses
|
|
777
|
+
`_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT`, the same
|
|
778
|
+
finite bound the Device Authorization Grant applies, so a
|
|
779
|
+
hung IdP connection backs off
|
|
780
|
+
instead of blocking the sign-out indefinitely.
|
|
781
|
+
|
|
782
|
+
Raises:
|
|
783
|
+
------
|
|
784
|
+
PyONCatError
|
|
785
|
+
If the revocation request fails (non-2xx response or a
|
|
786
|
+
transport error). The local token is cleared first, so the
|
|
787
|
+
caller is signed out locally regardless; the exception
|
|
788
|
+
surfaces that server-side revocation did not succeed.
|
|
789
|
+
|
|
790
|
+
"""
|
|
791
|
+
token = self._load_token()
|
|
792
|
+
refresh_token = token.get("refresh_token") if token else None
|
|
793
|
+
|
|
794
|
+
revocation_error: Exception | None = None
|
|
795
|
+
if refresh_token:
|
|
796
|
+
revocation_url = (
|
|
797
|
+
self._idp_url.rstrip("/") + "/as/revoke_token.oauth2"
|
|
798
|
+
)
|
|
799
|
+
post_timeout = (
|
|
800
|
+
http_timeout
|
|
801
|
+
if http_timeout is not None
|
|
802
|
+
else _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT
|
|
803
|
+
)
|
|
804
|
+
try:
|
|
805
|
+
revocation_response = requests.post(
|
|
806
|
+
revocation_url,
|
|
807
|
+
data={
|
|
808
|
+
"token": refresh_token,
|
|
809
|
+
"token_type_hint": "refresh_token",
|
|
810
|
+
"client_id": self._client_id,
|
|
811
|
+
},
|
|
812
|
+
verify=self._should_verify(),
|
|
813
|
+
timeout=post_timeout,
|
|
814
|
+
headers={
|
|
815
|
+
**_CLIENT_HEADERS,
|
|
816
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
817
|
+
"Accept": "application/json",
|
|
818
|
+
},
|
|
819
|
+
)
|
|
820
|
+
except requests.exceptions.RequestException as exc:
|
|
821
|
+
revocation_error = exc
|
|
822
|
+
else:
|
|
823
|
+
if not revocation_response.ok:
|
|
824
|
+
revocation_error = PyONCatError(
|
|
825
|
+
"Token revocation request failed with status "
|
|
826
|
+
f"{revocation_response.status_code}: "
|
|
827
|
+
f"{revocation_response.text}"
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
# Drop the cached session first so the in-memory teardown holds even
|
|
831
|
+
# if the token-store write raises: a caller-supplied store can fail,
|
|
832
|
+
# and a failed store write must not leave a live session behind. The
|
|
833
|
+
# store-write exception still propagates to the caller below.
|
|
834
|
+
self._oauth_client = None
|
|
835
|
+
self._save_token(None)
|
|
836
|
+
|
|
837
|
+
if revocation_error is not None:
|
|
838
|
+
msg = (
|
|
839
|
+
"Signed out locally, but server-side token revocation "
|
|
840
|
+
f"failed: {revocation_error}"
|
|
841
|
+
)
|
|
842
|
+
raise PyONCatError(msg) from revocation_error
|
|
843
|
+
|
|
844
|
+
def has_stored_token(self) -> bool:
|
|
845
|
+
"""Return True if a (non-null) token is already persisted.
|
|
846
|
+
|
|
847
|
+
Reads through the configured token store / token_getter; does
|
|
848
|
+
NOT contact the IdP and does NOT validate the token (it may be
|
|
849
|
+
expired). Lets a GUI decide its initial signed-in vs signed-out
|
|
850
|
+
state without a network round trip. A session cleared by
|
|
851
|
+
logout() (persisted as JSON null) reads as absent.
|
|
852
|
+
"""
|
|
853
|
+
return self._load_token() is not None
|
|
854
|
+
|
|
478
855
|
def _load_oauth_client(self):
|
|
479
856
|
if not self._oauth_client:
|
|
480
857
|
self.login()
|
|
@@ -513,7 +890,14 @@ class ONCat:
|
|
|
513
890
|
and "proxy" not in self._url
|
|
514
891
|
)
|
|
515
892
|
|
|
516
|
-
def login(
|
|
893
|
+
def login(
|
|
894
|
+
self,
|
|
895
|
+
username=None,
|
|
896
|
+
password=None,
|
|
897
|
+
cancel_event: threading.Event | None = None,
|
|
898
|
+
device_authorization_timeout: float | None = None,
|
|
899
|
+
http_timeout: float | None = None,
|
|
900
|
+
):
|
|
517
901
|
"""Login to the ONCat API as part of an appropriate OAuth flow.
|
|
518
902
|
|
|
519
903
|
This method is no longer strictly necessary to call manually,
|
|
@@ -534,6 +918,37 @@ class ONCat:
|
|
|
534
918
|
password: str | None
|
|
535
919
|
The password to use when logging in.
|
|
536
920
|
|
|
921
|
+
cancel_event: threading.Event | None
|
|
922
|
+
Device Authorization Grant only. When supplied, the
|
|
923
|
+
poll loop checks it around each sleep and before each
|
|
924
|
+
token POST; if it is set, the loop raises
|
|
925
|
+
`DeviceAuthorizationCancelled`. This is how a GUI
|
|
926
|
+
"Cancel" button truly aborts a sign-in in progress: Qt
|
|
927
|
+
(or any toolkit) cannot interrupt blocking Python, so
|
|
928
|
+
cancellation must be cooperative and live in PyONCat.
|
|
929
|
+
`None` (the default) means today's behavior -- no
|
|
930
|
+
cancellation.
|
|
931
|
+
|
|
932
|
+
device_authorization_timeout: float | None
|
|
933
|
+
Device Authorization Grant only. An overall caller
|
|
934
|
+
deadline in seconds for the sign-in, distinct from the
|
|
935
|
+
RFC 8628 server-side `expires_in`. When set, polling
|
|
936
|
+
stops at the deadline with `DeviceAuthorizationTimeout`.
|
|
937
|
+
Polling also stops no later than the server's own
|
|
938
|
+
`expires_in` (the effective deadline is the earlier of
|
|
939
|
+
the two). `None` (the default) means no overall deadline
|
|
940
|
+
-- today's behavior.
|
|
941
|
+
|
|
942
|
+
http_timeout: float | None
|
|
943
|
+
Device Authorization Grant only. The connect/read HTTP
|
|
944
|
+
timeout (seconds) applied to the two device-authorization POSTs
|
|
945
|
+
(device-authorization and token poll). `None` (the
|
|
946
|
+
default) uses `_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT`, a
|
|
947
|
+
finite value so a hung IdP connection backs off and the
|
|
948
|
+
loop keeps polling within the overall deadline instead of
|
|
949
|
+
hanging forever. This is distinct from the constructor's
|
|
950
|
+
`timeout` (which applies to ordinary API requests).
|
|
951
|
+
|
|
537
952
|
"""
|
|
538
953
|
if self._flow == CLIENT_CREDENTIALS_FLOW:
|
|
539
954
|
self._login_client_credentials()
|
|
@@ -554,7 +969,11 @@ class ONCat:
|
|
|
554
969
|
else:
|
|
555
970
|
self._login_resource_owner_credentials(username, password)
|
|
556
971
|
elif self._flow == DEVICE_AUTHORIZATION_FLOW:
|
|
557
|
-
self._login_device_authorization(
|
|
972
|
+
self._login_device_authorization(
|
|
973
|
+
cancel_event=cancel_event,
|
|
974
|
+
device_authorization_timeout=device_authorization_timeout,
|
|
975
|
+
http_timeout=http_timeout,
|
|
976
|
+
)
|
|
558
977
|
else:
|
|
559
978
|
raise AssertionError
|
|
560
979
|
|
|
@@ -570,6 +989,7 @@ class ONCat:
|
|
|
570
989
|
),
|
|
571
990
|
scope=self._scopes,
|
|
572
991
|
)
|
|
992
|
+
self._oauth_client.headers.update(_CLIENT_HEADERS)
|
|
573
993
|
try:
|
|
574
994
|
token = self._oauth_client.fetch_token(
|
|
575
995
|
self._url + "/oauth/token",
|
|
@@ -607,6 +1027,7 @@ class ONCat:
|
|
|
607
1027
|
client_secret=self._client_secret,
|
|
608
1028
|
)
|
|
609
1029
|
)
|
|
1030
|
+
initial_oauth_client.headers.update(_CLIENT_HEADERS)
|
|
610
1031
|
|
|
611
1032
|
try:
|
|
612
1033
|
token = initial_oauth_client.fetch_token(
|
|
@@ -655,11 +1076,16 @@ class ONCat:
|
|
|
655
1076
|
auto_refresh_kwargs=auto_refresh_kwargs,
|
|
656
1077
|
token_updater=self._save_token,
|
|
657
1078
|
)
|
|
1079
|
+
self._oauth_client.headers.update(_CLIENT_HEADERS)
|
|
658
1080
|
|
|
659
1081
|
def _login_device_authorization(
|
|
660
1082
|
self,
|
|
661
|
-
prompt: Callable[[str, str], None] | None = None,
|
|
1083
|
+
prompt: Callable[[str, str, str | None], None] | None = None,
|
|
662
1084
|
sleep: Callable[[float], None] | None = None,
|
|
1085
|
+
cancel_event: threading.Event | None = None,
|
|
1086
|
+
device_authorization_timeout: float | None = None,
|
|
1087
|
+
http_timeout: float | None = None,
|
|
1088
|
+
monotonic: Callable[[], float] | None = None,
|
|
663
1089
|
):
|
|
664
1090
|
"""Drive the OAuth 2.0 Device Authorization Grant (RFC 8628).
|
|
665
1091
|
|
|
@@ -669,17 +1095,36 @@ class ONCat:
|
|
|
669
1095
|
`OAuth2Session` is wired with `auto_refresh_url` so subsequent
|
|
670
1096
|
API calls roll the refresh token transparently.
|
|
671
1097
|
|
|
672
|
-
`
|
|
673
|
-
|
|
1098
|
+
`cancel_event` lets a caller cooperatively abort the poll loop;
|
|
1099
|
+
`device_authorization_timeout` is the overall caller deadline (distinct
|
|
1100
|
+
from the server's `expires_in`); `http_timeout` bounds each
|
|
1101
|
+
individual device-authorization POST. See `login()` for the public
|
|
1102
|
+
contract.
|
|
1103
|
+
|
|
1104
|
+
`prompt`, `sleep`, and `monotonic` exist to make the polling loop
|
|
1105
|
+
deterministic in unit tests; production callers pass none of them.
|
|
674
1106
|
"""
|
|
675
1107
|
if self._load_token() is not None:
|
|
1108
|
+
# No need to validate or refresh the token here: once the
|
|
1109
|
+
# device-authorization token carries an absolute `expires_at`
|
|
1110
|
+
# (`_stamp_token_expires_at`), the wired `OAuth2Session` owns
|
|
1111
|
+
# expiry -- it refreshes proactively before the first call and
|
|
1112
|
+
# reactively on a 401. Do not add a redundant validate/refresh.
|
|
676
1113
|
self._setup_device_oauth_session(self._load_token())
|
|
677
1114
|
return
|
|
678
1115
|
|
|
679
1116
|
if prompt is None:
|
|
680
1117
|
prompt = _default_device_flow_prompt
|
|
681
1118
|
if sleep is None:
|
|
682
|
-
sleep =
|
|
1119
|
+
sleep = self._device_authorization_sleep(cancel_event)
|
|
1120
|
+
if monotonic is None:
|
|
1121
|
+
monotonic = time.monotonic
|
|
1122
|
+
|
|
1123
|
+
post_timeout = (
|
|
1124
|
+
http_timeout
|
|
1125
|
+
if http_timeout is not None
|
|
1126
|
+
else _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT
|
|
1127
|
+
)
|
|
683
1128
|
|
|
684
1129
|
assert self._client_id
|
|
685
1130
|
|
|
@@ -692,16 +1137,38 @@ class ONCat:
|
|
|
692
1137
|
if scope_str:
|
|
693
1138
|
device_auth_body["scope"] = scope_str
|
|
694
1139
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1140
|
+
# The caller's `device_authorization_timeout` bounds the WHOLE sign-in
|
|
1141
|
+
# (ADR-0097), so the deadline clock starts BEFORE the initial
|
|
1142
|
+
# device-authorization POST. A slow initial POST therefore counts
|
|
1143
|
+
# against the overall budget. `http_timeout` still caps each
|
|
1144
|
+
# individual POST; the caller deadline is the cumulative bound.
|
|
1145
|
+
started_at = monotonic()
|
|
1146
|
+
caller_deadline = (
|
|
1147
|
+
started_at + device_authorization_timeout
|
|
1148
|
+
if device_authorization_timeout is not None
|
|
1149
|
+
else None
|
|
704
1150
|
)
|
|
1151
|
+
|
|
1152
|
+
self._raise_if_cancelled(cancel_event)
|
|
1153
|
+
self._raise_if_past_deadline(caller_deadline, monotonic)
|
|
1154
|
+
|
|
1155
|
+
try:
|
|
1156
|
+
device_auth_response = requests.post(
|
|
1157
|
+
device_auth_url,
|
|
1158
|
+
data=device_auth_body,
|
|
1159
|
+
verify=self._should_verify(),
|
|
1160
|
+
timeout=post_timeout,
|
|
1161
|
+
headers={
|
|
1162
|
+
**_CLIENT_HEADERS,
|
|
1163
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1164
|
+
"Accept": "application/json",
|
|
1165
|
+
},
|
|
1166
|
+
)
|
|
1167
|
+
except requests.exceptions.RequestException as exc:
|
|
1168
|
+
msg = f"Device authorization request failed: {exc}"
|
|
1169
|
+
raise DeviceAuthorizationNetworkError(msg) from exc
|
|
1170
|
+
# A slow initial POST may have already exhausted the caller budget.
|
|
1171
|
+
self._raise_if_past_deadline(caller_deadline, monotonic)
|
|
705
1172
|
if not device_auth_response.ok:
|
|
706
1173
|
msg = (
|
|
707
1174
|
"Device authorization request failed with status "
|
|
@@ -710,15 +1177,56 @@ class ONCat:
|
|
|
710
1177
|
)
|
|
711
1178
|
raise DeviceAuthorizationError(msg)
|
|
712
1179
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1180
|
+
try:
|
|
1181
|
+
device_auth = device_auth_response.json()
|
|
1182
|
+
device_code = device_auth["device_code"]
|
|
1183
|
+
user_code = device_auth["user_code"]
|
|
1184
|
+
verification_uri = device_auth["verification_uri"]
|
|
1185
|
+
# RFC 8628 §3.3.1: `verification_uri_complete` is OPTIONAL; it
|
|
1186
|
+
# may be absent, in which case the prompt falls back to the
|
|
1187
|
+
# bare URI.
|
|
1188
|
+
verification_uri_complete = device_auth.get(
|
|
1189
|
+
"verification_uri_complete"
|
|
1190
|
+
)
|
|
1191
|
+
# RFC 8628 §3.2: `expires_in` is the device-code lifetime;
|
|
1192
|
+
# OPTIONAL in practice, so tolerate its absence.
|
|
1193
|
+
expires_in_raw = device_auth.get("expires_in")
|
|
1194
|
+
expires_in = (
|
|
1195
|
+
int(expires_in_raw) if expires_in_raw is not None else None
|
|
1196
|
+
)
|
|
1197
|
+
interval = float(
|
|
1198
|
+
device_auth.get("interval", _DEFAULT_DEVICE_FLOW_INTERVAL)
|
|
1199
|
+
)
|
|
1200
|
+
except (ValueError, KeyError, TypeError) as err:
|
|
1201
|
+
msg = (
|
|
1202
|
+
"Device authorization response was not a valid JSON object "
|
|
1203
|
+
"with the required fields."
|
|
1204
|
+
)
|
|
1205
|
+
raise DeviceAuthorizationError(msg) from err
|
|
1206
|
+
|
|
1207
|
+
# The effective deadline is the earlier of the caller's overall
|
|
1208
|
+
# `device_authorization_timeout` (clocked from before the
|
|
1209
|
+
# initial POST) and the server's own `expires_in` (clocked from
|
|
1210
|
+
# now, once known).
|
|
1211
|
+
server_expiry = (
|
|
1212
|
+
monotonic() + expires_in if expires_in is not None else None
|
|
1213
|
+
)
|
|
1214
|
+
deadlines = [
|
|
1215
|
+
d for d in (caller_deadline, server_expiry) if d is not None
|
|
1216
|
+
]
|
|
1217
|
+
deadline = min(deadlines) if deadlines else None
|
|
1218
|
+
|
|
1219
|
+
self._dispatch_device_authorization_challenge(
|
|
1220
|
+
prompt,
|
|
1221
|
+
DeviceAuthorizationChallenge(
|
|
1222
|
+
verification_uri=verification_uri,
|
|
1223
|
+
user_code=user_code,
|
|
1224
|
+
verification_uri_complete=verification_uri_complete,
|
|
1225
|
+
expires_in=expires_in,
|
|
1226
|
+
interval=interval,
|
|
1227
|
+
expires_at=server_expiry,
|
|
1228
|
+
),
|
|
719
1229
|
)
|
|
720
|
-
|
|
721
|
-
prompt(verification_uri, user_code)
|
|
722
1230
|
|
|
723
1231
|
token_body = {
|
|
724
1232
|
"grant_type": _DEVICE_CODE_GRANT_TYPE,
|
|
@@ -727,21 +1235,40 @@ class ONCat:
|
|
|
727
1235
|
}
|
|
728
1236
|
|
|
729
1237
|
while True:
|
|
730
|
-
|
|
1238
|
+
self._raise_if_cancelled(cancel_event)
|
|
1239
|
+
self._raise_if_past_deadline(deadline, monotonic)
|
|
1240
|
+
sleep(self._poll_wait_seconds(interval, deadline, monotonic))
|
|
1241
|
+
self._raise_if_cancelled(cancel_event)
|
|
1242
|
+
self._raise_if_past_deadline(deadline, monotonic)
|
|
731
1243
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1244
|
+
try:
|
|
1245
|
+
token_response = requests.post(
|
|
1246
|
+
token_url,
|
|
1247
|
+
data=token_body,
|
|
1248
|
+
verify=self._should_verify(),
|
|
1249
|
+
timeout=post_timeout,
|
|
1250
|
+
headers={
|
|
1251
|
+
**_CLIENT_HEADERS,
|
|
1252
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1253
|
+
"Accept": "application/json",
|
|
1254
|
+
},
|
|
1255
|
+
)
|
|
1256
|
+
except requests.exceptions.Timeout:
|
|
1257
|
+
# A connection/read timeout on a single poll is recoverable:
|
|
1258
|
+
# back off and keep polling within the overall deadline
|
|
1259
|
+
# rather than aborting the whole sign-in. The next loop
|
|
1260
|
+
# iteration re-checks the deadline and cancel event.
|
|
1261
|
+
continue
|
|
1262
|
+
except requests.exceptions.RequestException as exc:
|
|
1263
|
+
msg = f"Device token poll failed: {exc}"
|
|
1264
|
+
raise DeviceAuthorizationNetworkError(msg) from exc
|
|
742
1265
|
|
|
743
1266
|
if token_response.ok:
|
|
744
|
-
|
|
1267
|
+
try:
|
|
1268
|
+
token = token_response.json()
|
|
1269
|
+
except (ValueError, KeyError, TypeError) as err:
|
|
1270
|
+
msg = "Device token response was not a valid JSON object."
|
|
1271
|
+
raise DeviceAuthorizationError(msg) from err
|
|
745
1272
|
break
|
|
746
1273
|
|
|
747
1274
|
try:
|
|
@@ -755,18 +1282,125 @@ class ONCat:
|
|
|
755
1282
|
if error_code == "slow_down":
|
|
756
1283
|
interval += _DEVICE_FLOW_SLOW_DOWN_BUMP
|
|
757
1284
|
continue
|
|
758
|
-
if error_code
|
|
759
|
-
msg =
|
|
760
|
-
raise
|
|
1285
|
+
if error_code == "expired_token":
|
|
1286
|
+
msg = "Device authorization terminated: expired_token"
|
|
1287
|
+
raise DeviceAuthorizationExpired(msg)
|
|
1288
|
+
if error_code == "access_denied":
|
|
1289
|
+
msg = "Device authorization terminated: access_denied"
|
|
1290
|
+
raise DeviceAuthorizationDenied(msg)
|
|
761
1291
|
msg = (
|
|
762
1292
|
"Device token poll failed with status "
|
|
763
1293
|
f"{token_response.status_code}: {token_response.text}"
|
|
764
1294
|
)
|
|
765
1295
|
raise DeviceAuthorizationError(msg)
|
|
766
1296
|
|
|
1297
|
+
self._stamp_token_expires_at(token)
|
|
767
1298
|
self._save_token(token)
|
|
768
1299
|
self._setup_device_oauth_session(token)
|
|
769
1300
|
|
|
1301
|
+
@staticmethod
|
|
1302
|
+
def _stamp_token_expires_at(token: dict) -> None:
|
|
1303
|
+
"""Stamp an absolute `expires_at` on a device-authorization
|
|
1304
|
+
token in place.
|
|
1305
|
+
|
|
1306
|
+
PingFed's device-token response carries only the relative
|
|
1307
|
+
`expires_in`. oauthlib's proactive-refresh gate keys on the
|
|
1308
|
+
absolute `expires_at`, recomputing it as `now + expires_in` on
|
|
1309
|
+
every reload -- so a persisted token without `expires_at` looks
|
|
1310
|
+
perpetually fresh and silent refresh never fires. PyONCat's other
|
|
1311
|
+
flows go through `OAuth2Session.fetch_token()`, which stamps
|
|
1312
|
+
`expires_at` via oauthlib's parser; the manual device poll does
|
|
1313
|
+
not, so it is stamped here to the same wall-clock shape
|
|
1314
|
+
(`round(time.time()) + expires_in`, matching oauthlib) anchored at
|
|
1315
|
+
the moment the token was received.
|
|
1316
|
+
|
|
1317
|
+
RFC 6749 §5.1 makes `expires_in` only RECOMMENDED. When it is
|
|
1318
|
+
absent or non-numeric, no `expires_at` is synthesized -- the
|
|
1319
|
+
bounded reactive 401 net (`_call_method`) is the backstop for
|
|
1320
|
+
that case.
|
|
1321
|
+
"""
|
|
1322
|
+
if "expires_at" in token:
|
|
1323
|
+
return
|
|
1324
|
+
try:
|
|
1325
|
+
expires_in = int(token["expires_in"])
|
|
1326
|
+
except (KeyError, ValueError, TypeError):
|
|
1327
|
+
return
|
|
1328
|
+
token["expires_at"] = round(time.time()) + expires_in
|
|
1329
|
+
|
|
1330
|
+
@staticmethod
|
|
1331
|
+
def _device_authorization_sleep(
|
|
1332
|
+
cancel_event: threading.Event | None,
|
|
1333
|
+
) -> Callable[[float], None]:
|
|
1334
|
+
"""Resolve the inter-poll wait primitive for the Device
|
|
1335
|
+
Authorization Grant.
|
|
1336
|
+
|
|
1337
|
+
When a `cancel_event` is supplied the wait blocks on the event,
|
|
1338
|
+
so setting it returns immediately rather than after the full
|
|
1339
|
+
interval; `threading.Event.wait(timeout)` returns as soon as the
|
|
1340
|
+
event is set. Without a `cancel_event` the wait is a plain
|
|
1341
|
+
`time.sleep`.
|
|
1342
|
+
"""
|
|
1343
|
+
if cancel_event is None:
|
|
1344
|
+
return time.sleep
|
|
1345
|
+
|
|
1346
|
+
def wait(seconds: float) -> None:
|
|
1347
|
+
cancel_event.wait(seconds)
|
|
1348
|
+
|
|
1349
|
+
return wait
|
|
1350
|
+
|
|
1351
|
+
@staticmethod
|
|
1352
|
+
def _poll_wait_seconds(
|
|
1353
|
+
interval: float,
|
|
1354
|
+
deadline: float | None,
|
|
1355
|
+
monotonic: Callable[[], float],
|
|
1356
|
+
) -> float:
|
|
1357
|
+
"""Seconds to wait before the next poll, bounded by the deadline.
|
|
1358
|
+
|
|
1359
|
+
The wait is capped at the time remaining until `deadline` (the
|
|
1360
|
+
earlier of the caller and server expiries) so a pending sign-in
|
|
1361
|
+
observes the deadline without waiting a full `interval` past it.
|
|
1362
|
+
"""
|
|
1363
|
+
if deadline is None:
|
|
1364
|
+
return interval
|
|
1365
|
+
return max(0.0, min(interval, deadline - monotonic()))
|
|
1366
|
+
|
|
1367
|
+
@staticmethod
|
|
1368
|
+
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
|
1369
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1370
|
+
msg = "Device authorization was cancelled by the caller."
|
|
1371
|
+
raise DeviceAuthorizationCancelled(msg)
|
|
1372
|
+
|
|
1373
|
+
@staticmethod
|
|
1374
|
+
def _raise_if_past_deadline(
|
|
1375
|
+
deadline: float | None, monotonic: Callable[[], float]
|
|
1376
|
+
) -> None:
|
|
1377
|
+
if deadline is not None and monotonic() >= deadline:
|
|
1378
|
+
msg = (
|
|
1379
|
+
"Device authorization timed out before the user completed "
|
|
1380
|
+
"the sign-in."
|
|
1381
|
+
)
|
|
1382
|
+
raise DeviceAuthorizationTimeout(msg)
|
|
1383
|
+
|
|
1384
|
+
def _dispatch_device_authorization_challenge(
|
|
1385
|
+
self,
|
|
1386
|
+
prompt: Callable[[str, str, str | None], None],
|
|
1387
|
+
challenge: DeviceAuthorizationChallenge,
|
|
1388
|
+
) -> None:
|
|
1389
|
+
"""Notify the caller of a pending challenge.
|
|
1390
|
+
|
|
1391
|
+
A configured `verification_handler` receives the structured
|
|
1392
|
+
`DeviceAuthorizationChallenge`; otherwise the resolved 3-arg `prompt`
|
|
1393
|
+
callback is used (the console default, or a unit-test hook).
|
|
1394
|
+
"""
|
|
1395
|
+
if self._verification_handler is not None:
|
|
1396
|
+
self._verification_handler(challenge)
|
|
1397
|
+
return
|
|
1398
|
+
prompt(
|
|
1399
|
+
challenge.verification_uri,
|
|
1400
|
+
challenge.user_code,
|
|
1401
|
+
challenge.verification_uri_complete,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
770
1404
|
def _setup_device_oauth_session(self, token):
|
|
771
1405
|
token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
|
|
772
1406
|
with warnings.catch_warnings():
|
|
@@ -782,6 +1416,7 @@ class ONCat:
|
|
|
782
1416
|
},
|
|
783
1417
|
token_updater=self._save_token,
|
|
784
1418
|
)
|
|
1419
|
+
self._oauth_client.headers.update(_CLIENT_HEADERS)
|
|
785
1420
|
|
|
786
1421
|
|
|
787
1422
|
class ONCatObject:
|
|
@@ -941,6 +1576,12 @@ ONCatRepresentation = ONCatObject
|
|
|
941
1576
|
########################################################################
|
|
942
1577
|
|
|
943
1578
|
|
|
1579
|
+
# TODO: The REST documentation page derives the PyONCat class and method
|
|
1580
|
+
# in each code example from the resource's namespace and path, without
|
|
1581
|
+
# consulting this module. Where a resource has no class here, or the
|
|
1582
|
+
# class lacks the method, the example does not run. Decide how the
|
|
1583
|
+
# documentation should learn what this module provides or otherwise
|
|
1584
|
+
# resolve this mismatch.
|
|
944
1585
|
class _ONCatMixinMeta(type):
|
|
945
1586
|
REGISTERED_RESOURCES: ClassVar[list] = []
|
|
946
1587
|
|
|
@@ -1104,7 +1745,7 @@ class Grouping(_ListableONCatMixin):
|
|
|
1104
1745
|
|
|
1105
1746
|
class Mapping(_ListableONCatMixin):
|
|
1106
1747
|
_RESOURCE_ENDPOINT = "mappings"
|
|
1107
|
-
_NAMESPACE = "
|
|
1748
|
+
_NAMESPACE = "option"
|
|
1108
1749
|
|
|
1109
1750
|
|
|
1110
1751
|
class Proposal(_ListableONCatMixin):
|
|
@@ -1188,11 +1829,6 @@ class Cache(_UpdatableONCatMixin):
|
|
|
1188
1829
|
_NAMESPACE = "admin"
|
|
1189
1830
|
|
|
1190
1831
|
|
|
1191
|
-
class Usage(_ListableONCatMixin):
|
|
1192
|
-
_RESOURCE_ENDPOINT = "usage"
|
|
1193
|
-
_NAMESPACE = "admin"
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
1832
|
########################################################################
|
|
1197
1833
|
|
|
1198
1834
|
|
|
@@ -1203,51 +1839,6 @@ class Template(
|
|
|
1203
1839
|
_NAMESPACE = "settings"
|
|
1204
1840
|
|
|
1205
1841
|
|
|
1206
|
-
########################################################################
|
|
1207
|
-
# Summary Resources
|
|
1208
|
-
########################################################################
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
class ExperimentSummary(_ONCatResource):
|
|
1212
|
-
_RESOURCE_ENDPOINT = "experiments"
|
|
1213
|
-
_NAMESPACE = "summary"
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
class GroupingSummary(_ONCatResource):
|
|
1217
|
-
_RESOURCE_ENDPOINT = "groupings"
|
|
1218
|
-
_NAMESPACE = "summary"
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
class InstrumentSummary(_ONCatResource):
|
|
1222
|
-
_RESOURCE_ENDPOINT = "instruments"
|
|
1223
|
-
_NAMESPACE = "summary"
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
class ReductionSummary(_ONCatResource):
|
|
1227
|
-
_RESOURCE_ENDPOINT = "reductions"
|
|
1228
|
-
_NAMESPACE = "summary"
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
class RunSummary(_ONCatResource):
|
|
1232
|
-
_RESOURCE_ENDPOINT = "runs"
|
|
1233
|
-
_NAMESPACE = "summary"
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
class SampleSummary(_ONCatResource):
|
|
1237
|
-
_RESOURCE_ENDPOINT = "samples"
|
|
1238
|
-
_NAMESPACE = "summary"
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
class ScanSummary(_ONCatResource):
|
|
1242
|
-
_RESOURCE_ENDPOINT = "scans"
|
|
1243
|
-
_NAMESPACE = "summary"
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
class SessionSummary(_ONCatResource):
|
|
1247
|
-
_RESOURCE_ENDPOINT = "sessions"
|
|
1248
|
-
_NAMESPACE = "summary"
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
1842
|
########################################################################
|
|
1252
1843
|
# Misc Tools
|
|
1253
1844
|
########################################################################
|
|
@@ -1343,8 +1934,21 @@ def prompt_user_for_login() -> tuple[str, str]:
|
|
|
1343
1934
|
return username, password
|
|
1344
1935
|
|
|
1345
1936
|
|
|
1346
|
-
def _default_device_flow_prompt(
|
|
1347
|
-
|
|
1937
|
+
def _default_device_flow_prompt(
|
|
1938
|
+
verification_uri: str,
|
|
1939
|
+
user_code: str,
|
|
1940
|
+
verification_uri_complete: str | None = None,
|
|
1941
|
+
) -> None:
|
|
1942
|
+
"""Print the RFC 8628 §3.3 user-confirmation prompt to stdout.
|
|
1943
|
+
|
|
1944
|
+
When the IdP supplies `verification_uri_complete` (RFC 8628 §3.3.1,
|
|
1945
|
+
the pre-filled one-click URL), render it directly; otherwise fall
|
|
1946
|
+
back to the bare verification URL plus the user code.
|
|
1947
|
+
"""
|
|
1948
|
+
if verification_uri_complete:
|
|
1949
|
+
print("Open this URL in a browser to authenticate:")
|
|
1950
|
+
print(f" {verification_uri_complete}")
|
|
1951
|
+
return
|
|
1348
1952
|
print("Open this URL in a browser to authenticate:")
|
|
1349
1953
|
print(f" {verification_uri}")
|
|
1350
1954
|
print("Enter this code:")
|
|
@@ -1456,4 +2060,57 @@ class DeviceAuthorizationError(PyONCatError):
|
|
|
1456
2060
|
finish the verification step before the device code expired, the user
|
|
1457
2061
|
explicitly denied the request, or the IdP returned an unrecognized
|
|
1458
2062
|
error response).
|
|
2063
|
+
|
|
2064
|
+
The specific failure modes are surfaced as subclasses
|
|
2065
|
+
(`DeviceAuthorizationDenied`, `DeviceAuthorizationExpired`,
|
|
2066
|
+
`DeviceAuthorizationCancelled`, `DeviceAuthorizationTimeout`,
|
|
2067
|
+
`DeviceAuthorizationNetworkError`); catching this base type still
|
|
2068
|
+
catches all of them.
|
|
2069
|
+
"""
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
# The four outcome subclasses below intentionally omit the `Error` suffix
|
|
2073
|
+
# (triggering N818): the names are the public 2.6 device-authorization
|
|
2074
|
+
# API contract
|
|
2075
|
+
# (ADR-0097), spelled to read as outcomes the caller catches by reason --
|
|
2076
|
+
# Denied / Expired / Cancelled / Timeout. They subclass
|
|
2077
|
+
# `DeviceAuthorizationError`, so the suffixed base type still catches them.
|
|
2078
|
+
class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
|
|
2079
|
+
"""Raised when the user explicitly denied the sign-in request
|
|
2080
|
+
(the IdP returned `access_denied`, RFC 8628 §3.5)."""
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
|
|
2084
|
+
"""Raised when the device code expired before the user approved the
|
|
2085
|
+
sign-in (the IdP returned `expired_token`, RFC 8628 §3.5)."""
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
|
|
2089
|
+
"""Raised when the caller cancelled the sign-in in progress via the
|
|
2090
|
+
`cancel_event` passed to `login()`."""
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
|
|
2094
|
+
"""Raised when the sign-in did not complete before the overall
|
|
2095
|
+
caller deadline (`device_authorization_timeout`) or the server's own
|
|
2096
|
+
`expires_in`, whichever came first."""
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
class DeviceAuthorizationNetworkError(DeviceAuthorizationError):
|
|
2100
|
+
"""Raised when an underlying HTTP request in the Device Authorization
|
|
2101
|
+
Grant failed at the transport layer (the original
|
|
2102
|
+
`requests.RequestException` is preserved as `__cause__`)."""
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
class InteractionRequiredError(PyONCatError):
|
|
2106
|
+
"""Raised when a Device Authorization Grant session has expired and a
|
|
2107
|
+
silent refresh is not possible, so the user must interactively sign
|
|
2108
|
+
in again.
|
|
2109
|
+
|
|
2110
|
+
Raised only when the client is constructed with
|
|
2111
|
+
`reauth_on_expired=REAUTH_INTERACTION_REQUIRED`: instead of
|
|
2112
|
+
transparently re-running `login()` (and firing the verification
|
|
2113
|
+
callback) from inside an ordinary API call, PyONCat raises this so
|
|
2114
|
+
the application can decide when and how to prompt the user to sign
|
|
2115
|
+
in again.
|
|
1459
2116
|
"""
|
|
@@ -566,7 +566,7 @@ class ReductionFileBuilder:
|
|
|
566
566
|
"""
|
|
567
567
|
try:
|
|
568
568
|
return self.write(path, overwrite)
|
|
569
|
-
except Exception as e: #
|
|
569
|
+
except Exception as e: # ruff: ignore[blind-except]
|
|
570
570
|
# Intentionally catch all exceptions - design requirement for
|
|
571
571
|
# autoreduction scripts where cataloging failures should never
|
|
572
572
|
# break the reduction workflow
|
|
@@ -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.7"
|
|
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"]
|
|
@@ -21,7 +21,7 @@ packages = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
[tool.poetry.dependencies]
|
|
24
|
-
python = ">=3.
|
|
24
|
+
python = ">=3.9, <4"
|
|
25
25
|
requests = "*"
|
|
26
26
|
oauthlib = { version = "*", optional = true }
|
|
27
27
|
requests-oauthlib = { version = "*", optional = true }
|
|
File without changes
|