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.
@@ -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
- class UsageException(ValueError):
26
- """Usage exception.
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
- class CredenzaAuthUtilCLI(BaseCLI):
34
- """CredenzaAuthUtil Command-line Interface.
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
- def __init__(self, *args, **kwargs):
37
- super(CredenzaAuthUtilCLI, self).__init__(*args, **kwargs)
38
- self.remove_options(['--token', '--oauth2-token'])
39
- self.parser.add_argument("--pretty", "-p", action="store_true",
40
- help="Pretty-print all result output.")
41
- self.credentials = None
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
- def load_credential(self, host, credential_file=None):
66
- if not self.credentials:
67
- self.credentials = read_credential(credential_file or DEFAULT_CREDENTIAL_FILE, create_default=True)
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.get("bearer-token")
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 get_session(self, args, check_only=False):
85
- credential = self.load_credential(args.host, args.credential_file)
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 if check_only else "Credential not found. Login required."
244
+ return None
245
+ token = credential.get("bearer-token")
88
246
 
89
- url = host_to_url(args.host, "/authn/session")
90
- session = get_new_requests_session(url)
91
- session.headers.update({"Authorization": f"Bearer {credential}"})
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
- if response.status_code == 200:
95
- return response.json()
96
- elif response.status_code == 404:
97
- return None if check_only else f"No valid session found for host '{args.host}'."
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
- response.raise_for_status()
259
+ resp.raise_for_status()
100
260
  return None
101
261
 
102
- def put_session(self, args):
103
- credential = self.load_credential(args.host, args.credential_file)
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
- return "Credential not found. Login required."
270
+ raise UsageException("Credential not found. Login required.")
271
+ token = credential.get("bearer-token")
106
272
 
107
273
  path = "/authn/session"
108
- if args.refresh_upstream:
109
- path += "?refresh_upstream=true"
110
- url = host_to_url(args.host, path)
111
- session = get_new_requests_session(url)
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
- if response.status_code == 200:
116
- return response.json()
117
- elif response.status_code == 404:
118
- return f"No valid session found for host '{args.host}'."
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
- response.raise_for_status()
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
- return f"You are already logged in to host '{args.host}'"
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
- self.save_credential(args.host, args.credential_file, token)
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
- token=token,
179
- keychain_file=args.bdbag_keychain_file or bdbkc.DEFAULT_KEYCHAIN_FILE)
180
- token_display = f"Access token: {token}" if args.show_token else ""
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, args.credential_file)
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
- url = host_to_url(args.host, "/authn/device/logout")
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 {credential}"})
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, args.credential_file)
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
- token=credential,
198
- delete=True,
199
- keychain_file=args.bdbag_keychain_file or bdbkc.DEFAULT_KEYCHAIN_FILE)
200
-
201
- return f"You have been successfully logged out of host '{args.host}'."
202
-
203
- def login_init(self):
204
- parser = self.subparsers.add_parser('login',
205
- help="Login with Globus Auth and get OAuth tokens for resource access.")
206
-
207
- parser.add_argument("--no-bdbag-keychain", action="store_true",
208
- help="Do not update the bdbag keychain file with result access tokens. Default false.")
209
- parser.add_argument('--bdbag-keychain-file', metavar='<file>',
210
- help="Non-default path to a bdbag keychain file.")
211
- parser.add_argument("--refresh", action="store_true",
212
- help="Allow the session manager to automatically refresh access tokens on the user's behalf "
213
- "until either the refresh token expires or the user logs out.")
214
- parser.add_argument("--force", action="store_true",
215
- help="Force a login flow even if the current access token is valid.")
216
- parser.add_argument("--show-token", action="store_true",
217
- help="Display the token from the authorization response.")
218
- parser.set_defaults(func=self.login)
219
-
220
- def logout_init(self):
221
- parser = self.subparsers.add_parser("logout", help="Logout and revoke all access and refresh tokens.")
222
- parser.add_argument("--no-bdbag-keychain", action="store_true",
223
- help="Do not update the bdbag keychain file by removing access tokens on logout. Default false.")
224
- parser.add_argument('--bdbag-keychain-file', metavar='<file>',
225
- help="Non-default path to a bdbag keychain file.")
226
- parser.set_defaults(func=self.logout)
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
- def get_session_init(self):
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 args.pretty:
260
- if isinstance(response, dict) or isinstance(response, list):
261
- try:
262
- print(json.dumps(response, indent=2))
263
- return 0
264
- except:
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())