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/__init__.py +92 -0
- ipaapi/_payload.py +150 -0
- ipaapi/auth.py +575 -0
- ipaapi/cli.py +971 -0
- ipaapi/client.py +658 -0
- ipaapi/dataset.py +285 -0
- ipaapi/errors.py +101 -0
- ipaapi/history.py +145 -0
- ipaapi/mapping.py +485 -0
- ipaapi/models.py +193 -0
- ipaapi/triage.py +136 -0
- ipaapi-1.0.0.dist-info/METADATA +833 -0
- ipaapi-1.0.0.dist-info/RECORD +16 -0
- ipaapi-1.0.0.dist-info/WHEEL +4 -0
- ipaapi-1.0.0.dist-info/entry_points.txt +2 -0
- ipaapi-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|