das-cli 1.2.32__tar.gz → 1.2.34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {das_cli-1.2.32/das_cli.egg-info → das_cli-1.2.34}/PKG-INFO +1 -1
  2. das_cli-1.2.34/das/authentication/oauth.py +324 -0
  3. {das_cli-1.2.32 → das_cli-1.2.34}/das/cli.py +179 -7
  4. das_cli-1.2.34/das/common/config.py +355 -0
  5. {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/search_manager.py +7 -3
  6. {das_cli-1.2.32 → das_cli-1.2.34/das_cli.egg-info}/PKG-INFO +1 -1
  7. {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/SOURCES.txt +1 -0
  8. {das_cli-1.2.32 → das_cli-1.2.34}/pyproject.toml +1 -1
  9. {das_cli-1.2.32 → das_cli-1.2.34}/tests/search_manager_test.py +8 -0
  10. das_cli-1.2.32/das/common/config.py +0 -199
  11. {das_cli-1.2.32 → das_cli-1.2.34}/LICENSE +0 -0
  12. {das_cli-1.2.32 → das_cli-1.2.34}/MANIFEST.in +0 -0
  13. {das_cli-1.2.32 → das_cli-1.2.34}/README.md +0 -0
  14. {das_cli-1.2.32 → das_cli-1.2.34}/das/__init__.py +0 -0
  15. {das_cli-1.2.32 → das_cli-1.2.34}/das/ai/plugins/dasai.py +0 -0
  16. {das_cli-1.2.32 → das_cli-1.2.34}/das/ai/plugins/entries/entries_plugin.py +0 -0
  17. {das_cli-1.2.32 → das_cli-1.2.34}/das/app.py +0 -0
  18. {das_cli-1.2.32 → das_cli-1.2.34}/das/authentication/auth.py +0 -0
  19. {das_cli-1.2.32 → das_cli-1.2.34}/das/authentication/secure_input.py +0 -0
  20. {das_cli-1.2.32 → das_cli-1.2.34}/das/common/api.py +0 -0
  21. {das_cli-1.2.32 → das_cli-1.2.34}/das/common/entry_fields_constants.py +0 -0
  22. {das_cli-1.2.32 → das_cli-1.2.34}/das/common/enums.py +0 -0
  23. {das_cli-1.2.32 → das_cli-1.2.34}/das/common/file_utils.py +0 -0
  24. {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/__init__.py +0 -0
  25. {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/digital_objects_manager.py +0 -0
  26. {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/download_manager.py +0 -0
  27. {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/entries_manager.py +0 -0
  28. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/attributes.py +0 -0
  29. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/cache.py +0 -0
  30. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/digital_objects.py +0 -0
  31. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/downloads.py +0 -0
  32. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/entries.py +0 -0
  33. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/entry_fields.py +0 -0
  34. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/hangfire.py +0 -0
  35. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/search.py +0 -0
  36. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/service_base.py +0 -0
  37. {das_cli-1.2.32 → das_cli-1.2.34}/das/services/users.py +0 -0
  38. {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/dependency_links.txt +0 -0
  39. {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/entry_points.txt +0 -0
  40. {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/requires.txt +0 -0
  41. {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/top_level.txt +0 -0
  42. {das_cli-1.2.32 → das_cli-1.2.34}/setup.cfg +0 -0
  43. {das_cli-1.2.32 → das_cli-1.2.34}/tests/__init__.py +0 -0
  44. {das_cli-1.2.32 → das_cli-1.2.34}/tests/attributes_test.py +0 -0
  45. {das_cli-1.2.32 → das_cli-1.2.34}/tests/download_manager_test.py +0 -0
  46. {das_cli-1.2.32 → das_cli-1.2.34}/tests/entries_manager_test.py +0 -0
  47. {das_cli-1.2.32 → das_cli-1.2.34}/tests/entries_service_test.py +0 -0
  48. {das_cli-1.2.32 → das_cli-1.2.34}/tests/entries_test.py +0 -0
  49. {das_cli-1.2.32 → das_cli-1.2.34}/tests/file_utils_test.py +0 -0
  50. {das_cli-1.2.32 → das_cli-1.2.34}/tests/run_tests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: das-cli
3
- Version: 1.2.32
3
+ Version: 1.2.34
4
4
  Summary: DAS api client.
5
5
  Author: Royal Netherlands Institute for Sea Research
6
6
  License-Expression: MIT
@@ -0,0 +1,324 @@
1
+ """
2
+ OAuth 2.0 Authorization Code + PKCE flow for Azure Entra / Azure AD.
3
+
4
+ Uses only stdlib + requests (already a dependency).
5
+ """
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ import secrets
10
+ import socket
11
+ import threading
12
+ import time
13
+ import urllib.parse
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ from typing import Optional, Tuple
17
+
18
+ import requests
19
+
20
+
21
+ def _decode_jwt_payload(token: str) -> dict:
22
+ """Decode a JWT payload (no signature verification — only for reading claims)."""
23
+ try:
24
+ parts = token.split(".")
25
+ if len(parts) != 3:
26
+ return {}
27
+ payload = parts[1]
28
+ payload += "=" * (4 - len(payload) % 4) # fix padding
29
+ return json.loads(base64.urlsafe_b64decode(payload))
30
+ except Exception:
31
+ return {}
32
+
33
+
34
+ def _find_free_port() -> int:
35
+ """Bind to port 0 and return the OS-assigned ephemeral port."""
36
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
37
+ s.bind(("127.0.0.1", 0))
38
+ return s.getsockname()[1]
39
+
40
+
41
+ def _generate_pkce() -> Tuple[str, str]:
42
+ """Return (code_verifier, code_challenge) using S256 method."""
43
+ code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
44
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
45
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
46
+ return code_verifier, code_challenge
47
+
48
+
49
+ _SUCCESS_HTML = """<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;margin-top:80px">
50
+ <h2 style="color:#2e7d32">&#10003; Authentication successful!</h2>
51
+ <p>You may close this browser tab and return to the terminal.</p>
52
+ </body></html>"""
53
+
54
+ _ERROR_HTML = """<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;margin-top:80px">
55
+ <h2 style="color:#c62828">&#10007; Authentication failed</h2>
56
+ <p>{error}</p>
57
+ <p>Please return to the terminal.</p>
58
+ </body></html>"""
59
+
60
+
61
+ class _CallbackHandler(BaseHTTPRequestHandler):
62
+ """Handles the single OAuth callback redirect from Azure."""
63
+
64
+ def do_GET(self):
65
+ parsed = urllib.parse.urlparse(self.path)
66
+ params = urllib.parse.parse_qs(parsed.query)
67
+
68
+ if "code" in params:
69
+ self.server.auth_code = params["code"][0]
70
+ self.server.auth_error = None
71
+ body = _SUCCESS_HTML.encode("utf-8")
72
+ elif "error" in params:
73
+ error_desc = params.get("error_description", [params.get("error", ["Unknown error"])[0]])[0]
74
+ self.server.auth_code = None
75
+ self.server.auth_error = error_desc
76
+ body = _ERROR_HTML.format(error=error_desc).encode("utf-8")
77
+ else:
78
+ # Ignore favicon or other unrelated requests
79
+ self.send_response(204)
80
+ self.end_headers()
81
+ return
82
+
83
+ self.send_response(200)
84
+ self.send_header("Content-Type", "text/html; charset=utf-8")
85
+ self.send_header("Content-Length", str(len(body)))
86
+ self.end_headers()
87
+ self.wfile.write(body)
88
+ # Signal the main thread
89
+ self.server.callback_event.set()
90
+
91
+ def log_message(self, format, *args): # noqa: A002
92
+ pass # Suppress default request logging
93
+
94
+
95
+ class OAuthPKCE:
96
+ """
97
+ Authorization Code + PKCE flow for Azure Entra (or any OIDC provider).
98
+
99
+ Example usage::
100
+
101
+ oauth = OAuthPKCE(
102
+ client_id="<app-client-id>",
103
+ authority="https://login.microsoftonline.com/<tenant>/v2.0",
104
+ scope="openid profile email offline_access",
105
+ )
106
+ tokens = oauth.authenticate()
107
+ access_token = tokens["access_token"]
108
+ """
109
+
110
+ def __init__(self, client_id: str, authority: str, scope: str):
111
+ self.client_id = client_id
112
+ self.authority = self._normalize_authority(authority)
113
+ self.scope = scope
114
+
115
+ @staticmethod
116
+ def _normalize_authority(authority: str) -> str:
117
+ """Strip any OAuth path components so the authority is bare.
118
+
119
+ Accepts any of these forms and normalises to the bare tenant URL:
120
+ https://login.microsoftonline.com/{tenant}
121
+ https://login.microsoftonline.com/{tenant}/v2.0
122
+ https://login.microsoftonline.com/{tenant}/oauth2/v2.0
123
+ https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
124
+ """
125
+ a = authority.rstrip("/")
126
+ # Strip anything from /oauth2 onwards
127
+ idx = a.find("/oauth2")
128
+ if idx != -1:
129
+ a = a[:idx]
130
+ # Strip trailing /v2.0
131
+ if a.endswith("/v2.0"):
132
+ a = a[: -len("/v2.0")]
133
+ return a
134
+
135
+ # ------------------------------------------------------------------
136
+ # Internal helpers
137
+ # ------------------------------------------------------------------
138
+
139
+ def _authorize_url(self) -> str:
140
+ return f"{self.authority}/oauth2/v2.0/authorize"
141
+
142
+ def _token_url(self) -> str:
143
+ return f"{self.authority}/oauth2/v2.0/token"
144
+
145
+ # ------------------------------------------------------------------
146
+ # Public API
147
+ # ------------------------------------------------------------------
148
+
149
+ def authenticate(self, verify_ssl: bool = True, timeout: int = 300) -> dict:
150
+ """
151
+ Run the full PKCE flow.
152
+
153
+ Opens the system browser, waits for the redirect callback on a
154
+ localhost HTTP server, then exchanges the auth code for tokens.
155
+
156
+ Returns the token response dict (keys: access_token, id_token,
157
+ refresh_token, expires_in, …).
158
+
159
+ Raises RuntimeError on failure or timeout.
160
+ """
161
+ code_verifier, code_challenge = _generate_pkce()
162
+ state = secrets.token_urlsafe(16)
163
+ port = _find_free_port()
164
+ redirect_uri = f"http://localhost:{port}/callback"
165
+
166
+ # Build authorisation URL
167
+ params = {
168
+ "client_id": self.client_id,
169
+ "response_type": "code",
170
+ "redirect_uri": redirect_uri,
171
+ "scope": self.scope,
172
+ "code_challenge": code_challenge,
173
+ "code_challenge_method": "S256",
174
+ "state": state,
175
+ "response_mode": "query",
176
+ }
177
+ auth_url = self._authorize_url() + "?" + urllib.parse.urlencode(params)
178
+
179
+ # Start local callback server
180
+ server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
181
+ server.auth_code = None
182
+ server.auth_error = None
183
+ server.callback_event = threading.Event()
184
+
185
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
186
+ server_thread.start()
187
+
188
+ try:
189
+ print(f"Opening browser for authentication…\n{auth_url}\n")
190
+ webbrowser.open(auth_url)
191
+
192
+ # Wait for callback (up to `timeout` seconds)
193
+ if not server.callback_event.wait(timeout=timeout):
194
+ raise RuntimeError("Timed out waiting for authentication callback.")
195
+
196
+ if server.auth_error:
197
+ raise RuntimeError(f"Authentication error: {server.auth_error}")
198
+
199
+ auth_code = server.auth_code
200
+ finally:
201
+ server.shutdown()
202
+
203
+ # Exchange code for tokens
204
+ return self._exchange_code(auth_code, code_verifier, redirect_uri, verify_ssl)
205
+
206
+ def _exchange_code(
207
+ self,
208
+ code: str,
209
+ code_verifier: str,
210
+ redirect_uri: str,
211
+ verify_ssl: bool,
212
+ ) -> dict:
213
+ data = {
214
+ "client_id": self.client_id,
215
+ "grant_type": "authorization_code",
216
+ "code": code,
217
+ "redirect_uri": redirect_uri,
218
+ "code_verifier": code_verifier,
219
+ "scope": self.scope,
220
+ }
221
+ response = requests.post(self._token_url(), data=data, verify=verify_ssl, timeout=30)
222
+ response.raise_for_status()
223
+ tokens = response.json()
224
+ if "access_token" not in tokens:
225
+ raise RuntimeError(f"Token response missing access_token: {tokens}")
226
+ return tokens
227
+
228
+ def refresh_tokens(self, refresh_token: str, verify_ssl: bool = True) -> dict:
229
+ """
230
+ Use a stored refresh token to obtain a new access token (and
231
+ possibly a new refresh token).
232
+
233
+ Returns the token response dict.
234
+ Raises RuntimeError on failure.
235
+ """
236
+ data = {
237
+ "client_id": self.client_id,
238
+ "grant_type": "refresh_token",
239
+ "refresh_token": refresh_token,
240
+ "scope": self.scope,
241
+ }
242
+ response = requests.post(self._token_url(), data=data, verify=verify_ssl, timeout=30)
243
+ response.raise_for_status()
244
+ tokens = response.json()
245
+ if "access_token" not in tokens:
246
+ raise RuntimeError(f"Token refresh response missing access_token: {tokens}")
247
+ return tokens
248
+
249
+ def _get_provider_name(self, api_url: str, verify_ssl: bool) -> str:
250
+ """Auto-detect the ExternalAuthenticate provider name by matching client_id."""
251
+ try:
252
+ resp = requests.get(
253
+ f"{api_url.rstrip('/')}/api/TokenAuth/GetExternalAuthenticationProviders",
254
+ verify=verify_ssl,
255
+ timeout=10,
256
+ )
257
+ resp.raise_for_status()
258
+ for provider in resp.json().get("result", []):
259
+ if provider.get("clientId") == self.client_id:
260
+ return provider["name"]
261
+ except Exception:
262
+ pass
263
+ return "OpenIdConnect" # sensible fallback
264
+
265
+ def exchange_for_das_token(
266
+ self,
267
+ azure_tokens: dict,
268
+ api_url: str,
269
+ verify_ssl: bool = True,
270
+ debug: bool = False,
271
+ ) -> str:
272
+ """Exchange an Azure access token for a DAS API token.
273
+
274
+ Calls POST /api/TokenAuth/ExternalAuthenticate on the DAS API.
275
+ Returns the DAS API access token string.
276
+ Raises RuntimeError on failure.
277
+ """
278
+ # The id_token is signed for the client (DAS API can validate it via OIDC keys).
279
+ # The access_token is signed for the resource/audience and will fail signature validation.
280
+ id_token = azure_tokens.get("id_token")
281
+ if not id_token:
282
+ raise RuntimeError(
283
+ "No id_token in Azure response. Ensure 'openid' is in the requested scopes."
284
+ )
285
+
286
+ claims = _decode_jwt_payload(id_token)
287
+
288
+ # ABP stores the 'sub' claim as the provider key (confirmed via AbpUserLogins).
289
+ # 'sub' in Azure AD v2.0 is pairwise per client_id, so the client_id used
290
+ # here must match the one the web app used when the user first logged in.
291
+ provider_key = claims.get("sub") or claims.get("oid")
292
+ if not provider_key:
293
+ raise RuntimeError(
294
+ "Could not extract user identity (sub/oid) from id_token. "
295
+ "Ensure 'openid' is in the requested scopes."
296
+ )
297
+
298
+ provider_name = self._get_provider_name(api_url, verify_ssl)
299
+
300
+ if debug:
301
+ print(f" authProvider : {provider_name}")
302
+ print(f" providerKey : {provider_key}")
303
+ print(f" providerKey from claim: oid={claims.get('oid')} sub={claims.get('sub')}")
304
+ print(f" preferred_username: {claims.get('preferred_username')}")
305
+ print()
306
+
307
+ response = requests.post(
308
+ f"{api_url.rstrip('/')}/api/TokenAuth/ExternalAuthenticate",
309
+ json={
310
+ "authProvider": provider_name,
311
+ "providerKey": provider_key,
312
+ "providerAccessCode": id_token, # id_token, not access_token
313
+ },
314
+ verify=verify_ssl,
315
+ timeout=30,
316
+ )
317
+ response.raise_for_status()
318
+ result = response.json()
319
+ das_token = (result.get("result") or {}).get("accessToken")
320
+ if not das_token:
321
+ raise RuntimeError(
322
+ f"DAS API did not return an access token. Response: {result}"
323
+ )
324
+ return das_token
@@ -7,7 +7,9 @@ from das.common.config import (
7
7
  save_api_url, load_api_url, DEFAULT_BASE_URL,
8
8
  save_verify_ssl, load_verify_ssl, VERIFY_SSL,
9
9
  load_openai_api_key, save_openai_api_key, clear_openai_api_key,
10
- clear_token, _config_dir
10
+ clear_token, _config_dir,
11
+ save_oauth_config, load_oauth_config, get_auth_type, set_auth_type,
12
+ save_token, save_token_expiry, save_refresh_token, clear_refresh_token
11
13
  )
12
14
 
13
15
  from das.app import Das
@@ -91,24 +93,134 @@ def cli(ctx):
91
93
 
92
94
  @cli.command()
93
95
  @click.option('--api-url', required=True, help='API base URL')
94
- @click.option('--username', required=True, prompt=True, help='Username')
95
- @click.option('--password', required=True, prompt=True, hide_input=True, help='Password')
96
+ @click.option('--username', default=None, help='Username (basic auth)')
97
+ @click.option('--password', default=None, hide_input=True, help='Password (basic auth)')
98
+ @click.option('--oauth', 'use_oauth', is_flag=True, default=False, help='Use Azure Entra/OAuth authentication (opens browser)')
99
+ @click.option('--debug', is_flag=True, default=False, help='Print decoded token claims for troubleshooting')
96
100
  @pass_das_context
97
- def login(das_ctx, api_url, username, password):
101
+ def login(das_ctx, api_url, username, password, use_oauth, debug):
98
102
  """Login and store authentication token"""
99
103
  # Save API URL for future use
100
104
  save_api_url(api_url)
101
105
  das_ctx.api_url = api_url
102
-
103
- # Authenticate
106
+
107
+ if use_oauth:
108
+ # --- OAuth / Azure Entra PKCE flow ---
109
+ oauth_cfg = load_oauth_config()
110
+ if not oauth_cfg.get("client_id"):
111
+ click.secho(
112
+ "❌ OAuth is not configured. Run 'das oauth configure --client-id <id> --authority <url>' first.",
113
+ fg="red",
114
+ )
115
+ return
116
+ try:
117
+ from das.authentication.oauth import OAuthPKCE, _decode_jwt_payload
118
+ import time as _time
119
+ pkce = OAuthPKCE(
120
+ client_id=oauth_cfg["client_id"],
121
+ authority=oauth_cfg.get("authority", ""),
122
+ scope=oauth_cfg.get("scope", "openid profile email offline_access"),
123
+ )
124
+ tokens = pkce.authenticate(verify_ssl=True) # Microsoft's servers always have valid certs
125
+
126
+ if debug:
127
+ id_token = tokens.get("id_token", "")
128
+ claims = _decode_jwt_payload(id_token)
129
+ click.secho("\nid_token claims:", fg="yellow", bold=True)
130
+ for k, v in claims.items():
131
+ click.echo(f" {k}: {v}")
132
+ click.echo()
133
+
134
+ # Exchange the Azure token for a DAS API token
135
+ verify_ssl = load_verify_ssl()
136
+ das_token = pkce.exchange_for_das_token(tokens, api_url, verify_ssl=verify_ssl, debug=debug)
137
+ save_token(das_token)
138
+ save_token_expiry(_time.time() + tokens.get("expires_in", 3600))
139
+ if "refresh_token" in tokens:
140
+ save_refresh_token(tokens["refresh_token"])
141
+ set_auth_type("oauth")
142
+ click.secho("✓ Authentication successful!", fg="green")
143
+ except Exception as e:
144
+ click.secho(f"❌ OAuth authentication failed: {e}", fg="red")
145
+ return
146
+
147
+ # --- Basic username/password flow ---
148
+ if not username:
149
+ username = click.prompt("Username")
150
+ if not password:
151
+ password = click.prompt("Password", hide_input=True)
152
+
104
153
  client = das_ctx.get_client()
105
154
  token = client.authenticate(username, password)
106
155
  if not token:
107
156
  click.secho("❌ Authentication failed. Please check your credentials.", fg="red")
108
157
  return
109
158
  else:
159
+ set_auth_type("basic")
110
160
  click.secho("✓ Authentication successful!", fg="green")
111
161
 
162
+ # OAuth / Azure Entra command group
163
+ @cli.group()
164
+ def oauth():
165
+ """OAuth / Azure Entra configuration"""
166
+
167
+
168
+ @oauth.command("configure")
169
+ @click.option('--client-id', required=True, help='Azure app (client) ID')
170
+ @click.option('--authority', default='https://login.microsoftonline.com/common/v2.0', show_default=True, help='Authority URL (e.g. https://login.microsoftonline.com/{tenant}/v2.0)')
171
+ @click.option('--scope', default='openid profile email offline_access', show_default=True, help='Space-separated OAuth scopes')
172
+ def oauth_configure(client_id, authority, scope):
173
+ """Save OAuth client settings (run once before 'das login --oauth')"""
174
+ save_oauth_config(client_id, authority, scope)
175
+ click.secho("✓ OAuth configuration saved.", fg="green")
176
+ click.echo(f" Client ID : {client_id}")
177
+ click.echo(f" Authority : {authority}")
178
+ click.echo(f" Scope : {scope}")
179
+
180
+
181
+ @oauth.command("status")
182
+ def oauth_status():
183
+ """Show current OAuth configuration"""
184
+ cfg = load_oauth_config()
185
+ auth_type = get_auth_type()
186
+ if not cfg.get("client_id"):
187
+ click.secho("OAuth is not configured.", fg="yellow")
188
+ click.echo("Run 'das oauth configure --client-id <id>' to set it up.")
189
+ return
190
+ click.secho("OAuth configuration:", fg="green")
191
+ click.echo(f" Client ID : {cfg.get('client_id')}")
192
+ click.echo(f" Authority : {cfg.get('authority')}")
193
+ click.echo(f" Scope : {cfg.get('scope')}")
194
+ click.echo(f" Auth type : {auth_type}")
195
+ expires_at = cfg.get("token_expires_at")
196
+ if expires_at:
197
+ import time as _time
198
+ remaining = int(expires_at - _time.time())
199
+ if remaining > 0:
200
+ click.echo(f" Token exp : in {remaining}s")
201
+ else:
202
+ click.echo(f" Token exp : expired {-remaining}s ago")
203
+
204
+
205
+ @oauth.command("clear")
206
+ def oauth_clear():
207
+ """Remove OAuth configuration and tokens"""
208
+ cfg = load_oauth_config()
209
+ if cfg:
210
+ # Remove oauth key from config.json
211
+ import json as _json
212
+ from das.common.config import CONFIG_FILE
213
+ try:
214
+ config = _json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
215
+ config.pop("oauth", None)
216
+ config.pop("auth_type", None)
217
+ CONFIG_FILE.write_text(_json.dumps(config), encoding="utf-8")
218
+ except Exception:
219
+ pass
220
+ clear_refresh_token()
221
+ click.secho("✓ OAuth configuration and tokens cleared.", fg="green")
222
+
223
+
112
224
  # Search commands group
113
225
  @cli.group()
114
226
  def search():
@@ -907,6 +1019,65 @@ def get_ssl_status():
907
1019
  status = "enabled" if VERIFY_SSL else "disabled"
908
1020
  click.echo(f"SSL certificate verification is currently {status}")
909
1021
 
1022
+ @config.command("show")
1023
+ def config_show():
1024
+ """Show all current persistent settings"""
1025
+ import time as _time
1026
+ from das.common.config import load_token, load_token_expiry
1027
+
1028
+ # API URL
1029
+ api_url = load_api_url() or "(not set)"
1030
+
1031
+ # Auth
1032
+ auth_type = get_auth_type()
1033
+ token = load_token(auto_refresh=False)
1034
+ token_display = f"{token[:8]}…" if token else "(none)"
1035
+
1036
+ # SSL
1037
+ ssl = load_verify_ssl()
1038
+
1039
+ # OAuth
1040
+ oauth_cfg = load_oauth_config()
1041
+
1042
+ # OpenAI
1043
+ openai_key = load_openai_api_key()
1044
+ openai_display = f"{openai_key[:8]}…" if openai_key else "(not set)"
1045
+
1046
+ click.secho("Current settings", bold=True)
1047
+ click.echo("─" * 40)
1048
+
1049
+ click.secho(" Connection", fg="blue", bold=True)
1050
+ click.echo(f" API URL : {api_url}")
1051
+ click.echo(f" SSL verify : {'enabled' if ssl else 'disabled'}")
1052
+
1053
+ click.echo("")
1054
+ click.secho(" Authentication", fg="blue", bold=True)
1055
+ click.echo(f" Auth type : {auth_type}")
1056
+ click.echo(f" Token : {token_display}")
1057
+
1058
+ if auth_type == "oauth":
1059
+ expires_at = load_token_expiry()
1060
+ if expires_at:
1061
+ remaining = int(expires_at - _time.time())
1062
+ if remaining > 0:
1063
+ click.echo(f" Token expiry : in {remaining}s")
1064
+ else:
1065
+ click.echo(f" Token expiry : expired {-remaining}s ago")
1066
+
1067
+ if oauth_cfg.get("client_id"):
1068
+ click.echo("")
1069
+ click.secho(" OAuth / Azure Entra", fg="blue", bold=True)
1070
+ click.echo(f" Client ID : {oauth_cfg.get('client_id')}")
1071
+ click.echo(f" Authority : {oauth_cfg.get('authority')}")
1072
+ click.echo(f" Scope : {oauth_cfg.get('scope')}")
1073
+
1074
+ click.echo("")
1075
+ click.secho(" AI", fg="blue", bold=True)
1076
+ click.echo(f" OpenAI key : {openai_display}")
1077
+
1078
+ click.echo("─" * 40)
1079
+
1080
+
910
1081
  @config.command("reset")
911
1082
  @click.option('--force', is_flag=True, help='Skip confirmation prompt')
912
1083
  def reset_config(force):
@@ -919,8 +1090,9 @@ def reset_config(force):
919
1090
  from das.common.config import clear_token, _config_dir
920
1091
  import shutil
921
1092
 
922
- # Clear token (handles both keyring and file-based storage)
1093
+ # Clear token and OAuth refresh token (handles both keyring and file-based storage)
923
1094
  clear_token()
1095
+ clear_refresh_token()
924
1096
 
925
1097
  # Get the config directory path
926
1098
  config_dir = _config_dir()