deriva 1.7.11__py3-none-any.whl → 1.7.12__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.
- deriva/core/__init__.py +1 -1
- deriva/core/catalog_cli.py +16 -18
- deriva/core/ermrest_catalog.py +1 -1
- deriva/core/ermrest_model.py +92 -1
- deriva/core/hatrac_cli.py +30 -2
- deriva/core/hatrac_store.py +88 -33
- deriva/core/utils/core_utils.py +312 -35
- deriva/core/utils/credenza_auth_utils.py +475 -129
- deriva/core/utils/globus_auth_utils.py +7 -13
- deriva/transfer/backup/deriva_backup.py +2 -2
- deriva/transfer/backup/deriva_backup_cli.py +5 -0
- deriva/transfer/upload/deriva_upload.py +18 -2
- {deriva-1.7.11.dist-info → deriva-1.7.12.dist-info}/METADATA +3 -1
- {deriva-1.7.11.dist-info → deriva-1.7.12.dist-info}/RECORD +18 -18
- {deriva-1.7.11.dist-info → deriva-1.7.12.dist-info}/WHEEL +1 -1
- {deriva-1.7.11.dist-info → deriva-1.7.12.dist-info}/entry_points.txt +0 -0
- {deriva-1.7.11.dist-info → deriva-1.7.12.dist-info}/licenses/LICENSE +0 -0
- {deriva-1.7.11.dist-info → deriva-1.7.12.dist-info}/top_level.txt +0 -0
|
@@ -2,6 +2,7 @@ import sys
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import traceback
|
|
5
|
+
from argparse import SUPPRESS
|
|
5
6
|
from pprint import pprint
|
|
6
7
|
from requests.exceptions import HTTPError, ConnectionError
|
|
7
8
|
from bdbag.fetch.auth import keychain as bdbkc
|
|
@@ -9,9 +10,19 @@ from deriva.core import DEFAULT_CREDENTIAL_FILE, read_credential, write_credenti
|
|
|
9
10
|
get_new_requests_session, urlparse, urljoin
|
|
10
11
|
from deriva.core.utils import eprint
|
|
11
12
|
|
|
13
|
+
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
# Default resource hint for service/M2M introspection calls when the caller
|
|
18
|
+
# doesn't provide any explicit --resource values. This is an umbrella resource
|
|
19
|
+
# and will only succeed if the token was minted to include it.
|
|
20
|
+
DEFAULT_SERVICE_RESOURCE = "urn:deriva:rest:service:all"
|
|
21
|
+
|
|
22
|
+
# Required as the grant_type when requesting a service token from Credenza
|
|
23
|
+
CREDENZA_SERVICE_AUTH_URN = "urn:credenza:service:auth"
|
|
24
|
+
|
|
25
|
+
|
|
15
26
|
def host_to_url(host, path="/", protocol="https"):
|
|
16
27
|
if not host:
|
|
17
28
|
return None
|
|
@@ -22,30 +33,163 @@ def host_to_url(host, path="/", protocol="https"):
|
|
|
22
33
|
url = "%s://%s%s" % (protocol, host, path if not host.endswith("/") else "")
|
|
23
34
|
return url.lower()
|
|
24
35
|
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
|
|
37
|
+
def _add_resources(url, resources):
|
|
38
|
+
"""
|
|
39
|
+
Append one or more ?resource=... params to URL.
|
|
40
|
+
Accepts str or list[str]; returns new URL string.
|
|
27
41
|
"""
|
|
42
|
+
if not resources:
|
|
43
|
+
return url
|
|
44
|
+
if isinstance(resources, str):
|
|
45
|
+
resources = [resources]
|
|
46
|
+
from urllib.parse import urlparse as _urlparse, parse_qsl, urlencode, urlunparse
|
|
47
|
+
p = _urlparse(url)
|
|
48
|
+
q = parse_qsl(p.query, keep_blank_values=True)
|
|
49
|
+
q.extend([("resource", r) for r in resources if r])
|
|
50
|
+
return urlunparse(p._replace(query=urlencode(q, doseq=True)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UsageException(ValueError):
|
|
54
|
+
"""Usage exception."""
|
|
55
|
+
|
|
28
56
|
def __init__(self, message):
|
|
29
|
-
"""Initializes the exception.
|
|
30
|
-
"""
|
|
31
57
|
super(UsageException, self).__init__(message)
|
|
32
58
|
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
|
|
60
|
+
class ServiceAuthClient:
|
|
61
|
+
"""Interface for client-side service-auth methods."""
|
|
62
|
+
|
|
63
|
+
def name(self):
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def add_cli_args(self, parser):
|
|
67
|
+
"""Register method-specific CLI flags on the service-token parser."""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def prepare(self, session, form, **kwargs):
|
|
71
|
+
"""
|
|
72
|
+
Mutate 'form' and/or 'session' headers for /authn/service/token.
|
|
73
|
+
|
|
74
|
+
kwargs carry method-specific parameters, e.g.:
|
|
75
|
+
aws_region, aws_expires
|
|
76
|
+
client_id, client_secret
|
|
77
|
+
"""
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_SERVICE_AUTH_CLIENTS = {} # name -> instance
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def register_service_auth_client(adapter: ServiceAuthClient):
|
|
85
|
+
_SERVICE_AUTH_CLIENTS[adapter.name()] = adapter
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_service_auth_client(name):
|
|
89
|
+
try:
|
|
90
|
+
return _SERVICE_AUTH_CLIENTS[name]
|
|
91
|
+
except KeyError:
|
|
92
|
+
raise UsageException(f"Unknown auth method: {name}. Available: {', '.join(sorted(_SERVICE_AUTH_CLIENTS))}")
|
|
93
|
+
|
|
94
|
+
def add_argument_once(parser, *option_strings, **kwargs):
|
|
35
95
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
96
|
+
Add an argparse option only if none of its option strings are already registered.
|
|
97
|
+
|
|
98
|
+
This avoids conflicts when multiple plugins attempt to add the same flags.
|
|
99
|
+
"""
|
|
100
|
+
existing = getattr(parser, "_option_string_actions", {})
|
|
101
|
+
if any(opt in existing for opt in option_strings):
|
|
102
|
+
return
|
|
103
|
+
parser.add_argument(*option_strings, **kwargs)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AwsPresignedClient(ServiceAuthClient):
|
|
107
|
+
def name(self):
|
|
108
|
+
return "aws_presigned"
|
|
109
|
+
|
|
110
|
+
def add_cli_args(self, parser):
|
|
111
|
+
parser.add_argument("--aws-region", default="us-west-2",
|
|
112
|
+
help="STS signing region (default: us-west-2).")
|
|
113
|
+
parser.add_argument("--aws-expires", type=int, default=60,
|
|
114
|
+
help="Presigned URL validity seconds (default: 60).")
|
|
115
|
+
|
|
116
|
+
def prepare(self, session, form, **kwargs):
|
|
117
|
+
aws_region = kwargs.get("aws_region", "us-west-2")
|
|
118
|
+
aws_expires = int(kwargs.get("aws_expires", 60))
|
|
119
|
+
try:
|
|
120
|
+
from botocore.session import get_session
|
|
121
|
+
from botocore.awsrequest import AWSRequest
|
|
122
|
+
from botocore.auth import SigV4QueryAuth
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise UsageException("AWS presign requires botocore; please install it.") from e
|
|
125
|
+
|
|
126
|
+
sess = get_session()
|
|
127
|
+
client = sess.create_client("sts", region_name=aws_region)
|
|
128
|
+
|
|
129
|
+
url = client.generate_presigned_url(
|
|
130
|
+
"get_caller_identity",
|
|
131
|
+
Params={},
|
|
132
|
+
ExpiresIn=aws_expires,
|
|
133
|
+
HttpMethod="GET",
|
|
134
|
+
)
|
|
135
|
+
form.append(("subject_token", url))
|
|
136
|
+
logger.debug("Successfully generated AWS presigned GetCallerIdentity URL: %s" % url)
|
|
137
|
+
|
|
138
|
+
class ClientSecretBasicClient(ServiceAuthClient):
|
|
139
|
+
def name(self):
|
|
140
|
+
return "client_secret_basic"
|
|
141
|
+
|
|
142
|
+
def add_cli_args(self, parser):
|
|
143
|
+
add_argument_once(parser, "--client-id", help="Client ID for client_secret_* methods.")
|
|
144
|
+
add_argument_once(parser, "--client-secret", help="Client secret for client_secret_* methods.")
|
|
145
|
+
|
|
146
|
+
def prepare(self, session, form, **kwargs):
|
|
147
|
+
client_id = kwargs.get("client_id")
|
|
148
|
+
client_secret = kwargs.get("client_secret")
|
|
149
|
+
if not client_id or client_secret is None:
|
|
150
|
+
raise UsageException("client_secret_basic requires client_id and client_secret.")
|
|
151
|
+
import base64
|
|
152
|
+
b64 = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii")
|
|
153
|
+
session.headers.update({"Authorization": f"Basic {b64}"})
|
|
154
|
+
# Optional hint; server detects Basic regardless:
|
|
155
|
+
form.append(("auth_method", "client_secret_basic"))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ClientSecretPostClient(ServiceAuthClient):
|
|
159
|
+
def name(self):
|
|
160
|
+
return "client_secret_post"
|
|
161
|
+
|
|
162
|
+
def add_cli_args(self, parser):
|
|
163
|
+
add_argument_once(parser, "--client-id", help="Client ID for client_secret_* methods.")
|
|
164
|
+
add_argument_once(parser, "--client-secret", help="Client secret for client_secret_* methods.")
|
|
165
|
+
|
|
166
|
+
def prepare(self, session, form, **kwargs):
|
|
167
|
+
client_id = kwargs.get("client_id")
|
|
168
|
+
client_secret = kwargs.get("client_secret")
|
|
169
|
+
if not client_id or client_secret is None:
|
|
170
|
+
raise UsageException("client_secret_post requires client_id and client_secret.")
|
|
171
|
+
form.extend([
|
|
172
|
+
("auth_method", "client_secret_post"),
|
|
173
|
+
("client_id", client_id),
|
|
174
|
+
("client_secret", client_secret),
|
|
175
|
+
])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Register built-ins at import time so CLI choices are populated
|
|
179
|
+
register_service_auth_client(AwsPresignedClient())
|
|
180
|
+
register_service_auth_client(ClientSecretBasicClient())
|
|
181
|
+
register_service_auth_client(ClientSecretPostClient())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class CredenzaAuthUtil:
|
|
185
|
+
"""
|
|
186
|
+
Reusable programmatic API for Credenza auth utilities (no argparse coupling).
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, credential_file = None):
|
|
190
|
+
self.credential_file = credential_file or DEFAULT_CREDENTIAL_FILE
|
|
191
|
+
self.credentials = None # loaded lazily
|
|
42
192
|
|
|
43
|
-
# init subparsers and corresponding functions
|
|
44
|
-
self.subparsers = self.parser.add_subparsers(title='sub-commands', dest='subcmd')
|
|
45
|
-
self.login_init()
|
|
46
|
-
self.logout_init()
|
|
47
|
-
self.get_session_init()
|
|
48
|
-
self.put_session_init()
|
|
49
193
|
|
|
50
194
|
@staticmethod
|
|
51
195
|
def update_bdbag_keychain(token=None, host=None, keychain_file=None, allow_redirects=False, delete=False):
|
|
@@ -62,73 +206,284 @@ class CredenzaAuthUtilCLI(BaseCLI):
|
|
|
62
206
|
}
|
|
63
207
|
bdbkc.update_keychain(entry, keychain_file=keychain_file, delete=delete)
|
|
64
208
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
209
|
+
|
|
210
|
+
def ensure_credentials(self):
|
|
211
|
+
if self.credentials is None:
|
|
212
|
+
self.credentials = read_credential(self.credential_file, create_default=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_credential(self, host):
|
|
216
|
+
self.ensure_credentials()
|
|
68
217
|
credential = self.credentials.get(host, self.credentials.get(host.lower()))
|
|
69
218
|
if not credential:
|
|
70
219
|
return None
|
|
71
|
-
return credential
|
|
220
|
+
return credential
|
|
72
221
|
|
|
73
|
-
def save_credential(self, host, credential_file=None, credential=None):
|
|
74
|
-
if not self.credentials:
|
|
75
|
-
self.credentials = read_credential(credential_file or DEFAULT_CREDENTIAL_FILE, create_default=True)
|
|
76
222
|
|
|
223
|
+
def save_credential(self, host, credential=None, auth_type="user"):
|
|
224
|
+
self.ensure_credentials()
|
|
77
225
|
if credential is not None:
|
|
78
|
-
self.credentials[host] = {"bearer-token": credential}
|
|
226
|
+
self.credentials[host] = {"bearer-token": credential, "auth_type": auth_type}
|
|
79
227
|
else:
|
|
80
228
|
self.credentials.pop(host, None)
|
|
229
|
+
write_credential(self.credential_file, self.credentials)
|
|
81
230
|
|
|
82
|
-
write_credential(credential_file or DEFAULT_CREDENTIAL_FILE, self.credentials)
|
|
83
231
|
|
|
84
|
-
def
|
|
85
|
-
credential = self.load_credential(
|
|
232
|
+
def show_token(self, host: str):
|
|
233
|
+
credential = self.load_credential(host) or {}
|
|
234
|
+
return credential.get("bearer-token")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_session(self, host: str, *, resources=None):
|
|
238
|
+
"""
|
|
239
|
+
GET /authn/session with Authorization: Bearer <token>.
|
|
240
|
+
Applies default resource (service umbrella) if resources is falsy.
|
|
241
|
+
"""
|
|
242
|
+
credential = self.load_credential(host)
|
|
86
243
|
if not credential:
|
|
87
|
-
return None
|
|
244
|
+
return None
|
|
245
|
+
token = credential.get("bearer-token")
|
|
88
246
|
|
|
89
|
-
url = host_to_url(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
response = session.get(url)
|
|
247
|
+
url = host_to_url(host, "/authn/session")
|
|
248
|
+
resources = resources or [DEFAULT_SERVICE_RESOURCE]
|
|
249
|
+
url = _add_resources(url, resources)
|
|
93
250
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
251
|
+
session = get_new_requests_session(url)
|
|
252
|
+
session.headers.update({"Authorization": f"Bearer {token}"})
|
|
253
|
+
resp = session.get(url)
|
|
254
|
+
if resp.status_code == 200:
|
|
255
|
+
return resp.json()
|
|
256
|
+
elif resp.status_code == 404:
|
|
257
|
+
return None
|
|
98
258
|
else:
|
|
99
|
-
|
|
259
|
+
resp.raise_for_status()
|
|
100
260
|
return None
|
|
101
261
|
|
|
102
|
-
|
|
103
|
-
|
|
262
|
+
|
|
263
|
+
def put_session(self, host: str, *, refresh_upstream: bool = False, resources=None):
|
|
264
|
+
"""
|
|
265
|
+
PUT /authn/session to extend the session (or refresh upstream if user session & enabled).
|
|
266
|
+
Applies default resource (service umbrella) if resources is falsy.
|
|
267
|
+
"""
|
|
268
|
+
credential = self.load_credential(host)
|
|
104
269
|
if not credential:
|
|
105
|
-
|
|
270
|
+
raise UsageException("Credential not found. Login required.")
|
|
271
|
+
token = credential.get("bearer-token")
|
|
106
272
|
|
|
107
273
|
path = "/authn/session"
|
|
108
|
-
if
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
session.headers.update({"Authorization": f"Bearer {credential}"})
|
|
113
|
-
response = session.put(url)
|
|
274
|
+
qs = "refresh_upstream=true" if refresh_upstream else ""
|
|
275
|
+
url = host_to_url(host, path + (("?" + qs) if qs else ""))
|
|
276
|
+
resources = resources or [DEFAULT_SERVICE_RESOURCE]
|
|
277
|
+
url = _add_resources(url, resources)
|
|
114
278
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
279
|
+
session = get_new_requests_session(url)
|
|
280
|
+
session.headers.update({"Authorization": f"Bearer {token}"})
|
|
281
|
+
resp = session.put(url)
|
|
282
|
+
if resp.status_code == 200:
|
|
283
|
+
return resp.json()
|
|
284
|
+
elif resp.status_code == 404:
|
|
285
|
+
return None
|
|
119
286
|
else:
|
|
120
|
-
|
|
287
|
+
resp.raise_for_status()
|
|
121
288
|
return None
|
|
122
289
|
|
|
123
|
-
def login(self, args):
|
|
124
290
|
|
|
291
|
+
def issue_service_token(self,
|
|
292
|
+
host: str,
|
|
293
|
+
resources = None,
|
|
294
|
+
*,
|
|
295
|
+
auth_method,
|
|
296
|
+
scope = None,
|
|
297
|
+
requested_ttl_seconds = None,
|
|
298
|
+
no_bdbag_keychain= False,
|
|
299
|
+
bdbag_keychain_file = None,
|
|
300
|
+
**method_kwargs):
|
|
301
|
+
"""
|
|
302
|
+
Issue a service/M2M token via /authn/service/token.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
base = host_to_url(host, "/authn/service/token")
|
|
306
|
+
session = get_new_requests_session(base)
|
|
307
|
+
|
|
308
|
+
# Common body
|
|
309
|
+
form = [("grant_type", CREDENZA_SERVICE_AUTH_URN)]
|
|
310
|
+
# Default to umbrella resource if caller omitted resources
|
|
311
|
+
resources = resources or [DEFAULT_SERVICE_RESOURCE]
|
|
312
|
+
for r in resources:
|
|
313
|
+
form.append(("resource", r))
|
|
314
|
+
if scope:
|
|
315
|
+
form.append(("scope", scope))
|
|
316
|
+
if requested_ttl_seconds is not None:
|
|
317
|
+
form.append(("requested_ttl_seconds", str(int(requested_ttl_seconds))))
|
|
318
|
+
|
|
319
|
+
# Delegate method-specific preparation
|
|
320
|
+
adapter = get_service_auth_client(auth_method)
|
|
321
|
+
adapter.prepare(session, form, **method_kwargs)
|
|
322
|
+
|
|
323
|
+
resp = session.post(base, data=form)
|
|
324
|
+
resp.raise_for_status()
|
|
325
|
+
body = resp.json()
|
|
326
|
+
|
|
327
|
+
token = body.get("access_token")
|
|
328
|
+
if token:
|
|
329
|
+
self.save_credential(host, token, auth_type="service")
|
|
330
|
+
if not no_bdbag_keychain:
|
|
331
|
+
self.update_bdbag_keychain(host=host,
|
|
332
|
+
token=token,
|
|
333
|
+
keychain_file=bdbag_keychain_file or bdbkc.DEFAULT_KEYCHAIN_FILE)
|
|
334
|
+
return body
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class CredenzaAuthUtilCLI(BaseCLI):
|
|
338
|
+
"""Command-line Interface that wraps CredenzaAuthUtil (API)."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, *args, **kwargs):
|
|
341
|
+
super(CredenzaAuthUtilCLI, self).__init__(*args, **kwargs)
|
|
342
|
+
self.remove_options(['--host', '--token', '--oauth2-token'])
|
|
343
|
+
self.parser.add_argument('--host', required=True, metavar='<host>', help="Fully qualified host name.")
|
|
344
|
+
self.parser.add_argument("--pretty", "-p", action="store_true", help="Pretty-print all result output.")
|
|
345
|
+
self.args = None
|
|
346
|
+
self.api = None
|
|
347
|
+
|
|
348
|
+
# init subparsers and corresponding functions
|
|
349
|
+
self.subparsers = self.parser.add_subparsers(title='sub-commands', dest='subcmd')
|
|
350
|
+
self.login_init()
|
|
351
|
+
self.logout_init()
|
|
352
|
+
self.get_session_init()
|
|
353
|
+
self.put_session_init()
|
|
354
|
+
self.show_token_init()
|
|
355
|
+
self.service_token_init()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def login_init(self):
|
|
359
|
+
parser = self.subparsers.add_parser('login',
|
|
360
|
+
help="Login with device flow and get tokens for resource access.")
|
|
361
|
+
parser.add_argument("--no-bdbag-keychain", action="store_true",
|
|
362
|
+
help="Do not update the bdbag keychain file with result access tokens. Default false.")
|
|
363
|
+
parser.add_argument('--bdbag-keychain-file', metavar='<file>',
|
|
364
|
+
help="Non-default path to a bdbag keychain file.")
|
|
365
|
+
parser.add_argument("--refresh", action="store_true",
|
|
366
|
+
help="Allow the session manager to automatically refresh access tokens on the user's behalf "
|
|
367
|
+
"until either the refresh token expires or the user logs out.")
|
|
368
|
+
parser.add_argument("--force", action="store_true",
|
|
369
|
+
help="Force a login flow even if the current access token is valid.")
|
|
370
|
+
parser.add_argument("--show-token", action="store_true",
|
|
371
|
+
help="Display the token from the authorization response.")
|
|
372
|
+
parser.set_defaults(func=self.login)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def logout_init(self):
|
|
376
|
+
parser = self.subparsers.add_parser("logout", help="Logout and revoke all access and refresh tokens.")
|
|
377
|
+
parser.add_argument("--no-bdbag-keychain", action="store_true",
|
|
378
|
+
help="Do not update the bdbag keychain file by removing access tokens on logout. Default false.")
|
|
379
|
+
parser.add_argument('--bdbag-keychain-file', metavar='<file>',
|
|
380
|
+
help="Non-default path to a bdbag keychain file.")
|
|
381
|
+
parser.set_defaults(func=self.logout)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_session_init(self):
|
|
385
|
+
parser = self.subparsers.add_parser("get-session",
|
|
386
|
+
help="Retrieve information about the current session (user or service).")
|
|
387
|
+
parser.add_argument(
|
|
388
|
+
"--resource",
|
|
389
|
+
action="append",
|
|
390
|
+
default=SUPPRESS, # avoid auto-mixing a default with user-specified values
|
|
391
|
+
help=f"Resource hint (repeatable). Defaults to {DEFAULT_SERVICE_RESOURCE} when omitted."
|
|
392
|
+
)
|
|
393
|
+
parser.set_defaults(func=self.get_session)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def put_session_init(self):
|
|
397
|
+
parser = self.subparsers.add_parser("put-session",
|
|
398
|
+
help="Extend the current session (user or service).")
|
|
399
|
+
parser.add_argument(
|
|
400
|
+
"--resource",
|
|
401
|
+
action="append",
|
|
402
|
+
default=SUPPRESS,
|
|
403
|
+
help=f"Resource hint (repeatable). Defaults to {DEFAULT_SERVICE_RESOURCE} when omitted."
|
|
404
|
+
)
|
|
405
|
+
parser.add_argument("--refresh-upstream", action="store_true",
|
|
406
|
+
help="Attempt to refresh access tokens, other dependent tokens, and claims from the "
|
|
407
|
+
"upstream identity provider (user sessions only).")
|
|
408
|
+
parser.set_defaults(func=self.put_session)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def show_token_init(self):
|
|
412
|
+
parser = self.subparsers.add_parser("show-token",
|
|
413
|
+
help="Print access token for a given host. Use with caution.")
|
|
414
|
+
parser.set_defaults(func=self.show_token)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def service_token_init(self):
|
|
418
|
+
parser = self.subparsers.add_parser(
|
|
419
|
+
"service-token",
|
|
420
|
+
help="Issue a service/M2M token via /authn/service/token using a pluggable auth method."
|
|
421
|
+
)
|
|
422
|
+
parser.add_argument(
|
|
423
|
+
"--resource", dest="resource", action="append", default=SUPPRESS,
|
|
424
|
+
help=f"Resource hint (repeatable). Defaults to {DEFAULT_SERVICE_RESOURCE} when omitted."
|
|
425
|
+
)
|
|
426
|
+
parser.add_argument("--scope", help="Optional scope string (space-delimited).")
|
|
427
|
+
parser.add_argument("--requested-ttl-seconds", type=int, help="Requested TTL; server may cap/deny.")
|
|
428
|
+
parser.add_argument("--no-bdbag-keychain", action="store_true", help="Do not update bdbag keychain.")
|
|
429
|
+
parser.add_argument("--show-token", action="store_true",
|
|
430
|
+
help="Display the token from the authorization response.")
|
|
431
|
+
# Method selection
|
|
432
|
+
parser.add_argument("--auth-method",
|
|
433
|
+
choices=sorted(_SERVICE_AUTH_CLIENTS) or ["aws_presigned", "client_secret_basic",
|
|
434
|
+
"client_secret_post"],
|
|
435
|
+
required=True,
|
|
436
|
+
help="Service auth method to use.")
|
|
437
|
+
|
|
438
|
+
# Each adapter contributes its own flags
|
|
439
|
+
for adapter in _SERVICE_AUTH_CLIENTS.values():
|
|
440
|
+
adapter.add_cli_args(parser)
|
|
441
|
+
|
|
442
|
+
parser.set_defaults(func=self.service_token)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _api(self):
|
|
446
|
+
if self.api is None:
|
|
447
|
+
credential_file = (
|
|
448
|
+
self.args.credential_file) if hasattr(self.args, "credential_file") else DEFAULT_CREDENTIAL_FILE
|
|
449
|
+
self.api = CredenzaAuthUtil(credential_file=credential_file)
|
|
450
|
+
return self.api
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def show_token(self, args):
|
|
454
|
+
return self._api().show_token(args.host)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def get_session(self, args, check_only=False):
|
|
458
|
+
# Default resource if omitted
|
|
459
|
+
resources = args.resource if hasattr(args, "resource") else [DEFAULT_SERVICE_RESOURCE]
|
|
460
|
+
result = self._api().get_session(args.host, resources=resources)
|
|
461
|
+
if not result and check_only:
|
|
462
|
+
return None
|
|
463
|
+
if result is None and not check_only:
|
|
464
|
+
return f"No valid session found for host '{args.host}'."
|
|
465
|
+
return result
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def put_session(self, args):
|
|
469
|
+
resources = args.resource if hasattr(args, "resource") else [DEFAULT_SERVICE_RESOURCE]
|
|
470
|
+
result = self._api().put_session(args.host, refresh_upstream=args.refresh_upstream, resources=resources)
|
|
471
|
+
if result is None:
|
|
472
|
+
return f"No valid session found for host '{args.host}'."
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# Device login/logout are interactive; kept in CLI wrapper for now
|
|
477
|
+
def login(self, args):
|
|
125
478
|
if not sys.stdin.isatty():
|
|
126
479
|
raise RuntimeError("Interactive TTY required for device login.")
|
|
127
480
|
|
|
128
481
|
if not args.force:
|
|
129
482
|
resp = self.get_session(args, check_only=True)
|
|
130
483
|
if resp:
|
|
131
|
-
|
|
484
|
+
token = self.show_token(args)
|
|
485
|
+
token_display = f"Bearer token: {token}" if args.show_token else ""
|
|
486
|
+
return f"You are already logged in to host '{args.host}'. {token_display}"
|
|
132
487
|
|
|
133
488
|
path = "/authn/device/start"
|
|
134
489
|
if args.refresh:
|
|
@@ -143,7 +498,7 @@ class CredenzaAuthUtilCLI(BaseCLI):
|
|
|
143
498
|
login_prompt = f"""
|
|
144
499
|
|
|
145
500
|
Device login initiated to {args.host}.
|
|
146
|
-
|
|
501
|
+
|
|
147
502
|
1. Please visit {verification_url} in a browser to complete authentication.
|
|
148
503
|
2. After that, return here and enter "y" or "yes" at the prompt below to proceed.
|
|
149
504
|
|
|
@@ -172,74 +527,74 @@ class CredenzaAuthUtilCLI(BaseCLI):
|
|
|
172
527
|
token_response.raise_for_status()
|
|
173
528
|
body = token_response.json()
|
|
174
529
|
token = body["access_token"]
|
|
175
|
-
|
|
530
|
+
|
|
531
|
+
self._api().save_credential(args.host, token)
|
|
176
532
|
if not args.no_bdbag_keychain:
|
|
177
|
-
self.update_bdbag_keychain(host=args.host,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
token_display = f"
|
|
533
|
+
self._api().update_bdbag_keychain(host=args.host,
|
|
534
|
+
token=token,
|
|
535
|
+
keychain_file=args.bdbag_keychain_file or bdbkc.DEFAULT_KEYCHAIN_FILE)
|
|
536
|
+
token_display = f"Bearer token: {token}" if args.show_token else ""
|
|
181
537
|
return f"You have been successfully logged in to host '{args.host}'. {token_display}"
|
|
182
538
|
|
|
539
|
+
|
|
183
540
|
def logout(self, args):
|
|
184
|
-
credential = self.load_credential(args.host
|
|
541
|
+
credential = self._api().load_credential(args.host)
|
|
185
542
|
if not credential:
|
|
186
543
|
return "Credential not found. Not logged in."
|
|
544
|
+
token = credential.get("bearer-token")
|
|
545
|
+
auth_type = credential.get("auth_type", "user")
|
|
187
546
|
|
|
188
|
-
|
|
547
|
+
url_path = "/authn/service/token" if auth_type == "service" else "/authn/device/logout"
|
|
548
|
+
url = host_to_url(args.host, url_path)
|
|
189
549
|
session = get_new_requests_session(url)
|
|
190
|
-
session.headers.update({"Authorization": f"Bearer {
|
|
191
|
-
response = session.post(url)
|
|
550
|
+
session.headers.update({"Authorization": f"Bearer {token}"})
|
|
551
|
+
response = session.delete(url) if auth_type == "service" else session.post(url)
|
|
192
552
|
response.raise_for_status()
|
|
193
553
|
|
|
194
|
-
self.save_credential(args.host,
|
|
554
|
+
self._api().save_credential(args.host, None)
|
|
195
555
|
if not args.no_bdbag_keychain:
|
|
196
|
-
self.update_bdbag_keychain(host=args.host,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return f"
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
556
|
+
self._api().update_bdbag_keychain(host=args.host,
|
|
557
|
+
token=token,
|
|
558
|
+
delete=True,
|
|
559
|
+
keychain_file=args.bdbag_keychain_file or bdbkc.DEFAULT_KEYCHAIN_FILE)
|
|
560
|
+
|
|
561
|
+
return f"Successfully logged out of host '{args.host}'."
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def service_token(self, args):
|
|
565
|
+
kw = vars(args).copy()
|
|
566
|
+
host = kw.pop("host")
|
|
567
|
+
resources = None
|
|
568
|
+
if "resource" in kw:
|
|
569
|
+
resources = kw.pop("resource")
|
|
570
|
+
scope = kw.pop("scope", None)
|
|
571
|
+
requested_ttl_seconds = kw.pop("requested_ttl_seconds", None)
|
|
572
|
+
auth_method = kw.pop("auth_method")
|
|
573
|
+
no_bdbag_keychain = kw.pop("no_bdbag_keychain", False)
|
|
574
|
+
credential_file = kw.pop("credential_file", None) # honored by API init, not per-call
|
|
575
|
+
bdbag_keychain_file = kw.pop("bdbag_keychain_file", None)
|
|
576
|
+
|
|
577
|
+
if credential_file and (self.api is None or self.api.credential_file != credential_file):
|
|
578
|
+
self.api = CredenzaAuthUtil(credential_file)
|
|
579
|
+
|
|
580
|
+
response = self._api().issue_service_token(
|
|
581
|
+
host,
|
|
582
|
+
resources,
|
|
583
|
+
auth_method=auth_method,
|
|
584
|
+
scope=scope,
|
|
585
|
+
requested_ttl_seconds=requested_ttl_seconds,
|
|
586
|
+
no_bdbag_keychain=no_bdbag_keychain,
|
|
587
|
+
bdbag_keychain_file=bdbag_keychain_file,
|
|
588
|
+
**kw # remaining CLI params become method_kwargs for adapters
|
|
589
|
+
)
|
|
590
|
+
token = response["access_token"]
|
|
591
|
+
token_display = f"Bearer token: {token}" if args.show_token else ""
|
|
227
592
|
|
|
228
|
-
|
|
229
|
-
parser = self.subparsers.add_parser("get-session",
|
|
230
|
-
help="Retrieve information about the currently logged-in user.")
|
|
231
|
-
parser.set_defaults(func=self.get_session)
|
|
593
|
+
return f"Service API token granted by host '{args.host}'. {token_display}"
|
|
232
594
|
|
|
233
|
-
def put_session_init(self):
|
|
234
|
-
parser = self.subparsers.add_parser("put-session",
|
|
235
|
-
help="Extend the current logged-in user's session.")
|
|
236
|
-
parser.add_argument("--refresh-upstream", action="store_true",
|
|
237
|
-
help="Attempt to refresh access tokens, other dependent tokens, and claims from the "
|
|
238
|
-
"upstream identity provider.")
|
|
239
|
-
parser.set_defaults(func=self.put_session)
|
|
240
595
|
|
|
241
596
|
def main(self):
|
|
242
|
-
args = self.parse_cli()
|
|
597
|
+
args = self.args = self.parse_cli()
|
|
243
598
|
|
|
244
599
|
def _cmd_error_message(emsg):
|
|
245
600
|
return "{prog} {subcmd}: {msg}".format(
|
|
@@ -250,37 +605,26 @@ class CredenzaAuthUtilCLI(BaseCLI):
|
|
|
250
605
|
self.parser.print_usage()
|
|
251
606
|
return 2
|
|
252
607
|
|
|
253
|
-
if args.subcmd == "login" or args.subcmd == "logout" or args.subcmd == "session":
|
|
254
|
-
pass
|
|
255
|
-
else:
|
|
256
|
-
pass
|
|
257
|
-
|
|
258
608
|
response = args.func(args)
|
|
259
|
-
if
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
pprint(response)
|
|
266
|
-
return 0
|
|
267
|
-
elif not isinstance(response, str):
|
|
268
|
-
pprint(response)
|
|
269
|
-
return 0
|
|
270
|
-
print(response)
|
|
609
|
+
if isinstance(response, dict) or isinstance(response, list):
|
|
610
|
+
print(json.dumps(response, indent=2 if args.pretty else None))
|
|
611
|
+
elif not isinstance(response, str):
|
|
612
|
+
pprint(response)
|
|
613
|
+
else:
|
|
614
|
+
print(response)
|
|
271
615
|
return 0
|
|
272
616
|
|
|
273
617
|
except UsageException as e:
|
|
274
618
|
eprint("{prog} {subcmd}: {msg}".format(prog=self.parser.prog, subcmd=args.subcmd, msg=e))
|
|
275
619
|
except ConnectionError as e:
|
|
276
|
-
eprint("{prog}: Connection error occurred".format(prog=self.parser.prog))
|
|
620
|
+
eprint("{prog}: Connection error occurred: {err}".format(prog=self.parser.prog, err=format_exception(e)))
|
|
277
621
|
except HTTPError as e:
|
|
278
622
|
if 401 == e.response.status_code:
|
|
279
623
|
msg = 'Authentication required: %s' % format_exception(e)
|
|
280
624
|
elif 403 == e.response.status_code:
|
|
281
625
|
msg = 'Permission denied: %s' % format_exception(e)
|
|
282
626
|
else:
|
|
283
|
-
msg = e
|
|
627
|
+
msg = format_exception(e)
|
|
284
628
|
eprint(_cmd_error_message(msg))
|
|
285
629
|
except RuntimeError as e:
|
|
286
630
|
logging.debug(format_exception(e))
|
|
@@ -290,10 +634,12 @@ class CredenzaAuthUtilCLI(BaseCLI):
|
|
|
290
634
|
traceback.print_exc()
|
|
291
635
|
return 1
|
|
292
636
|
|
|
637
|
+
|
|
293
638
|
def main():
|
|
294
639
|
desc = "Credenza Auth Utilities"
|
|
295
640
|
info = "For more information see: https://github.com/informatics-isi-edu/deriva-py"
|
|
296
641
|
return CredenzaAuthUtilCLI(desc, info).main()
|
|
297
642
|
|
|
643
|
+
|
|
298
644
|
if __name__ == '__main__':
|
|
299
|
-
sys.exit(main())
|
|
645
|
+
sys.exit(main())
|