ipaapi 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipaapi/auth.py ADDED
@@ -0,0 +1,575 @@
1
+ """Browser-based OAuth 2.0 login against QIAGEN's authorization server.
2
+
3
+ The flow is authorization code with PKCE. A short-lived HTTP server is bound to
4
+ the loopback interface to catch the redirect, the user authorizes in their
5
+ browser, and the resulting code is exchanged for an access token.
6
+
7
+ This is the same flow the original demo used, with the sharp edges removed:
8
+
9
+ * the callback is awaited on a :class:`threading.Event` rather than a spin loop
10
+ that pegged a CPU core;
11
+ * the ``state`` parameter is verified, closing the CSRF hole left by discarding
12
+ it;
13
+ * the login times out instead of hanging forever if the user never authorizes;
14
+ * the callback server is always shut down, so a second login in the same process
15
+ does not fail on an already-bound port;
16
+ * an ``error`` response from the authorization server is surfaced as an
17
+ exception rather than an infinite wait;
18
+ * tokens can be cached to disk so repeat runs skip the browser entirely.
19
+
20
+ .. note::
21
+ The redirect URI must exactly match what is registered for the OAuth client.
22
+ For the public client ID shipped as :data:`DEFAULT_CLIENT_ID` that is
23
+ ``http://localhost:8000``, so the callback port defaults to 8000 and changing
24
+ it will generally cause the authorization server to reject the request.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import errno
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import secrets
35
+ import stat
36
+ import threading
37
+ import time
38
+ import webbrowser
39
+ from dataclasses import asdict, dataclass, field
40
+ from http.server import BaseHTTPRequestHandler, HTTPServer
41
+ from typing import Any, Dict, Optional
42
+ from urllib.parse import parse_qs, urlparse
43
+
44
+ from .errors import AuthenticationError
45
+
46
+ __all__ = [
47
+ "Credentials",
48
+ "TokenCache",
49
+ "login",
50
+ "refresh",
51
+ "DEFAULT_CLIENT_ID",
52
+ "AUTHORIZATION_BASE_URL",
53
+ "TOKEN_URL",
54
+ "DEFAULT_HOST",
55
+ ]
56
+
57
+ #: Public client ID usable by any IPA user; not a secret.
58
+ DEFAULT_CLIENT_ID = "1571511054-1646124475-167497993-BQfZBb"
59
+ AUTHORIZATION_BASE_URL = "https://apps.ingenuity.com/qiaoauth/oauth/authorize"
60
+ TOKEN_URL = "https://apps.ingenuity.com/qiaoauth/oauth/token"
61
+ DEFAULT_HOST = "analysis.ingenuity.com"
62
+ DEFAULT_REDIRECT_URI = "http://localhost:8000"
63
+ DEFAULT_APPLICATION_NAME = "PythonAPI"
64
+
65
+ _SUCCESS_PAGE = b"""<!doctype html><html><head><title>IPA login</title></head>
66
+ <body style="font-family:system-ui;margin:3rem">
67
+ <h2>Authorization received</h2>
68
+ <p>You can close this window and return to Python.</p>
69
+ </body></html>"""
70
+
71
+ _FAILURE_PAGE = b"""<!doctype html><html><head><title>IPA login</title></head>
72
+ <body style="font-family:system-ui;margin:3rem">
73
+ <h2>Authorization failed</h2>
74
+ <p>The authorization server reported an error. Check the Python console.</p>
75
+ </body></html>"""
76
+
77
+
78
+ @dataclass
79
+ class Credentials:
80
+ """An access token and the context needed to use it.
81
+
82
+ Attributes:
83
+ access_token: Bearer token for the IPA API.
84
+ host: API host the token is valid against.
85
+ application_name: ``applicationname`` sent with every request; IPA uses
86
+ it to scope datasets and analyses.
87
+ expires_at: Unix timestamp of expiry, when the server reported one.
88
+ refresh_token: Refresh token, when the server issued one.
89
+ cookie_file: Cookie file reported by the token response, if any.
90
+ """
91
+
92
+ access_token: str
93
+ host: str = DEFAULT_HOST
94
+ application_name: str = DEFAULT_APPLICATION_NAME
95
+ expires_at: Optional[float] = None
96
+ refresh_token: Optional[str] = None
97
+ cookie_file: Optional[str] = None
98
+
99
+ def __post_init__(self) -> None:
100
+ if not self.access_token:
101
+ raise AuthenticationError("Credentials require a non-empty access token.")
102
+
103
+ @property
104
+ def is_expired(self) -> bool:
105
+ """Whether the token is known to have expired (60s safety margin)."""
106
+ if self.expires_at is None:
107
+ return False
108
+ return time.time() >= (self.expires_at - 60)
109
+
110
+ @property
111
+ def auth_header(self) -> Dict[str, str]:
112
+ return {"Authorization": f"Bearer {self.access_token}"}
113
+
114
+ def to_dict(self) -> Dict[str, Any]:
115
+ return asdict(self)
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: Dict[str, Any]) -> "Credentials":
119
+ known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
120
+ return cls(**{k: v for k, v in data.items() if k in known})
121
+
122
+ @classmethod
123
+ def from_token(
124
+ cls,
125
+ access_token: str,
126
+ host: str = DEFAULT_HOST,
127
+ application_name: str = DEFAULT_APPLICATION_NAME,
128
+ ) -> "Credentials":
129
+ """Wrap a token obtained elsewhere, e.g. from an environment variable."""
130
+ return cls(
131
+ access_token=access_token,
132
+ host=host,
133
+ application_name=application_name,
134
+ )
135
+
136
+ def __repr__(self) -> str: # never print the token
137
+ tail = self.access_token[-4:] if len(self.access_token) > 4 else "?"
138
+ return (
139
+ f"Credentials(host={self.host!r}, application_name="
140
+ f"{self.application_name!r}, access_token=***{tail})"
141
+ )
142
+
143
+
144
+ #: Overrides the token cache location. Useful where the home directory is not
145
+ #: writable -- a shared or exported filesystem, for instance -- since a cache
146
+ #: that cannot be written means re-authenticating on every single run.
147
+ TOKEN_FILE_ENV = "IPAAPI_TOKEN_FILE"
148
+
149
+
150
+ def _default_cache_path() -> str:
151
+ override = os.environ.get(TOKEN_FILE_ENV)
152
+ if override:
153
+ return os.path.expanduser(override)
154
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(
155
+ os.path.expanduser("~"), ".cache"
156
+ )
157
+ return os.path.join(base, "ipaapi", "token.json")
158
+
159
+
160
+ @dataclass
161
+ class TokenCache:
162
+ """Stores tokens on disk between runs, keyed by client and application.
163
+
164
+ The file is written with owner-only permissions. Cached tokens are ignored
165
+ once expired, and a corrupt cache is treated as a miss rather than an error.
166
+ """
167
+
168
+ path: str = field(default_factory=_default_cache_path)
169
+
170
+ def _load_all(self) -> Dict[str, Any]:
171
+ try:
172
+ with open(self.path, "r", encoding="utf-8") as fh:
173
+ data = json.load(fh)
174
+ return data if isinstance(data, dict) else {}
175
+ except (OSError, ValueError):
176
+ return {}
177
+
178
+ @staticmethod
179
+ def _key(client_id: str, application_name: str, host: str) -> str:
180
+ return f"{client_id}|{application_name}|{host}"
181
+
182
+ def get(
183
+ self,
184
+ client_id: str,
185
+ application_name: str,
186
+ host: str,
187
+ allow_expired: bool = False,
188
+ ) -> Optional[Credentials]:
189
+ """Return cached credentials, or ``None`` on a miss.
190
+
191
+ Expired entries are withheld unless *allow_expired* is set -- which
192
+ :func:`login` does, because an expired entry still carries the refresh
193
+ token needed to get a new one without a browser.
194
+ """
195
+ entry = self._load_all().get(self._key(client_id, application_name, host))
196
+ if not isinstance(entry, dict):
197
+ return None
198
+ try:
199
+ creds = Credentials.from_dict(entry)
200
+ except (TypeError, AuthenticationError):
201
+ return None
202
+ if creds.is_expired and not allow_expired:
203
+ return None
204
+ return creds
205
+
206
+ def put(self, client_id: str, credentials: Credentials) -> None:
207
+ """Persist *credentials*.
208
+
209
+ A cache failure is reported but not fatal -- losing the cache costs an
210
+ extra login, not the run. It is not swallowed silently, because a cache
211
+ that never writes looks exactly like a token that expires instantly.
212
+ """
213
+ data = self._load_all()
214
+ data[self._key(client_id, credentials.application_name, credentials.host)] = (
215
+ credentials.to_dict()
216
+ )
217
+ try:
218
+ directory = os.path.dirname(self.path)
219
+ if directory:
220
+ os.makedirs(directory, exist_ok=True)
221
+ tmp = f"{self.path}.tmp"
222
+ with open(tmp, "w", encoding="utf-8") as fh:
223
+ json.dump(data, fh)
224
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
225
+ os.replace(tmp, self.path)
226
+ except OSError as exc:
227
+ print(
228
+ f"Warning: could not write the token cache at {self.path!r} ({exc}).\n"
229
+ " Every run will therefore need a fresh login, which is painful "
230
+ "on a machine without a browser.\n"
231
+ " Point it somewhere writable instead, either per-command with "
232
+ f"--token-file PATH, or once with:\n"
233
+ f" export {TOKEN_FILE_ENV}=$HOME/ipaapi-token.json"
234
+ )
235
+
236
+ def clear(self) -> None:
237
+ """Remove the cache file if present."""
238
+ try:
239
+ os.remove(self.path)
240
+ except OSError:
241
+ pass
242
+
243
+
244
+ class _CallbackHandler(BaseHTTPRequestHandler):
245
+ """Single-shot handler that records the authorization response."""
246
+
247
+ server_version = "ipaapi"
248
+
249
+ def do_GET(self) -> None: # noqa: N802 - name fixed by BaseHTTPRequestHandler
250
+ params = parse_qs(urlparse(self.path).query)
251
+ result = self.server.result # type: ignore[attr-defined]
252
+
253
+ if "code" in params or "error" in params:
254
+ ok = "code" in params
255
+ self.send_response(200)
256
+ self.send_header("Content-Type", "text/html; charset=utf-8")
257
+ self.end_headers()
258
+ self.wfile.write(_SUCCESS_PAGE if ok else _FAILURE_PAGE)
259
+ result.update(
260
+ code=params.get("code", [None])[0],
261
+ state=params.get("state", [None])[0],
262
+ error=params.get("error", [None])[0],
263
+ error_description=params.get("error_description", [None])[0],
264
+ )
265
+ self.server.done.set() # type: ignore[attr-defined]
266
+ else:
267
+ self.send_response(404)
268
+ self.end_headers()
269
+ self.wfile.write(b"Not Found")
270
+
271
+ def log_message(self, fmt: str, *args) -> None: # silence stderr access log
272
+ return
273
+
274
+
275
+ class _CallbackServer(HTTPServer):
276
+ allow_reuse_address = True
277
+
278
+ def __init__(self, address):
279
+ super().__init__(address, _CallbackHandler)
280
+ self.result: Dict[str, Optional[str]] = {}
281
+ self.done = threading.Event()
282
+
283
+
284
+ def _open_browser(url: str, browser: Optional[str] = None) -> bool:
285
+ """Try to display *url* in a browser. Returns whether that appears to have worked.
286
+
287
+ On a remote machine reached with ``ssh -X``/``-Y``, a browser installed on
288
+ that machine renders on the local display, and the OAuth redirect to
289
+ ``localhost:8000`` resolves on the remote side where the callback server is
290
+ listening. That combination works without any port forwarding.
291
+ """
292
+ try:
293
+ if browser:
294
+ return webbrowser.get(browser).open(url)
295
+ return webbrowser.open(url)
296
+ except webbrowser.Error:
297
+ return False
298
+ except Exception:
299
+ return False
300
+
301
+
302
+ def _no_browser_help(url: str) -> str:
303
+ """Explain why no browser opened, and what to do about it."""
304
+ display = os.environ.get("DISPLAY")
305
+ lines = ["Could not open a browser automatically."]
306
+
307
+ if not display:
308
+ lines.append(
309
+ "DISPLAY is not set, so there is no graphical session to open one in. "
310
+ "On a remote machine, reconnect with X forwarding (`ssh -X you@host`, "
311
+ "or `ssh -Y` on macOS with XQuartz running) and try again -- a browser "
312
+ "installed on that machine will then display locally, and the OAuth "
313
+ "redirect resolves correctly without any port forwarding."
314
+ )
315
+ else:
316
+ lines.append(
317
+ f"DISPLAY is set to {display!r}, but no usable browser was found. "
318
+ "Install one (firefox, chromium) or name it with --browser."
319
+ )
320
+
321
+ lines.append(
322
+ "Alternatively, forward the callback port and use your own browser:\n"
323
+ " ssh -L 8000:localhost:8000 you@host"
324
+ )
325
+ lines.append("Or open this URL yourself:\n" + url)
326
+ return "\n\n".join(lines)
327
+
328
+
329
+ def _credentials_from_token(
330
+ token: dict,
331
+ host: str,
332
+ application_name: str,
333
+ previous: Optional[Credentials] = None,
334
+ ) -> Credentials:
335
+ """Build :class:`Credentials` from an OAuth token response."""
336
+ access_token = token.get("access_token")
337
+ if not access_token:
338
+ raise AuthenticationError("Token response contained no access_token.")
339
+
340
+ expires_at = token.get("expires_at")
341
+ if expires_at is None and token.get("expires_in") is not None:
342
+ try:
343
+ expires_at = time.time() + float(token["expires_in"])
344
+ except (TypeError, ValueError):
345
+ expires_at = None
346
+
347
+ # A refresh response often omits the refresh token, meaning "keep using the
348
+ # one you have". Dropping it would force a browser login next time.
349
+ refresh_token = token.get("refresh_token") or (
350
+ previous.refresh_token if previous else None
351
+ )
352
+
353
+ return Credentials(
354
+ access_token=access_token,
355
+ host=host,
356
+ application_name=application_name,
357
+ expires_at=float(expires_at) if expires_at is not None else None,
358
+ refresh_token=refresh_token,
359
+ cookie_file=token.get("cookieFile") or (previous.cookie_file if previous else None),
360
+ )
361
+
362
+
363
+ def refresh(
364
+ credentials: Credentials,
365
+ client_id: str = DEFAULT_CLIENT_ID,
366
+ token_url: str = TOKEN_URL,
367
+ cache: Optional[TokenCache] = None,
368
+ ) -> Credentials:
369
+ """Exchange a refresh token for a fresh access token. No browser involved.
370
+
371
+ This is what makes unattended and headless use practical: a token copied
372
+ from a machine that can run a browser keeps renewing itself on a server that
373
+ cannot, for as long as the refresh token stays valid.
374
+
375
+ Args:
376
+ credentials: Existing credentials carrying a refresh token. May be
377
+ expired -- that is the normal case here.
378
+ client_id: OAuth client the refresh token belongs to.
379
+ token_url: Token endpoint.
380
+ cache: If given, the renewed credentials are written back to it.
381
+
382
+ Raises:
383
+ AuthenticationError: If there is no refresh token, or the server
384
+ refuses to honour it (typically because it has itself expired or
385
+ been revoked, in which case a browser login is required).
386
+ """
387
+ if not credentials.refresh_token:
388
+ raise AuthenticationError(
389
+ "The cached token has expired and carries no refresh token, so it "
390
+ "cannot be renewed without logging in again."
391
+ )
392
+
393
+ try:
394
+ from requests_oauthlib import OAuth2Session
395
+ except ImportError as exc: # pragma: no cover - dependency is declared
396
+ raise AuthenticationError(
397
+ "requests-oauthlib is required to refresh a token."
398
+ ) from exc
399
+
400
+ oauth = OAuth2Session(client_id)
401
+ try:
402
+ token = oauth.refresh_token(
403
+ token_url,
404
+ refresh_token=credentials.refresh_token,
405
+ client_id=client_id,
406
+ )
407
+ except Exception as exc:
408
+ raise AuthenticationError(f"Refreshing the access token failed: {exc}") from exc
409
+
410
+ renewed = _credentials_from_token(
411
+ token,
412
+ host=credentials.host,
413
+ application_name=credentials.application_name,
414
+ previous=credentials,
415
+ )
416
+ if cache is not None:
417
+ cache.put(client_id, renewed)
418
+ return renewed
419
+
420
+
421
+ def _start_callback_server(host: str, port: int) -> _CallbackServer:
422
+ try:
423
+ server = _CallbackServer((host, port))
424
+ except OSError as exc:
425
+ if exc.errno in (errno.EADDRINUSE, errno.EACCES):
426
+ raise AuthenticationError(
427
+ f"Cannot listen on {host}:{port} for the OAuth redirect ({exc.strerror}). "
428
+ "Another process is probably using that port -- stop it, or pass a "
429
+ "different redirect_uri. Note the redirect URI must be registered with "
430
+ "the OAuth client, so for the default public client it has to be "
431
+ f"{DEFAULT_REDIRECT_URI}."
432
+ ) from exc
433
+ raise AuthenticationError(f"Could not start the OAuth callback server: {exc}") from exc
434
+ threading.Thread(target=server.serve_forever, daemon=True).start()
435
+ return server
436
+
437
+
438
+ def login(
439
+ client_id: str = DEFAULT_CLIENT_ID,
440
+ application_name: str = DEFAULT_APPLICATION_NAME,
441
+ host: str = DEFAULT_HOST,
442
+ authorization_base_url: str = AUTHORIZATION_BASE_URL,
443
+ token_url: str = TOKEN_URL,
444
+ redirect_uri: str = DEFAULT_REDIRECT_URI,
445
+ scope: Optional[str] = None,
446
+ timeout: float = 300.0,
447
+ open_browser: bool = True,
448
+ browser: Optional[str] = None,
449
+ cache: Optional[TokenCache] = None,
450
+ force: bool = False,
451
+ **fetch_token_kwargs,
452
+ ) -> Credentials:
453
+ """Run the browser OAuth flow and return :class:`Credentials`.
454
+
455
+ Args:
456
+ client_id: OAuth client. The default is a public client any IPA user may
457
+ use; it is not a secret.
458
+ application_name: ``applicationname`` IPA should associate the session
459
+ with. Datasets and analyses are scoped to it.
460
+ host: IPA API host the resulting token will be used against.
461
+ authorization_base_url: Authorization endpoint.
462
+ token_url: Token exchange endpoint.
463
+ redirect_uri: Loopback URI the authorization server redirects to. Must
464
+ match the client registration.
465
+ scope: Optional scope string.
466
+ timeout: Seconds to wait for the user to finish authorizing.
467
+ open_browser: Open the URL automatically. When ``False``, the URL is
468
+ printed for the user to open themselves -- useful over SSH.
469
+ cache: Optional :class:`TokenCache`. When given, a valid cached token is
470
+ reused and new tokens are written back.
471
+ force: Ignore any cached token and re-authorize.
472
+ **fetch_token_kwargs: Extra keyword arguments passed to
473
+ ``OAuth2Session.fetch_token``, e.g. ``include_client_id=True`` if the
474
+ authorization server requires the client ID in the token request.
475
+
476
+ Raises:
477
+ AuthenticationError: On timeout, state mismatch, an error response from
478
+ the authorization server, or a failed token exchange.
479
+ """
480
+ if cache is not None and not force:
481
+ cached = cache.get(client_id, application_name, host)
482
+ if cached is not None:
483
+ return cached
484
+
485
+ # Expired, but a refresh token renews it without touching a browser.
486
+ stale = cache.get(client_id, application_name, host, allow_expired=True)
487
+ if stale is not None and stale.refresh_token:
488
+ try:
489
+ return refresh(stale, client_id=client_id, token_url=token_url, cache=cache)
490
+ except AuthenticationError as exc:
491
+ print(f"Warning: {exc}\nFalling back to browser login.")
492
+
493
+ try:
494
+ from requests_oauthlib import OAuth2Session
495
+ except ImportError as exc: # pragma: no cover - dependency is declared
496
+ raise AuthenticationError(
497
+ "requests-oauthlib is required for browser login. Install it with "
498
+ "`pip install requests-oauthlib`."
499
+ ) from exc
500
+
501
+ parsed = urlparse(redirect_uri)
502
+ bind_host = parsed.hostname or "localhost"
503
+ bind_port = parsed.port or 80
504
+ # Bind the loopback interface regardless of how the URI spells it.
505
+ listen_host = "127.0.0.1" if bind_host in ("localhost", "127.0.0.1") else bind_host
506
+
507
+ code_verifier = secrets.token_urlsafe(64)
508
+ code_challenge = (
509
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
510
+ .decode()
511
+ .rstrip("=")
512
+ )
513
+
514
+ oauth = OAuth2Session(client_id, redirect_uri=redirect_uri, scope=scope)
515
+ authorization_url, expected_state = oauth.authorization_url(
516
+ authorization_base_url,
517
+ code_challenge=code_challenge,
518
+ code_challenge_method="S256",
519
+ )
520
+
521
+ server = _start_callback_server(listen_host, bind_port)
522
+ try:
523
+ if open_browser:
524
+ if not _open_browser(authorization_url, browser):
525
+ print(_no_browser_help(authorization_url))
526
+ else:
527
+ print("Open this URL to authorize:\n" + authorization_url)
528
+
529
+ if not server.done.wait(timeout=timeout):
530
+ raise AuthenticationError(
531
+ f"Timed out after {timeout:g}s waiting for authorization. "
532
+ "Nothing arrived at the redirect URI -- was the browser window "
533
+ "closed before granting access?"
534
+ )
535
+
536
+ result = server.result
537
+ if result.get("error"):
538
+ detail = result.get("error_description") or ""
539
+ raise AuthenticationError(
540
+ f"Authorization server returned an error: {result['error']}"
541
+ + (f" ({detail})" if detail else "")
542
+ )
543
+
544
+ returned_state = result.get("state")
545
+ if returned_state != expected_state:
546
+ raise AuthenticationError(
547
+ "OAuth state mismatch between the request and the redirect. The "
548
+ "response may not correspond to this login attempt; refusing to "
549
+ "exchange the code."
550
+ )
551
+
552
+ code = result.get("code")
553
+ if not code:
554
+ raise AuthenticationError("Redirect carried no authorization code.")
555
+
556
+ try:
557
+ token = oauth.fetch_token(
558
+ token_url,
559
+ code=code,
560
+ code_verifier=code_verifier,
561
+ state=returned_state,
562
+ **fetch_token_kwargs,
563
+ )
564
+ except Exception as exc:
565
+ raise AuthenticationError(f"Token exchange failed: {exc}") from exc
566
+ finally:
567
+ server.shutdown()
568
+ server.server_close()
569
+
570
+ credentials = _credentials_from_token(
571
+ token, host=host, application_name=application_name
572
+ )
573
+ if cache is not None:
574
+ cache.put(client_id, credentials)
575
+ return credentials