python-alfresco-api 1.2.1__py3-none-any.whl → 1.2.2__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.
- python_alfresco_api/auth_util.py +875 -853
- {python_alfresco_api-1.2.1.dist-info → python_alfresco_api-1.2.2.dist-info}/METADATA +6 -5
- {python_alfresco_api-1.2.1.dist-info → python_alfresco_api-1.2.2.dist-info}/RECORD +5 -5
- {python_alfresco_api-1.2.1.dist-info → python_alfresco_api-1.2.2.dist-info}/WHEEL +1 -1
- {python_alfresco_api-1.2.1.dist-info → python_alfresco_api-1.2.2.dist-info}/licenses/LICENSE +0 -0
python_alfresco_api/auth_util.py
CHANGED
|
@@ -1,853 +1,875 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Shared Authentication Utility
|
|
3
|
-
|
|
4
|
-
Provides authentication management that can be shared across multiple API clients.
|
|
5
|
-
Handles ticket-based authentication with automatic renewal.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import asyncio
|
|
9
|
-
import base64
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
from typing import Optional, Dict, Any, Union
|
|
13
|
-
from datetime import datetime, timedelta
|
|
14
|
-
import httpx
|
|
15
|
-
|
|
16
|
-
# Try to import python-dotenv for .env file support (optional)
|
|
17
|
-
try:
|
|
18
|
-
from dotenv import load_dotenv
|
|
19
|
-
DOTENV_AVAILABLE = True
|
|
20
|
-
except ImportError:
|
|
21
|
-
DOTENV_AVAILABLE = False
|
|
22
|
-
|
|
23
|
-
def load_env_config(
|
|
24
|
-
base_url: Optional[str] = None,
|
|
25
|
-
username: Optional[str] = None,
|
|
26
|
-
password: Optional[str] = None,
|
|
27
|
-
verify_ssl: Optional[Union[bool, str]] = None,
|
|
28
|
-
timeout: Optional[int] = None,
|
|
29
|
-
load_env: bool = True,
|
|
30
|
-
env_file: Optional[str] = None
|
|
31
|
-
) -> Dict[str, Any]:
|
|
32
|
-
"""
|
|
33
|
-
Load configuration from environment variables and .env files.
|
|
34
|
-
|
|
35
|
-
Priority: explicit parameters > environment variables > defaults
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
base_url: Explicit base URL (overrides env)
|
|
39
|
-
username: Explicit username (overrides env)
|
|
40
|
-
password: Explicit password (overrides env)
|
|
41
|
-
verify_ssl: SSL verification - True/False or path to certificate bundle (overrides env)
|
|
42
|
-
timeout: Request timeout in seconds (overrides env)
|
|
43
|
-
load_env: Whether to load from environment/.env file
|
|
44
|
-
env_file: Specific .env file path
|
|
45
|
-
|
|
46
|
-
Returns:
|
|
47
|
-
Dict with resolved configuration values
|
|
48
|
-
"""
|
|
49
|
-
# Load .env file if available and requested
|
|
50
|
-
if load_env and DOTENV_AVAILABLE:
|
|
51
|
-
if env_file:
|
|
52
|
-
load_dotenv(env_file)
|
|
53
|
-
else:
|
|
54
|
-
load_dotenv() # Loads .env from current directory
|
|
55
|
-
|
|
56
|
-
# Priority: explicit parameters > environment variables > defaults
|
|
57
|
-
resolved_base_url = base_url or os.getenv('ALFRESCO_URL') or os.getenv('ALFRESCO_BASE_URL') or 'http://localhost:8080'
|
|
58
|
-
resolved_username = username or os.getenv('ALFRESCO_USERNAME') or 'admin'
|
|
59
|
-
resolved_password = password or os.getenv('ALFRESCO_PASSWORD') or 'admin'
|
|
60
|
-
|
|
61
|
-
# Handle timeout with environment variable support
|
|
62
|
-
if timeout is not None:
|
|
63
|
-
resolved_timeout = timeout
|
|
64
|
-
else:
|
|
65
|
-
timeout_env = os.getenv('ALFRESCO_TIMEOUT')
|
|
66
|
-
if timeout_env:
|
|
67
|
-
try:
|
|
68
|
-
resolved_timeout = int(timeout_env)
|
|
69
|
-
except ValueError:
|
|
70
|
-
resolved_timeout = None # Invalid value, don't set timeout
|
|
71
|
-
else:
|
|
72
|
-
resolved_timeout = None # No timeout specified, let system/client defaults apply
|
|
73
|
-
|
|
74
|
-
# Handle SSL verification (supports bool or certificate path like raw client)
|
|
75
|
-
if verify_ssl is not None:
|
|
76
|
-
resolved_verify_ssl = verify_ssl
|
|
77
|
-
else:
|
|
78
|
-
ssl_env = os.getenv('ALFRESCO_VERIFY_SSL') or os.getenv('ALFRESCO_SSL_VERIFY') or 'true'
|
|
79
|
-
# Support raw client SSL modes: True, False, or certificate path
|
|
80
|
-
if ssl_env.lower() in ('false', '0', 'no', 'off'):
|
|
81
|
-
resolved_verify_ssl = False
|
|
82
|
-
elif ssl_env.lower() in ('true', '1', 'yes', 'on'):
|
|
83
|
-
resolved_verify_ssl = True
|
|
84
|
-
else:
|
|
85
|
-
# Assume it's a certificate path
|
|
86
|
-
resolved_verify_ssl = ssl_env
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
'base_url': resolved_base_url,
|
|
90
|
-
'username': resolved_username,
|
|
91
|
-
'password': resolved_password,
|
|
92
|
-
'verify_ssl': resolved_verify_ssl,
|
|
93
|
-
'timeout': resolved_timeout
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
class SimpleAuthUtil:
|
|
97
|
-
"""
|
|
98
|
-
Simple authentication utility using Basic authentication.
|
|
99
|
-
|
|
100
|
-
This is the proven working implementation that the MCP server uses.
|
|
101
|
-
Provides reliable Basic authentication for inheritance-based clients.
|
|
102
|
-
"""
|
|
103
|
-
|
|
104
|
-
def __init__(self, username: str, password: str):
|
|
105
|
-
"""
|
|
106
|
-
Initialize simple auth utility.
|
|
107
|
-
|
|
108
|
-
Args:
|
|
109
|
-
username: Alfresco username
|
|
110
|
-
password: Alfresco password
|
|
111
|
-
"""
|
|
112
|
-
self.username = username
|
|
113
|
-
self.password = password
|
|
114
|
-
|
|
115
|
-
def get_basic_auth_header(self):
|
|
116
|
-
"""Get basic auth header for HTTP requests."""
|
|
117
|
-
auth_string = f"{self.username}:{self.password}"
|
|
118
|
-
auth_b64 = base64.b64encode(auth_string.encode()).decode()
|
|
119
|
-
return f'Basic {auth_b64}'
|
|
120
|
-
|
|
121
|
-
def get_auth_token(self):
|
|
122
|
-
"""Get just the base64 auth token (without 'Basic ' prefix) for AuthenticatedClient."""
|
|
123
|
-
auth_string = f"{self.username}:{self.password}"
|
|
124
|
-
return base64.b64encode(auth_string.encode()).decode()
|
|
125
|
-
|
|
126
|
-
def get_auth_prefix(self):
|
|
127
|
-
"""Get the authentication prefix for this auth type."""
|
|
128
|
-
return "Basic"
|
|
129
|
-
|
|
130
|
-
def is_authenticated(self):
|
|
131
|
-
return True # Simple auth is always "authenticated"
|
|
132
|
-
|
|
133
|
-
async def ensure_authenticated(self):
|
|
134
|
-
return True # Simple auth is always ready
|
|
135
|
-
|
|
136
|
-
class AuthUtil:
|
|
137
|
-
"""
|
|
138
|
-
Shared authentication utility for Alfresco APIs.
|
|
139
|
-
|
|
140
|
-
Handles ticket-based authentication with automatic renewal.
|
|
141
|
-
Can be shared across multiple API clients.
|
|
142
|
-
"""
|
|
143
|
-
|
|
144
|
-
def __init__(
|
|
145
|
-
self,
|
|
146
|
-
base_url: str,
|
|
147
|
-
username: str,
|
|
148
|
-
password: str,
|
|
149
|
-
verify_ssl: Union[bool, str] = True,
|
|
150
|
-
timeout: Optional[int] = None
|
|
151
|
-
):
|
|
152
|
-
"""
|
|
153
|
-
Initialize authentication utility.
|
|
154
|
-
|
|
155
|
-
Args:
|
|
156
|
-
base_url: Base URL of Alfresco instance
|
|
157
|
-
username: Alfresco username
|
|
158
|
-
password: Alfresco password
|
|
159
|
-
verify_ssl: SSL verification - True, False, or path to certificate bundle
|
|
160
|
-
timeout: Request timeout in seconds (None = use system defaults)
|
|
161
|
-
"""
|
|
162
|
-
self.base_url = base_url.rstrip('/')
|
|
163
|
-
self.username = username
|
|
164
|
-
self.password = password
|
|
165
|
-
self.verify_ssl = verify_ssl
|
|
166
|
-
self.timeout = timeout
|
|
167
|
-
|
|
168
|
-
self.ticket = None
|
|
169
|
-
self.ticket_expires = None
|
|
170
|
-
self._authenticated = False
|
|
171
|
-
|
|
172
|
-
async def authenticate(self) -> bool:
|
|
173
|
-
"""
|
|
174
|
-
Authenticate with Alfresco and get ticket using direct HTTP request.
|
|
175
|
-
|
|
176
|
-
This uses the WORKING authentication method discovered in test_working_api.py.
|
|
177
|
-
Uses direct HTTP requests instead of raw clients to avoid header auth issues.
|
|
178
|
-
|
|
179
|
-
Returns:
|
|
180
|
-
True if authentication successful, False otherwise
|
|
181
|
-
"""
|
|
182
|
-
try:
|
|
183
|
-
# Use direct HTTP approach like the working test code
|
|
184
|
-
auth_url = f"{self.base_url}/alfresco/api/-default-/public/authentication/versions/1/tickets"
|
|
185
|
-
auth_data = {"userId": self.username, "password": self.password}
|
|
186
|
-
|
|
187
|
-
# Create httpx client with timeout only if specified
|
|
188
|
-
client_kwargs = {"verify": self.verify_ssl}
|
|
189
|
-
if self.timeout is not None:
|
|
190
|
-
client_kwargs["timeout"] = self.timeout
|
|
191
|
-
|
|
192
|
-
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
193
|
-
response = await client.post(auth_url, json=auth_data)
|
|
194
|
-
|
|
195
|
-
if response.status_code == 201:
|
|
196
|
-
ticket_data = response.json()
|
|
197
|
-
self.ticket = ticket_data["entry"]["id"]
|
|
198
|
-
# Tickets typically expire after 1 hour
|
|
199
|
-
self.ticket_expires = datetime.now() + timedelta(hours=1)
|
|
200
|
-
self._authenticated = True
|
|
201
|
-
return True
|
|
202
|
-
else:
|
|
203
|
-
print(f"Authentication failed with status {response.status_code}: {response.text}")
|
|
204
|
-
|
|
205
|
-
except Exception as e:
|
|
206
|
-
print(f"Authentication failed: {e}")
|
|
207
|
-
self._authenticated = False
|
|
208
|
-
|
|
209
|
-
return False
|
|
210
|
-
|
|
211
|
-
def is_authenticated(self) -> bool:
|
|
212
|
-
"""Check if currently authenticated with valid ticket"""
|
|
213
|
-
if not self._authenticated or not self.ticket:
|
|
214
|
-
return False
|
|
215
|
-
|
|
216
|
-
if self.ticket_expires and datetime.now() >= self.ticket_expires:
|
|
217
|
-
self._authenticated = False
|
|
218
|
-
return False
|
|
219
|
-
|
|
220
|
-
return True
|
|
221
|
-
|
|
222
|
-
def get_auth_headers(self) -> Dict[str, str]:
|
|
223
|
-
"""Get authentication headers for API requests"""
|
|
224
|
-
if not self.is_authenticated() or not self.ticket:
|
|
225
|
-
return {}
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
"X-Alfresco-Ticket": self.ticket
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async def ensure_authenticated(self) -> bool:
|
|
232
|
-
"""Ensure we have valid authentication, refresh if needed"""
|
|
233
|
-
if self.is_authenticated():
|
|
234
|
-
return True
|
|
235
|
-
|
|
236
|
-
return await self.authenticate()
|
|
237
|
-
|
|
238
|
-
def get_basic_auth_header(self):
|
|
239
|
-
"""Get basic auth header for compatibility with SimpleAuthUtil interface"""
|
|
240
|
-
auth_string = f"{self.username}:{self.password}"
|
|
241
|
-
auth_b64 = base64.b64encode(auth_string.encode()).decode()
|
|
242
|
-
return f'Basic {auth_b64}'
|
|
243
|
-
|
|
244
|
-
def get_auth_token(self):
|
|
245
|
-
"""Get just the base64 auth token (without 'Basic ' prefix) for AuthenticatedClient."""
|
|
246
|
-
auth_string = f"{self.username}:{self.password}"
|
|
247
|
-
return base64.b64encode(auth_string.encode()).decode()
|
|
248
|
-
|
|
249
|
-
def get_auth_prefix(self):
|
|
250
|
-
"""Get authentication prefix for headers. AuthUtil uses Basic auth."""
|
|
251
|
-
return "Basic"
|
|
252
|
-
|
|
253
|
-
def add_auth_params(self, url: str) -> str:
|
|
254
|
-
"""
|
|
255
|
-
Add authentication parameters to URL (query parameter method).
|
|
256
|
-
|
|
257
|
-
This is the WORKING authentication method for this Alfresco instance.
|
|
258
|
-
Uses alf_ticket={ticket} as query parameter instead of headers.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
url: Base URL to add authentication to
|
|
262
|
-
|
|
263
|
-
Returns:
|
|
264
|
-
URL with authentication parameters added
|
|
265
|
-
"""
|
|
266
|
-
if not self.is_authenticated() or not self.ticket:
|
|
267
|
-
# No authentication available, return original URL
|
|
268
|
-
return url
|
|
269
|
-
|
|
270
|
-
# Add ticket as query parameter
|
|
271
|
-
separator = "&" if "?" in url else "?"
|
|
272
|
-
return f"{url}{separator}alf_ticket={self.ticket}"
|
|
273
|
-
|
|
274
|
-
class TicketAuthUtil:
|
|
275
|
-
"""
|
|
276
|
-
Enhanced authentication utility with direct ticket support.
|
|
277
|
-
Handles both Basic auth and ticket-based authentication.
|
|
278
|
-
"""
|
|
279
|
-
|
|
280
|
-
def __init__(self, username: str, password: str, base_url: str = "",
|
|
281
|
-
verify_ssl: Union[bool, str] = True, timeout: Optional[int] = None
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if self.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
""
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
self.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
#
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
#
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
"
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
"
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
return
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1
|
+
"""
|
|
2
|
+
Shared Authentication Utility
|
|
3
|
+
|
|
4
|
+
Provides authentication management that can be shared across multiple API clients.
|
|
5
|
+
Handles ticket-based authentication with automatic renewal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Optional, Dict, Any, Union
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
# Try to import python-dotenv for .env file support (optional)
|
|
17
|
+
try:
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
DOTENV_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
DOTENV_AVAILABLE = False
|
|
22
|
+
|
|
23
|
+
def load_env_config(
|
|
24
|
+
base_url: Optional[str] = None,
|
|
25
|
+
username: Optional[str] = None,
|
|
26
|
+
password: Optional[str] = None,
|
|
27
|
+
verify_ssl: Optional[Union[bool, str]] = None,
|
|
28
|
+
timeout: Optional[int] = None,
|
|
29
|
+
load_env: bool = True,
|
|
30
|
+
env_file: Optional[str] = None
|
|
31
|
+
) -> Dict[str, Any]:
|
|
32
|
+
"""
|
|
33
|
+
Load configuration from environment variables and .env files.
|
|
34
|
+
|
|
35
|
+
Priority: explicit parameters > environment variables > defaults
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
base_url: Explicit base URL (overrides env)
|
|
39
|
+
username: Explicit username (overrides env)
|
|
40
|
+
password: Explicit password (overrides env)
|
|
41
|
+
verify_ssl: SSL verification - True/False or path to certificate bundle (overrides env)
|
|
42
|
+
timeout: Request timeout in seconds (overrides env)
|
|
43
|
+
load_env: Whether to load from environment/.env file
|
|
44
|
+
env_file: Specific .env file path
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dict with resolved configuration values
|
|
48
|
+
"""
|
|
49
|
+
# Load .env file if available and requested
|
|
50
|
+
if load_env and DOTENV_AVAILABLE:
|
|
51
|
+
if env_file:
|
|
52
|
+
load_dotenv(env_file)
|
|
53
|
+
else:
|
|
54
|
+
load_dotenv() # Loads .env from current directory
|
|
55
|
+
|
|
56
|
+
# Priority: explicit parameters > environment variables > defaults
|
|
57
|
+
resolved_base_url = base_url or os.getenv('ALFRESCO_URL') or os.getenv('ALFRESCO_BASE_URL') or 'http://localhost:8080'
|
|
58
|
+
resolved_username = username or os.getenv('ALFRESCO_USERNAME') or 'admin'
|
|
59
|
+
resolved_password = password or os.getenv('ALFRESCO_PASSWORD') or 'admin'
|
|
60
|
+
|
|
61
|
+
# Handle timeout with environment variable support
|
|
62
|
+
if timeout is not None:
|
|
63
|
+
resolved_timeout = timeout
|
|
64
|
+
else:
|
|
65
|
+
timeout_env = os.getenv('ALFRESCO_TIMEOUT')
|
|
66
|
+
if timeout_env:
|
|
67
|
+
try:
|
|
68
|
+
resolved_timeout = int(timeout_env)
|
|
69
|
+
except ValueError:
|
|
70
|
+
resolved_timeout = None # Invalid value, don't set timeout
|
|
71
|
+
else:
|
|
72
|
+
resolved_timeout = None # No timeout specified, let system/client defaults apply
|
|
73
|
+
|
|
74
|
+
# Handle SSL verification (supports bool or certificate path like raw client)
|
|
75
|
+
if verify_ssl is not None:
|
|
76
|
+
resolved_verify_ssl = verify_ssl
|
|
77
|
+
else:
|
|
78
|
+
ssl_env = os.getenv('ALFRESCO_VERIFY_SSL') or os.getenv('ALFRESCO_SSL_VERIFY') or 'true'
|
|
79
|
+
# Support raw client SSL modes: True, False, or certificate path
|
|
80
|
+
if ssl_env.lower() in ('false', '0', 'no', 'off'):
|
|
81
|
+
resolved_verify_ssl = False
|
|
82
|
+
elif ssl_env.lower() in ('true', '1', 'yes', 'on'):
|
|
83
|
+
resolved_verify_ssl = True
|
|
84
|
+
else:
|
|
85
|
+
# Assume it's a certificate path
|
|
86
|
+
resolved_verify_ssl = ssl_env
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
'base_url': resolved_base_url,
|
|
90
|
+
'username': resolved_username,
|
|
91
|
+
'password': resolved_password,
|
|
92
|
+
'verify_ssl': resolved_verify_ssl,
|
|
93
|
+
'timeout': resolved_timeout
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class SimpleAuthUtil:
|
|
97
|
+
"""
|
|
98
|
+
Simple authentication utility using Basic authentication.
|
|
99
|
+
|
|
100
|
+
This is the proven working implementation that the MCP server uses.
|
|
101
|
+
Provides reliable Basic authentication for inheritance-based clients.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, username: str, password: str):
|
|
105
|
+
"""
|
|
106
|
+
Initialize simple auth utility.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
username: Alfresco username
|
|
110
|
+
password: Alfresco password
|
|
111
|
+
"""
|
|
112
|
+
self.username = username
|
|
113
|
+
self.password = password
|
|
114
|
+
|
|
115
|
+
def get_basic_auth_header(self):
|
|
116
|
+
"""Get basic auth header for HTTP requests."""
|
|
117
|
+
auth_string = f"{self.username}:{self.password}"
|
|
118
|
+
auth_b64 = base64.b64encode(auth_string.encode()).decode()
|
|
119
|
+
return f'Basic {auth_b64}'
|
|
120
|
+
|
|
121
|
+
def get_auth_token(self):
|
|
122
|
+
"""Get just the base64 auth token (without 'Basic ' prefix) for AuthenticatedClient."""
|
|
123
|
+
auth_string = f"{self.username}:{self.password}"
|
|
124
|
+
return base64.b64encode(auth_string.encode()).decode()
|
|
125
|
+
|
|
126
|
+
def get_auth_prefix(self):
|
|
127
|
+
"""Get the authentication prefix for this auth type."""
|
|
128
|
+
return "Basic"
|
|
129
|
+
|
|
130
|
+
def is_authenticated(self):
|
|
131
|
+
return True # Simple auth is always "authenticated"
|
|
132
|
+
|
|
133
|
+
async def ensure_authenticated(self):
|
|
134
|
+
return True # Simple auth is always ready
|
|
135
|
+
|
|
136
|
+
class AuthUtil:
|
|
137
|
+
"""
|
|
138
|
+
Shared authentication utility for Alfresco APIs.
|
|
139
|
+
|
|
140
|
+
Handles ticket-based authentication with automatic renewal.
|
|
141
|
+
Can be shared across multiple API clients.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
base_url: str,
|
|
147
|
+
username: str,
|
|
148
|
+
password: str,
|
|
149
|
+
verify_ssl: Union[bool, str] = True,
|
|
150
|
+
timeout: Optional[int] = None
|
|
151
|
+
):
|
|
152
|
+
"""
|
|
153
|
+
Initialize authentication utility.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
base_url: Base URL of Alfresco instance
|
|
157
|
+
username: Alfresco username
|
|
158
|
+
password: Alfresco password
|
|
159
|
+
verify_ssl: SSL verification - True, False, or path to certificate bundle
|
|
160
|
+
timeout: Request timeout in seconds (None = use system defaults)
|
|
161
|
+
"""
|
|
162
|
+
self.base_url = base_url.rstrip('/')
|
|
163
|
+
self.username = username
|
|
164
|
+
self.password = password
|
|
165
|
+
self.verify_ssl = verify_ssl
|
|
166
|
+
self.timeout = timeout
|
|
167
|
+
|
|
168
|
+
self.ticket = None
|
|
169
|
+
self.ticket_expires = None
|
|
170
|
+
self._authenticated = False
|
|
171
|
+
|
|
172
|
+
async def authenticate(self) -> bool:
|
|
173
|
+
"""
|
|
174
|
+
Authenticate with Alfresco and get ticket using direct HTTP request.
|
|
175
|
+
|
|
176
|
+
This uses the WORKING authentication method discovered in test_working_api.py.
|
|
177
|
+
Uses direct HTTP requests instead of raw clients to avoid header auth issues.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if authentication successful, False otherwise
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
# Use direct HTTP approach like the working test code
|
|
184
|
+
auth_url = f"{self.base_url}/alfresco/api/-default-/public/authentication/versions/1/tickets"
|
|
185
|
+
auth_data = {"userId": self.username, "password": self.password}
|
|
186
|
+
|
|
187
|
+
# Create httpx client with timeout only if specified
|
|
188
|
+
client_kwargs = {"verify": self.verify_ssl}
|
|
189
|
+
if self.timeout is not None:
|
|
190
|
+
client_kwargs["timeout"] = self.timeout
|
|
191
|
+
|
|
192
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
193
|
+
response = await client.post(auth_url, json=auth_data)
|
|
194
|
+
|
|
195
|
+
if response.status_code == 201:
|
|
196
|
+
ticket_data = response.json()
|
|
197
|
+
self.ticket = ticket_data["entry"]["id"]
|
|
198
|
+
# Tickets typically expire after 1 hour
|
|
199
|
+
self.ticket_expires = datetime.now() + timedelta(hours=1)
|
|
200
|
+
self._authenticated = True
|
|
201
|
+
return True
|
|
202
|
+
else:
|
|
203
|
+
print(f"Authentication failed with status {response.status_code}: {response.text}")
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f"Authentication failed: {e}")
|
|
207
|
+
self._authenticated = False
|
|
208
|
+
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def is_authenticated(self) -> bool:
|
|
212
|
+
"""Check if currently authenticated with valid ticket"""
|
|
213
|
+
if not self._authenticated or not self.ticket:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
if self.ticket_expires and datetime.now() >= self.ticket_expires:
|
|
217
|
+
self._authenticated = False
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
|
223
|
+
"""Get authentication headers for API requests"""
|
|
224
|
+
if not self.is_authenticated() or not self.ticket:
|
|
225
|
+
return {}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
"X-Alfresco-Ticket": self.ticket
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async def ensure_authenticated(self) -> bool:
|
|
232
|
+
"""Ensure we have valid authentication, refresh if needed"""
|
|
233
|
+
if self.is_authenticated():
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
return await self.authenticate()
|
|
237
|
+
|
|
238
|
+
def get_basic_auth_header(self):
|
|
239
|
+
"""Get basic auth header for compatibility with SimpleAuthUtil interface"""
|
|
240
|
+
auth_string = f"{self.username}:{self.password}"
|
|
241
|
+
auth_b64 = base64.b64encode(auth_string.encode()).decode()
|
|
242
|
+
return f'Basic {auth_b64}'
|
|
243
|
+
|
|
244
|
+
def get_auth_token(self):
|
|
245
|
+
"""Get just the base64 auth token (without 'Basic ' prefix) for AuthenticatedClient."""
|
|
246
|
+
auth_string = f"{self.username}:{self.password}"
|
|
247
|
+
return base64.b64encode(auth_string.encode()).decode()
|
|
248
|
+
|
|
249
|
+
def get_auth_prefix(self):
|
|
250
|
+
"""Get authentication prefix for headers. AuthUtil uses Basic auth."""
|
|
251
|
+
return "Basic"
|
|
252
|
+
|
|
253
|
+
def add_auth_params(self, url: str) -> str:
|
|
254
|
+
"""
|
|
255
|
+
Add authentication parameters to URL (query parameter method).
|
|
256
|
+
|
|
257
|
+
This is the WORKING authentication method for this Alfresco instance.
|
|
258
|
+
Uses alf_ticket={ticket} as query parameter instead of headers.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
url: Base URL to add authentication to
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
URL with authentication parameters added
|
|
265
|
+
"""
|
|
266
|
+
if not self.is_authenticated() or not self.ticket:
|
|
267
|
+
# No authentication available, return original URL
|
|
268
|
+
return url
|
|
269
|
+
|
|
270
|
+
# Add ticket as query parameter
|
|
271
|
+
separator = "&" if "?" in url else "?"
|
|
272
|
+
return f"{url}{separator}alf_ticket={self.ticket}"
|
|
273
|
+
|
|
274
|
+
class TicketAuthUtil:
|
|
275
|
+
"""
|
|
276
|
+
Enhanced authentication utility with direct ticket support.
|
|
277
|
+
Handles both Basic auth and ticket-based authentication.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self, username: str = "", password: str = "", base_url: str = "",
|
|
281
|
+
verify_ssl: Union[bool, str] = True, timeout: Optional[int] = None,
|
|
282
|
+
ticket: Optional[str] = None):
|
|
283
|
+
"""
|
|
284
|
+
Initialize ticket auth utility.
|
|
285
|
+
|
|
286
|
+
Two modes:
|
|
287
|
+
- Acquire: pass username/password (+ base_url) and a ticket is fetched on demand.
|
|
288
|
+
- Pass-through: pass an existing `ticket` obtained elsewhere (e.g. one an ADF
|
|
289
|
+
front end already holds). No username/password is needed or used, and the
|
|
290
|
+
ticket is never re-fetched, because there are no credentials to re-fetch with.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
username: Alfresco username (acquire mode)
|
|
294
|
+
password: Alfresco password (acquire mode)
|
|
295
|
+
base_url: Base URL for Alfresco (required to self-fetch a ticket)
|
|
296
|
+
verify_ssl: SSL verification - True, False, or path to certificate bundle
|
|
297
|
+
timeout: Request timeout in seconds (None = use system defaults)
|
|
298
|
+
ticket: A pre-obtained Alfresco login ticket (pass-through mode)
|
|
299
|
+
"""
|
|
300
|
+
self.username = username
|
|
301
|
+
self.password = password
|
|
302
|
+
self.base_url = base_url.rstrip('/')
|
|
303
|
+
self.verify_ssl = verify_ssl
|
|
304
|
+
self.timeout = timeout
|
|
305
|
+
self.ticket = ticket or None
|
|
306
|
+
self.ticket_expires = None
|
|
307
|
+
# A supplied ticket is trusted until Alfresco rejects it. Its remaining lifetime is
|
|
308
|
+
# unknown (the caller acquired it, not us), so ticket_expires stays None rather than
|
|
309
|
+
# guessing an hour from now and expiring a ticket that is still good.
|
|
310
|
+
self._supplied_ticket = bool(ticket)
|
|
311
|
+
self._authenticated = bool(ticket)
|
|
312
|
+
|
|
313
|
+
def authenticate_sync(self) -> bool:
|
|
314
|
+
"""Synchronously fetch an Alfresco login ticket from username/password."""
|
|
315
|
+
if self._supplied_ticket:
|
|
316
|
+
# Nothing to fetch with. Re-fetching would need credentials we were never given,
|
|
317
|
+
# and falling back to basic auth would silently authenticate as someone else.
|
|
318
|
+
return bool(self.ticket)
|
|
319
|
+
if not self.base_url:
|
|
320
|
+
return False
|
|
321
|
+
try:
|
|
322
|
+
auth_url = f"{self.base_url}/alfresco/api/-default-/public/authentication/versions/1/tickets"
|
|
323
|
+
client_kwargs = {"verify": self.verify_ssl}
|
|
324
|
+
if self.timeout is not None:
|
|
325
|
+
client_kwargs["timeout"] = self.timeout
|
|
326
|
+
with httpx.Client(**client_kwargs) as client:
|
|
327
|
+
response = client.post(auth_url, json={"userId": self.username, "password": self.password})
|
|
328
|
+
if response.status_code == 201:
|
|
329
|
+
self.ticket = response.json()["entry"]["id"]
|
|
330
|
+
self.ticket_expires = datetime.now() + timedelta(hours=1)
|
|
331
|
+
self._authenticated = True
|
|
332
|
+
return True
|
|
333
|
+
print(f"Alfresco ticket authentication failed: {response.status_code} {response.text}")
|
|
334
|
+
except Exception as e:
|
|
335
|
+
print(f"Alfresco ticket authentication failed: {e}")
|
|
336
|
+
self._authenticated = False
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
def ensure_authenticated_sync(self) -> bool:
|
|
340
|
+
"""Ensure a valid ticket, fetching one if needed."""
|
|
341
|
+
if self.is_authenticated():
|
|
342
|
+
return True
|
|
343
|
+
return self.authenticate_sync()
|
|
344
|
+
|
|
345
|
+
def get_basic_auth_header(self):
|
|
346
|
+
"""Get basic auth header for initial authentication."""
|
|
347
|
+
if self._supplied_ticket:
|
|
348
|
+
# There are no credentials in pass-through mode; the ticket is the credential.
|
|
349
|
+
return f'Basic {base64.b64encode(self.ticket.encode()).decode()}'
|
|
350
|
+
auth_string = f"{self.username}:{self.password}"
|
|
351
|
+
auth_b64 = base64.b64encode(auth_string.encode()).decode()
|
|
352
|
+
return f'Basic {auth_b64}'
|
|
353
|
+
|
|
354
|
+
def get_auth_token(self):
|
|
355
|
+
"""Get the base64 token (no 'Basic ' prefix) for AuthenticatedClient.
|
|
356
|
+
|
|
357
|
+
Alfresco accepts a login ticket as `Authorization: Basic base64(<ticket>)`. We lazily
|
|
358
|
+
fetch a ticket (when base_url is set) and return base64(ticket); if that fails we fall
|
|
359
|
+
back to base64(username:password) so the client still authenticates.
|
|
360
|
+
"""
|
|
361
|
+
if not self.is_authenticated() and self.base_url and not self._supplied_ticket:
|
|
362
|
+
try:
|
|
363
|
+
self.ensure_authenticated_sync()
|
|
364
|
+
except Exception as e:
|
|
365
|
+
print(f"Alfresco ticket acquisition failed: {e}")
|
|
366
|
+
if self.is_authenticated() and self.ticket:
|
|
367
|
+
return base64.b64encode(self.ticket.encode()).decode()
|
|
368
|
+
if self._supplied_ticket:
|
|
369
|
+
# Pass-through mode with no usable ticket: there is no credential to fall back to.
|
|
370
|
+
raise ValueError("Alfresco ticket authentication failed: supplied ticket is empty")
|
|
371
|
+
# Fallback: basic credentials
|
|
372
|
+
return base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
|
|
373
|
+
|
|
374
|
+
def get_ticket_header(self):
|
|
375
|
+
"""Get ticket auth header if we have a ticket."""
|
|
376
|
+
if self.ticket:
|
|
377
|
+
return f'Basic {base64.b64encode(self.ticket.encode()).decode()}'
|
|
378
|
+
return self.get_basic_auth_header()
|
|
379
|
+
|
|
380
|
+
def get_auth_header(self):
|
|
381
|
+
"""Get the appropriate auth header (ticket preferred, basic fallback)."""
|
|
382
|
+
return self.get_ticket_header()
|
|
383
|
+
|
|
384
|
+
def get_auth_prefix(self):
|
|
385
|
+
"""Get authentication prefix for headers. TicketAuthUtil uses Basic auth."""
|
|
386
|
+
return "Basic"
|
|
387
|
+
|
|
388
|
+
def store_ticket_from_response(self, response_data):
|
|
389
|
+
"""
|
|
390
|
+
Store ticket from authentication response.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
response_data: Response from create_ticket API call
|
|
394
|
+
"""
|
|
395
|
+
if hasattr(response_data, 'entry') and hasattr(response_data.entry, 'id'):
|
|
396
|
+
self.ticket = response_data.entry.id
|
|
397
|
+
self._authenticated = True
|
|
398
|
+
elif isinstance(response_data, dict) and 'entry' in response_data:
|
|
399
|
+
if 'id' in response_data['entry']:
|
|
400
|
+
self.ticket = response_data['entry']['id']
|
|
401
|
+
self._authenticated = True
|
|
402
|
+
|
|
403
|
+
def is_authenticated(self):
|
|
404
|
+
"""Check if we have a valid (unexpired) ticket."""
|
|
405
|
+
if not self._authenticated or not self.ticket:
|
|
406
|
+
return False
|
|
407
|
+
if self.ticket_expires and datetime.now() >= self.ticket_expires:
|
|
408
|
+
self._authenticated = False
|
|
409
|
+
return False
|
|
410
|
+
return True
|
|
411
|
+
|
|
412
|
+
class OAuth2AuthUtil:
|
|
413
|
+
"""
|
|
414
|
+
OAuth2 authentication utility for enterprise environments.
|
|
415
|
+
|
|
416
|
+
Supports multiple OAuth2 flows while maintaining the same interface
|
|
417
|
+
as other auth utilities for seamless integration.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
def __init__(
|
|
421
|
+
self,
|
|
422
|
+
base_url: str,
|
|
423
|
+
client_id: str,
|
|
424
|
+
client_secret: Optional[str] = None,
|
|
425
|
+
token_endpoint: Optional[str] = None,
|
|
426
|
+
authorization_endpoint: Optional[str] = None,
|
|
427
|
+
# OAuth2 flow configuration
|
|
428
|
+
grant_type: str = "client_credentials", # or "authorization_code"
|
|
429
|
+
scope: Optional[str] = None,
|
|
430
|
+
redirect_uri: Optional[str] = None,
|
|
431
|
+
# Token management
|
|
432
|
+
access_token: Optional[str] = None,
|
|
433
|
+
refresh_token: Optional[str] = None,
|
|
434
|
+
# Standard auth util parameters
|
|
435
|
+
verify_ssl: Union[bool, str] = True,
|
|
436
|
+
timeout: Optional[int] = None,
|
|
437
|
+
# Environment loading
|
|
438
|
+
load_env: bool = True,
|
|
439
|
+
env_file: Optional[str] = None
|
|
440
|
+
):
|
|
441
|
+
"""
|
|
442
|
+
Initialize OAuth2 authentication utility.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
base_url: Alfresco server base URL
|
|
446
|
+
client_id: OAuth2 client identifier
|
|
447
|
+
client_secret: OAuth2 client secret (for confidential clients)
|
|
448
|
+
token_endpoint: OAuth2 token endpoint URL
|
|
449
|
+
authorization_endpoint: OAuth2 authorization endpoint URL (for auth code flow)
|
|
450
|
+
grant_type: OAuth2 grant type ("client_credentials", "authorization_code", "refresh_token")
|
|
451
|
+
scope: Requested OAuth2 scopes
|
|
452
|
+
redirect_uri: Redirect URI for authorization code flow
|
|
453
|
+
access_token: Existing access token (optional)
|
|
454
|
+
refresh_token: Existing refresh token (optional)
|
|
455
|
+
verify_ssl: SSL verification - True, False, or path to certificate bundle
|
|
456
|
+
timeout: Request timeout in seconds (None = use system defaults)
|
|
457
|
+
load_env: Whether to load configuration from environment
|
|
458
|
+
env_file: Optional path to .env file
|
|
459
|
+
"""
|
|
460
|
+
# Load configuration from environment if needed
|
|
461
|
+
config = load_env_config(
|
|
462
|
+
base_url=base_url,
|
|
463
|
+
username=None, # Not used for OAuth2
|
|
464
|
+
password=None, # Not used for OAuth2
|
|
465
|
+
verify_ssl=verify_ssl,
|
|
466
|
+
load_env=load_env,
|
|
467
|
+
env_file=env_file
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
self.base_url = config['base_url'].rstrip('/')
|
|
471
|
+
self.client_id = client_id
|
|
472
|
+
self.client_secret = client_secret
|
|
473
|
+
self.verify_ssl = config['verify_ssl']
|
|
474
|
+
self.timeout = timeout
|
|
475
|
+
|
|
476
|
+
# OAuth2 flow configuration
|
|
477
|
+
self.grant_type = grant_type
|
|
478
|
+
self.scope = scope
|
|
479
|
+
self.redirect_uri = redirect_uri
|
|
480
|
+
|
|
481
|
+
# Token endpoints - smart defaults for common providers
|
|
482
|
+
self.token_endpoint = token_endpoint or self._detect_token_endpoint()
|
|
483
|
+
self.authorization_endpoint = authorization_endpoint
|
|
484
|
+
|
|
485
|
+
# Token state
|
|
486
|
+
self.access_token = access_token
|
|
487
|
+
self.refresh_token = refresh_token
|
|
488
|
+
self.token_expires = None
|
|
489
|
+
self._authenticated = False
|
|
490
|
+
|
|
491
|
+
# For environment variable loading
|
|
492
|
+
self._load_oauth_env_config(load_env, env_file)
|
|
493
|
+
|
|
494
|
+
# If an access token was supplied (constructor or env), mark it usable so the
|
|
495
|
+
# synchronous client-build path can use it immediately. Previously a provided token
|
|
496
|
+
# was ignored because _authenticated stayed False.
|
|
497
|
+
if self.access_token:
|
|
498
|
+
self._authenticated = True
|
|
499
|
+
# Derive expiry from the token's own JWT `exp` claim when no expires_in was given,
|
|
500
|
+
# so a *provided* (pasted) token that has since expired is detected as stale and
|
|
501
|
+
# refreshed via refresh_token — instead of being used blindly and 401ing.
|
|
502
|
+
if self.token_expires is None:
|
|
503
|
+
self.token_expires = self._expiry_from_jwt(self.access_token)
|
|
504
|
+
|
|
505
|
+
def _load_oauth_env_config(self, load_env: bool, env_file: Optional[str]):
|
|
506
|
+
"""Load OAuth2-specific configuration from environment."""
|
|
507
|
+
if not load_env:
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
if DOTENV_AVAILABLE:
|
|
511
|
+
if env_file:
|
|
512
|
+
from dotenv import load_dotenv
|
|
513
|
+
load_dotenv(env_file)
|
|
514
|
+
else:
|
|
515
|
+
from dotenv import load_dotenv
|
|
516
|
+
load_dotenv()
|
|
517
|
+
|
|
518
|
+
# Load OAuth2 environment variables
|
|
519
|
+
self.client_id = self.client_id or os.getenv('OAUTH2_CLIENT_ID') or os.getenv('ALFRESCO_OAUTH2_CLIENT_ID')
|
|
520
|
+
self.client_secret = self.client_secret or os.getenv('OAUTH2_CLIENT_SECRET') or os.getenv('ALFRESCO_OAUTH2_CLIENT_SECRET')
|
|
521
|
+
self.token_endpoint = self.token_endpoint or os.getenv('OAUTH2_TOKEN_ENDPOINT') or os.getenv('ALFRESCO_OAUTH2_TOKEN_ENDPOINT')
|
|
522
|
+
self.scope = self.scope or os.getenv('OAUTH2_SCOPE') or os.getenv('ALFRESCO_OAUTH2_SCOPE')
|
|
523
|
+
|
|
524
|
+
# Existing tokens from environment
|
|
525
|
+
self.access_token = self.access_token or os.getenv('OAUTH2_ACCESS_TOKEN') or os.getenv('ALFRESCO_OAUTH2_ACCESS_TOKEN')
|
|
526
|
+
self.refresh_token = self.refresh_token or os.getenv('OAUTH2_REFRESH_TOKEN') or os.getenv('ALFRESCO_OAUTH2_REFRESH_TOKEN')
|
|
527
|
+
|
|
528
|
+
def _detect_token_endpoint(self) -> Optional[str]:
|
|
529
|
+
"""Smart detection of token endpoints for common providers."""
|
|
530
|
+
if not self.base_url:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
# Common Alfresco OAuth2 patterns
|
|
534
|
+
alfresco_patterns = [
|
|
535
|
+
f"{self.base_url}/alfresco/service/oauth2/token",
|
|
536
|
+
f"{self.base_url}/auth/oauth/token",
|
|
537
|
+
f"{self.base_url}/oauth2/token"
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
# For cloud/enterprise environments, often separate auth servers
|
|
541
|
+
if "cloud" in self.base_url.lower() or "enterprise" in self.base_url.lower():
|
|
542
|
+
return None # Require explicit configuration
|
|
543
|
+
|
|
544
|
+
# Return most common pattern for on-premise
|
|
545
|
+
return alfresco_patterns[0]
|
|
546
|
+
|
|
547
|
+
async def authenticate(self) -> bool:
|
|
548
|
+
"""
|
|
549
|
+
Authenticate using OAuth2 flow and obtain access token.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
True if authentication successful, False otherwise
|
|
553
|
+
"""
|
|
554
|
+
try:
|
|
555
|
+
if self.grant_type == "client_credentials":
|
|
556
|
+
return await self._client_credentials_flow()
|
|
557
|
+
elif self.grant_type == "refresh_token" and self.refresh_token:
|
|
558
|
+
return await self._refresh_token_flow()
|
|
559
|
+
elif self.grant_type == "authorization_code":
|
|
560
|
+
# This typically requires user interaction, so we just validate existing token
|
|
561
|
+
return self.is_authenticated()
|
|
562
|
+
else:
|
|
563
|
+
raise ValueError(f"Unsupported grant type: {self.grant_type}")
|
|
564
|
+
|
|
565
|
+
except Exception as e:
|
|
566
|
+
print(f"OAuth2 authentication failed: {e}")
|
|
567
|
+
self._authenticated = False
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
async def _client_credentials_flow(self) -> bool:
|
|
571
|
+
"""Execute OAuth2 client credentials flow."""
|
|
572
|
+
if not self.client_id or not self.client_secret or not self.token_endpoint:
|
|
573
|
+
raise ValueError(
|
|
574
|
+
"Client credentials flow requires client_id, client_secret, and token_endpoint"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
data = {
|
|
578
|
+
"grant_type": "client_credentials",
|
|
579
|
+
"client_id": self.client_id,
|
|
580
|
+
"client_secret": self.client_secret
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if self.scope:
|
|
584
|
+
data["scope"] = self.scope
|
|
585
|
+
|
|
586
|
+
# Create httpx client with timeout only if specified
|
|
587
|
+
client_kwargs = {"verify": self.verify_ssl}
|
|
588
|
+
if self.timeout is not None:
|
|
589
|
+
client_kwargs["timeout"] = self.timeout
|
|
590
|
+
|
|
591
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
592
|
+
response = await client.post(
|
|
593
|
+
self.token_endpoint,
|
|
594
|
+
data=data,
|
|
595
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
if response.status_code == 200:
|
|
599
|
+
token_data = response.json()
|
|
600
|
+
self.access_token = token_data.get("access_token")
|
|
601
|
+
|
|
602
|
+
# Handle token expiration
|
|
603
|
+
expires_in = token_data.get("expires_in")
|
|
604
|
+
if expires_in:
|
|
605
|
+
self.token_expires = datetime.now() + timedelta(seconds=expires_in)
|
|
606
|
+
|
|
607
|
+
# Store refresh token if provided
|
|
608
|
+
if "refresh_token" in token_data:
|
|
609
|
+
self.refresh_token = token_data["refresh_token"]
|
|
610
|
+
|
|
611
|
+
self._authenticated = True
|
|
612
|
+
return True
|
|
613
|
+
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
async def _refresh_token_flow(self) -> bool:
|
|
617
|
+
"""Refresh access token using refresh token."""
|
|
618
|
+
if not self.refresh_token or not self.token_endpoint:
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
data = {
|
|
622
|
+
"grant_type": "refresh_token",
|
|
623
|
+
"refresh_token": self.refresh_token,
|
|
624
|
+
"client_id": self.client_id
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if self.client_secret:
|
|
628
|
+
data["client_secret"] = self.client_secret
|
|
629
|
+
|
|
630
|
+
# Create httpx client with timeout only if specified
|
|
631
|
+
client_kwargs = {"verify": self.verify_ssl}
|
|
632
|
+
if self.timeout is not None:
|
|
633
|
+
client_kwargs["timeout"] = self.timeout
|
|
634
|
+
|
|
635
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
636
|
+
response = await client.post(
|
|
637
|
+
self.token_endpoint,
|
|
638
|
+
data=data,
|
|
639
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if response.status_code == 200:
|
|
643
|
+
token_data = response.json()
|
|
644
|
+
self.access_token = token_data.get("access_token")
|
|
645
|
+
|
|
646
|
+
# Update expiration
|
|
647
|
+
expires_in = token_data.get("expires_in")
|
|
648
|
+
if expires_in:
|
|
649
|
+
self.token_expires = datetime.now() + timedelta(seconds=expires_in)
|
|
650
|
+
|
|
651
|
+
# Update refresh token if provided
|
|
652
|
+
if "refresh_token" in token_data:
|
|
653
|
+
self.refresh_token = token_data["refresh_token"]
|
|
654
|
+
|
|
655
|
+
self._authenticated = True
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
return False
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def _expiry_from_jwt(token: str) -> Optional[datetime]:
|
|
662
|
+
"""Best-effort read of a JWT access token's `exp` claim as a local naive datetime.
|
|
663
|
+
|
|
664
|
+
Lets a provided access token (which carries no expires_in) still be expiry-checked so
|
|
665
|
+
it can be refreshed when stale. Returns None for opaque (non-JWT) tokens or on any error.
|
|
666
|
+
"""
|
|
667
|
+
try:
|
|
668
|
+
parts = token.split(".")
|
|
669
|
+
if len(parts) != 3:
|
|
670
|
+
return None
|
|
671
|
+
payload_b64 = parts[1]
|
|
672
|
+
payload_b64 += "=" * (-len(payload_b64) % 4) # restore base64 padding
|
|
673
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
674
|
+
exp = payload.get("exp")
|
|
675
|
+
return datetime.fromtimestamp(exp) if exp else None
|
|
676
|
+
except Exception:
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
def _apply_token_response(self, token_data: Dict[str, Any]) -> None:
|
|
680
|
+
"""Apply an OAuth2 token-endpoint response to this util's token state."""
|
|
681
|
+
self.access_token = token_data.get("access_token")
|
|
682
|
+
expires_in = token_data.get("expires_in")
|
|
683
|
+
if expires_in:
|
|
684
|
+
self.token_expires = datetime.now() + timedelta(seconds=expires_in)
|
|
685
|
+
elif self.access_token:
|
|
686
|
+
self.token_expires = self._expiry_from_jwt(self.access_token)
|
|
687
|
+
if "refresh_token" in token_data:
|
|
688
|
+
self.refresh_token = token_data["refresh_token"]
|
|
689
|
+
self._authenticated = bool(self.access_token)
|
|
690
|
+
|
|
691
|
+
# --- Synchronous token acquisition ---------------------------------------
|
|
692
|
+
# The sub-clients call get_auth_token() synchronously at client-build time, so the async
|
|
693
|
+
# authenticate()/ensure_authenticated() above never fire on their own. These sync variants
|
|
694
|
+
# let get_auth_token() lazily acquire/refresh a token so OAuth2 works end-to-end without the
|
|
695
|
+
# caller having to pre-await authenticate().
|
|
696
|
+
|
|
697
|
+
def _client_credentials_flow_sync(self) -> bool:
|
|
698
|
+
"""Synchronous OAuth2 client-credentials flow."""
|
|
699
|
+
if not self.client_id or not self.client_secret or not self.token_endpoint:
|
|
700
|
+
raise ValueError(
|
|
701
|
+
"Client credentials flow requires client_id, client_secret, and token_endpoint"
|
|
702
|
+
)
|
|
703
|
+
data = {
|
|
704
|
+
"grant_type": "client_credentials",
|
|
705
|
+
"client_id": self.client_id,
|
|
706
|
+
"client_secret": self.client_secret,
|
|
707
|
+
}
|
|
708
|
+
if self.scope:
|
|
709
|
+
data["scope"] = self.scope
|
|
710
|
+
client_kwargs = {"verify": self.verify_ssl}
|
|
711
|
+
if self.timeout is not None:
|
|
712
|
+
client_kwargs["timeout"] = self.timeout
|
|
713
|
+
with httpx.Client(**client_kwargs) as client:
|
|
714
|
+
response = client.post(
|
|
715
|
+
self.token_endpoint, data=data,
|
|
716
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
717
|
+
)
|
|
718
|
+
if response.status_code == 200:
|
|
719
|
+
self._apply_token_response(response.json())
|
|
720
|
+
return True
|
|
721
|
+
return False
|
|
722
|
+
|
|
723
|
+
def _refresh_token_flow_sync(self) -> bool:
|
|
724
|
+
"""Synchronous refresh-token flow."""
|
|
725
|
+
if not self.refresh_token or not self.token_endpoint:
|
|
726
|
+
return False
|
|
727
|
+
data = {
|
|
728
|
+
"grant_type": "refresh_token",
|
|
729
|
+
"refresh_token": self.refresh_token,
|
|
730
|
+
"client_id": self.client_id,
|
|
731
|
+
}
|
|
732
|
+
if self.client_secret:
|
|
733
|
+
data["client_secret"] = self.client_secret
|
|
734
|
+
client_kwargs = {"verify": self.verify_ssl}
|
|
735
|
+
if self.timeout is not None:
|
|
736
|
+
client_kwargs["timeout"] = self.timeout
|
|
737
|
+
with httpx.Client(**client_kwargs) as client:
|
|
738
|
+
response = client.post(
|
|
739
|
+
self.token_endpoint, data=data,
|
|
740
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
741
|
+
)
|
|
742
|
+
if response.status_code == 200:
|
|
743
|
+
self._apply_token_response(response.json())
|
|
744
|
+
return True
|
|
745
|
+
return False
|
|
746
|
+
|
|
747
|
+
def authenticate_sync(self) -> bool:
|
|
748
|
+
"""Synchronous authenticate() — dispatches by grant type."""
|
|
749
|
+
try:
|
|
750
|
+
if self.grant_type == "client_credentials":
|
|
751
|
+
return self._client_credentials_flow_sync()
|
|
752
|
+
elif self.grant_type == "refresh_token" and self.refresh_token:
|
|
753
|
+
return self._refresh_token_flow_sync()
|
|
754
|
+
elif self.grant_type == "authorization_code":
|
|
755
|
+
# Requires prior user interaction; usable only if an access_token was supplied.
|
|
756
|
+
return self.is_authenticated()
|
|
757
|
+
else:
|
|
758
|
+
raise ValueError(f"Unsupported grant type: {self.grant_type}")
|
|
759
|
+
except Exception as e:
|
|
760
|
+
print(f"OAuth2 authentication failed: {e}")
|
|
761
|
+
self._authenticated = False
|
|
762
|
+
return False
|
|
763
|
+
|
|
764
|
+
def ensure_authenticated_sync(self) -> bool:
|
|
765
|
+
"""Synchronous ensure_authenticated() — refresh if possible, else authenticate."""
|
|
766
|
+
if self.is_authenticated():
|
|
767
|
+
return True
|
|
768
|
+
if self.refresh_token:
|
|
769
|
+
if self._refresh_token_flow_sync():
|
|
770
|
+
return True
|
|
771
|
+
return self.authenticate_sync()
|
|
772
|
+
|
|
773
|
+
def is_authenticated(self) -> bool:
|
|
774
|
+
"""Check if currently authenticated with valid access token."""
|
|
775
|
+
if not self._authenticated or not self.access_token:
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
# Check token expiration (30s skew so we refresh just before it actually expires)
|
|
779
|
+
if self.token_expires and datetime.now() >= (self.token_expires - timedelta(seconds=30)):
|
|
780
|
+
self._authenticated = False
|
|
781
|
+
return False
|
|
782
|
+
|
|
783
|
+
return True
|
|
784
|
+
|
|
785
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
|
786
|
+
"""Get authentication headers for API requests (lazily acquiring a token if needed)."""
|
|
787
|
+
if not self.is_authenticated():
|
|
788
|
+
try:
|
|
789
|
+
self.ensure_authenticated_sync()
|
|
790
|
+
except Exception as e:
|
|
791
|
+
print(f"OAuth2 token acquisition failed: {e}")
|
|
792
|
+
if not self.is_authenticated():
|
|
793
|
+
return {}
|
|
794
|
+
return {
|
|
795
|
+
"Authorization": f"Bearer {self.access_token}"
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
def get_basic_auth_header(self):
|
|
799
|
+
"""Compatibility method - returns Bearer token as auth header."""
|
|
800
|
+
if self.is_authenticated():
|
|
801
|
+
return f"Bearer {self.access_token}"
|
|
802
|
+
return ""
|
|
803
|
+
|
|
804
|
+
def get_auth_token(self):
|
|
805
|
+
"""Get the access token (no 'Bearer ' prefix) for AuthenticatedClient.
|
|
806
|
+
|
|
807
|
+
Lazily acquires/refreshes the token synchronously when needed, so the sub-clients'
|
|
808
|
+
synchronous build path produces a real Bearer header. Previously this returned "" unless
|
|
809
|
+
the caller had manually pre-awaited authenticate(), which nothing in the build path did.
|
|
810
|
+
"""
|
|
811
|
+
if not self.is_authenticated():
|
|
812
|
+
try:
|
|
813
|
+
self.ensure_authenticated_sync()
|
|
814
|
+
except Exception as e:
|
|
815
|
+
print(f"OAuth2 token acquisition failed: {e}")
|
|
816
|
+
return self.access_token if self.is_authenticated() else ""
|
|
817
|
+
|
|
818
|
+
def get_auth_prefix(self):
|
|
819
|
+
"""Get authentication prefix for headers. OAuth2AuthUtil uses Bearer tokens."""
|
|
820
|
+
return "Bearer"
|
|
821
|
+
|
|
822
|
+
async def ensure_authenticated(self) -> bool:
|
|
823
|
+
"""Ensure we have valid authentication, refresh if needed."""
|
|
824
|
+
if self.is_authenticated():
|
|
825
|
+
return True
|
|
826
|
+
|
|
827
|
+
# Try refresh token first if available
|
|
828
|
+
if self.refresh_token:
|
|
829
|
+
if await self._refresh_token_flow():
|
|
830
|
+
return True
|
|
831
|
+
|
|
832
|
+
# Fall back to main authentication flow
|
|
833
|
+
return await self.authenticate()
|
|
834
|
+
|
|
835
|
+
@classmethod
|
|
836
|
+
def from_env(
|
|
837
|
+
cls,
|
|
838
|
+
base_url: Optional[str] = None,
|
|
839
|
+
env_file: Optional[str] = None,
|
|
840
|
+
grant_type: str = "client_credentials",
|
|
841
|
+
**kwargs
|
|
842
|
+
):
|
|
843
|
+
"""
|
|
844
|
+
Create OAuth2AuthUtil from environment variables.
|
|
845
|
+
|
|
846
|
+
Expected environment variables:
|
|
847
|
+
- OAUTH2_CLIENT_ID or ALFRESCO_OAUTH2_CLIENT_ID
|
|
848
|
+
- OAUTH2_CLIENT_SECRET or ALFRESCO_OAUTH2_CLIENT_SECRET
|
|
849
|
+
- OAUTH2_TOKEN_ENDPOINT or ALFRESCO_OAUTH2_TOKEN_ENDPOINT
|
|
850
|
+
- OAUTH2_SCOPE or ALFRESCO_OAUTH2_SCOPE (optional)
|
|
851
|
+
"""
|
|
852
|
+
return cls(
|
|
853
|
+
base_url=base_url or os.getenv('ALFRESCO_URL') or os.getenv('ALFRESCO_BASE_URL') or 'http://localhost:8080',
|
|
854
|
+
client_id="", # Will be loaded from env
|
|
855
|
+
grant_type=grant_type,
|
|
856
|
+
load_env=True,
|
|
857
|
+
env_file=env_file,
|
|
858
|
+
**kwargs
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
def get_config_info(self) -> Dict[str, Any]:
|
|
862
|
+
"""Get configuration information for debugging (without sensitive data)."""
|
|
863
|
+
return {
|
|
864
|
+
"base_url": self.base_url,
|
|
865
|
+
"client_id": self.client_id[:8] + "..." if self.client_id else None,
|
|
866
|
+
"client_secret": "***" if self.client_secret else None,
|
|
867
|
+
"token_endpoint": self.token_endpoint,
|
|
868
|
+
"grant_type": self.grant_type,
|
|
869
|
+
"scope": self.scope,
|
|
870
|
+
"has_access_token": bool(self.access_token),
|
|
871
|
+
"has_refresh_token": bool(self.refresh_token),
|
|
872
|
+
"token_expires": self.token_expires.isoformat() if self.token_expires else None,
|
|
873
|
+
"is_authenticated": self.is_authenticated(),
|
|
874
|
+
"verify_ssl": self.verify_ssl
|
|
875
|
+
}
|