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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.5
3
+ Version: 2.6
4
4
  Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
5
5
  Keywords: ornl,neutron,metadata,catalog
6
6
  Author: ONCat Support
@@ -18,7 +18,8 @@ import sys
18
18
  import time
19
19
  import warnings
20
20
  import weakref
21
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
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
- __version__ = "2.5"
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 `reauth_on_expired=True` (the "
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=False` for unattended / non-interactive use, "
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: bool
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
- `True` (the default) prompts on stderr for the user to
225
- press Enter to re-authenticate via the browser, then
226
- retries the original request once. `False` raises
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: bool = True,
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 = 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 device authorization flow "
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 _handle_device_flow_token_expiry(self, send_request, error):
445
- """Recover from an expired Device Flow refresh token.
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
- Clears the persisted token and, when `reauth_on_expired` is
448
- `True`, prompts on stderr for the user to re-authenticate via
449
- the browser before retrying the original request once. When
450
- `reauth_on_expired` is `False`, raises immediately so callers
451
- running unattended can react without blocking on `input()`.
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 not self._reauth_on_expired:
456
- raise InvalidRefreshTokenError(
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
- print(_DEVICE_FLOW_REAUTH_PROMPT, file=sys.stderr)
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 eof_error
470
- # KeyboardInterrupt propagates naturally; no special handling.
716
+ ) from error
471
717
 
472
- # Drop any cached OAuth session so `login()` re-runs the device
473
- # flow rather than reusing an exhausted refresh token.
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(self, username=None, password=None):
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
- `prompt` and `sleep` exist to make the polling loop deterministic
673
- in unit tests; production callers pass neither.
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 = time.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
- device_auth_response = requests.post(
696
- device_auth_url,
697
- data=device_auth_body,
698
- verify=self._should_verify(),
699
- timeout=self._timeout,
700
- headers={
701
- "Content-Type": "application/x-www-form-urlencoded",
702
- "Accept": "application/json",
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
- device_auth = device_auth_response.json()
714
- device_code = device_auth["device_code"]
715
- user_code = device_auth["user_code"]
716
- verification_uri = device_auth["verification_uri"]
717
- interval = float(
718
- device_auth.get("interval", _DEFAULT_DEVICE_FLOW_INTERVAL)
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
- sleep(interval)
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
- token_response = requests.post(
733
- token_url,
734
- data=token_body,
735
- verify=self._should_verify(),
736
- timeout=self._timeout,
737
- headers={
738
- "Content-Type": "application/x-www-form-urlencoded",
739
- "Accept": "application/json",
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
- token = token_response.json()
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 in {"expired_token", "access_denied"}:
759
- msg = f"Device authorization terminated: {error_code}"
760
- raise DeviceAuthorizationError(msg)
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(verification_uri: str, user_code: str) -> None:
1347
- """Print the RFC 8628 §3.3 user-confirmation prompt to stdout."""
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.5"
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