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.
- {das_cli-1.2.32/das_cli.egg-info → das_cli-1.2.34}/PKG-INFO +1 -1
- das_cli-1.2.34/das/authentication/oauth.py +324 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/cli.py +179 -7
- das_cli-1.2.34/das/common/config.py +355 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/search_manager.py +7 -3
- {das_cli-1.2.32 → das_cli-1.2.34/das_cli.egg-info}/PKG-INFO +1 -1
- {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/SOURCES.txt +1 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/pyproject.toml +1 -1
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/search_manager_test.py +8 -0
- das_cli-1.2.32/das/common/config.py +0 -199
- {das_cli-1.2.32 → das_cli-1.2.34}/LICENSE +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/MANIFEST.in +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/README.md +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/__init__.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/ai/plugins/dasai.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/ai/plugins/entries/entries_plugin.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/app.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/authentication/auth.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/authentication/secure_input.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/common/api.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/common/entry_fields_constants.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/common/enums.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/common/file_utils.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/__init__.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/digital_objects_manager.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/download_manager.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/managers/entries_manager.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/attributes.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/cache.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/digital_objects.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/downloads.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/entries.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/entry_fields.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/hangfire.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/search.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/service_base.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das/services/users.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/dependency_links.txt +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/entry_points.txt +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/requires.txt +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/das_cli.egg-info/top_level.txt +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/setup.cfg +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/__init__.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/attributes_test.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/download_manager_test.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/entries_manager_test.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/entries_service_test.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/entries_test.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/file_utils_test.py +0 -0
- {das_cli-1.2.32 → das_cli-1.2.34}/tests/run_tests.py +0 -0
|
@@ -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">✓ 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">✗ 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',
|
|
95
|
-
@click.option('--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
|
-
|
|
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()
|