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.
@@ -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
- Initialize ticket auth utility.
284
-
285
- Args:
286
- username: Alfresco username
287
- password: Alfresco password
288
- base_url: Base URL for Alfresco (required to self-fetch a ticket)
289
- verify_ssl: SSL verification - True, False, or path to certificate bundle
290
- timeout: Request timeout in seconds (None = use system defaults)
291
- """
292
- self.username = username
293
- self.password = password
294
- self.base_url = base_url.rstrip('/')
295
- self.verify_ssl = verify_ssl
296
- self.timeout = timeout
297
- self.ticket = None
298
- self.ticket_expires = None
299
- self._authenticated = False
300
-
301
- def authenticate_sync(self) -> bool:
302
- """Synchronously fetch an Alfresco login ticket from username/password."""
303
- if not self.base_url:
304
- return False
305
- try:
306
- auth_url = f"{self.base_url}/alfresco/api/-default-/public/authentication/versions/1/tickets"
307
- client_kwargs = {"verify": self.verify_ssl}
308
- if self.timeout is not None:
309
- client_kwargs["timeout"] = self.timeout
310
- with httpx.Client(**client_kwargs) as client:
311
- response = client.post(auth_url, json={"userId": self.username, "password": self.password})
312
- if response.status_code == 201:
313
- self.ticket = response.json()["entry"]["id"]
314
- self.ticket_expires = datetime.now() + timedelta(hours=1)
315
- self._authenticated = True
316
- return True
317
- print(f"Alfresco ticket authentication failed: {response.status_code} {response.text}")
318
- except Exception as e:
319
- print(f"Alfresco ticket authentication failed: {e}")
320
- self._authenticated = False
321
- return False
322
-
323
- def ensure_authenticated_sync(self) -> bool:
324
- """Ensure a valid ticket, fetching one if needed."""
325
- if self.is_authenticated():
326
- return True
327
- return self.authenticate_sync()
328
-
329
- def get_basic_auth_header(self):
330
- """Get basic auth header for initial authentication."""
331
- auth_string = f"{self.username}:{self.password}"
332
- auth_b64 = base64.b64encode(auth_string.encode()).decode()
333
- return f'Basic {auth_b64}'
334
-
335
- def get_auth_token(self):
336
- """Get the base64 token (no 'Basic ' prefix) for AuthenticatedClient.
337
-
338
- Alfresco accepts a login ticket as `Authorization: Basic base64(<ticket>)`. We lazily
339
- fetch a ticket (when base_url is set) and return base64(ticket); if that fails we fall
340
- back to base64(username:password) so the client still authenticates.
341
- """
342
- if not self.is_authenticated() and self.base_url:
343
- try:
344
- self.ensure_authenticated_sync()
345
- except Exception as e:
346
- print(f"Alfresco ticket acquisition failed: {e}")
347
- if self.is_authenticated() and self.ticket:
348
- return base64.b64encode(self.ticket.encode()).decode()
349
- # Fallback: basic credentials
350
- return base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
351
-
352
- def get_ticket_header(self):
353
- """Get ticket auth header if we have a ticket."""
354
- if self.ticket:
355
- return f'Basic {base64.b64encode(self.ticket.encode()).decode()}'
356
- return self.get_basic_auth_header()
357
-
358
- def get_auth_header(self):
359
- """Get the appropriate auth header (ticket preferred, basic fallback)."""
360
- return self.get_ticket_header()
361
-
362
- def get_auth_prefix(self):
363
- """Get authentication prefix for headers. TicketAuthUtil uses Basic auth."""
364
- return "Basic"
365
-
366
- def store_ticket_from_response(self, response_data):
367
- """
368
- Store ticket from authentication response.
369
-
370
- Args:
371
- response_data: Response from create_ticket API call
372
- """
373
- if hasattr(response_data, 'entry') and hasattr(response_data.entry, 'id'):
374
- self.ticket = response_data.entry.id
375
- self._authenticated = True
376
- elif isinstance(response_data, dict) and 'entry' in response_data:
377
- if 'id' in response_data['entry']:
378
- self.ticket = response_data['entry']['id']
379
- self._authenticated = True
380
-
381
- def is_authenticated(self):
382
- """Check if we have a valid (unexpired) ticket."""
383
- if not self._authenticated or not self.ticket:
384
- return False
385
- if self.ticket_expires and datetime.now() >= self.ticket_expires:
386
- self._authenticated = False
387
- return False
388
- return True
389
-
390
- class OAuth2AuthUtil:
391
- """
392
- OAuth2 authentication utility for enterprise environments.
393
-
394
- Supports multiple OAuth2 flows while maintaining the same interface
395
- as other auth utilities for seamless integration.
396
- """
397
-
398
- def __init__(
399
- self,
400
- base_url: str,
401
- client_id: str,
402
- client_secret: Optional[str] = None,
403
- token_endpoint: Optional[str] = None,
404
- authorization_endpoint: Optional[str] = None,
405
- # OAuth2 flow configuration
406
- grant_type: str = "client_credentials", # or "authorization_code"
407
- scope: Optional[str] = None,
408
- redirect_uri: Optional[str] = None,
409
- # Token management
410
- access_token: Optional[str] = None,
411
- refresh_token: Optional[str] = None,
412
- # Standard auth util parameters
413
- verify_ssl: Union[bool, str] = True,
414
- timeout: Optional[int] = None,
415
- # Environment loading
416
- load_env: bool = True,
417
- env_file: Optional[str] = None
418
- ):
419
- """
420
- Initialize OAuth2 authentication utility.
421
-
422
- Args:
423
- base_url: Alfresco server base URL
424
- client_id: OAuth2 client identifier
425
- client_secret: OAuth2 client secret (for confidential clients)
426
- token_endpoint: OAuth2 token endpoint URL
427
- authorization_endpoint: OAuth2 authorization endpoint URL (for auth code flow)
428
- grant_type: OAuth2 grant type ("client_credentials", "authorization_code", "refresh_token")
429
- scope: Requested OAuth2 scopes
430
- redirect_uri: Redirect URI for authorization code flow
431
- access_token: Existing access token (optional)
432
- refresh_token: Existing refresh token (optional)
433
- verify_ssl: SSL verification - True, False, or path to certificate bundle
434
- timeout: Request timeout in seconds (None = use system defaults)
435
- load_env: Whether to load configuration from environment
436
- env_file: Optional path to .env file
437
- """
438
- # Load configuration from environment if needed
439
- config = load_env_config(
440
- base_url=base_url,
441
- username=None, # Not used for OAuth2
442
- password=None, # Not used for OAuth2
443
- verify_ssl=verify_ssl,
444
- load_env=load_env,
445
- env_file=env_file
446
- )
447
-
448
- self.base_url = config['base_url'].rstrip('/')
449
- self.client_id = client_id
450
- self.client_secret = client_secret
451
- self.verify_ssl = config['verify_ssl']
452
- self.timeout = timeout
453
-
454
- # OAuth2 flow configuration
455
- self.grant_type = grant_type
456
- self.scope = scope
457
- self.redirect_uri = redirect_uri
458
-
459
- # Token endpoints - smart defaults for common providers
460
- self.token_endpoint = token_endpoint or self._detect_token_endpoint()
461
- self.authorization_endpoint = authorization_endpoint
462
-
463
- # Token state
464
- self.access_token = access_token
465
- self.refresh_token = refresh_token
466
- self.token_expires = None
467
- self._authenticated = False
468
-
469
- # For environment variable loading
470
- self._load_oauth_env_config(load_env, env_file)
471
-
472
- # If an access token was supplied (constructor or env), mark it usable so the
473
- # synchronous client-build path can use it immediately. Previously a provided token
474
- # was ignored because _authenticated stayed False.
475
- if self.access_token:
476
- self._authenticated = True
477
- # Derive expiry from the token's own JWT `exp` claim when no expires_in was given,
478
- # so a *provided* (pasted) token that has since expired is detected as stale and
479
- # refreshed via refresh_token — instead of being used blindly and 401ing.
480
- if self.token_expires is None:
481
- self.token_expires = self._expiry_from_jwt(self.access_token)
482
-
483
- def _load_oauth_env_config(self, load_env: bool, env_file: Optional[str]):
484
- """Load OAuth2-specific configuration from environment."""
485
- if not load_env:
486
- return
487
-
488
- if DOTENV_AVAILABLE:
489
- if env_file:
490
- from dotenv import load_dotenv
491
- load_dotenv(env_file)
492
- else:
493
- from dotenv import load_dotenv
494
- load_dotenv()
495
-
496
- # Load OAuth2 environment variables
497
- self.client_id = self.client_id or os.getenv('OAUTH2_CLIENT_ID') or os.getenv('ALFRESCO_OAUTH2_CLIENT_ID')
498
- self.client_secret = self.client_secret or os.getenv('OAUTH2_CLIENT_SECRET') or os.getenv('ALFRESCO_OAUTH2_CLIENT_SECRET')
499
- self.token_endpoint = self.token_endpoint or os.getenv('OAUTH2_TOKEN_ENDPOINT') or os.getenv('ALFRESCO_OAUTH2_TOKEN_ENDPOINT')
500
- self.scope = self.scope or os.getenv('OAUTH2_SCOPE') or os.getenv('ALFRESCO_OAUTH2_SCOPE')
501
-
502
- # Existing tokens from environment
503
- self.access_token = self.access_token or os.getenv('OAUTH2_ACCESS_TOKEN') or os.getenv('ALFRESCO_OAUTH2_ACCESS_TOKEN')
504
- self.refresh_token = self.refresh_token or os.getenv('OAUTH2_REFRESH_TOKEN') or os.getenv('ALFRESCO_OAUTH2_REFRESH_TOKEN')
505
-
506
- def _detect_token_endpoint(self) -> Optional[str]:
507
- """Smart detection of token endpoints for common providers."""
508
- if not self.base_url:
509
- return None
510
-
511
- # Common Alfresco OAuth2 patterns
512
- alfresco_patterns = [
513
- f"{self.base_url}/alfresco/service/oauth2/token",
514
- f"{self.base_url}/auth/oauth/token",
515
- f"{self.base_url}/oauth2/token"
516
- ]
517
-
518
- # For cloud/enterprise environments, often separate auth servers
519
- if "cloud" in self.base_url.lower() or "enterprise" in self.base_url.lower():
520
- return None # Require explicit configuration
521
-
522
- # Return most common pattern for on-premise
523
- return alfresco_patterns[0]
524
-
525
- async def authenticate(self) -> bool:
526
- """
527
- Authenticate using OAuth2 flow and obtain access token.
528
-
529
- Returns:
530
- True if authentication successful, False otherwise
531
- """
532
- try:
533
- if self.grant_type == "client_credentials":
534
- return await self._client_credentials_flow()
535
- elif self.grant_type == "refresh_token" and self.refresh_token:
536
- return await self._refresh_token_flow()
537
- elif self.grant_type == "authorization_code":
538
- # This typically requires user interaction, so we just validate existing token
539
- return self.is_authenticated()
540
- else:
541
- raise ValueError(f"Unsupported grant type: {self.grant_type}")
542
-
543
- except Exception as e:
544
- print(f"OAuth2 authentication failed: {e}")
545
- self._authenticated = False
546
- return False
547
-
548
- async def _client_credentials_flow(self) -> bool:
549
- """Execute OAuth2 client credentials flow."""
550
- if not self.client_id or not self.client_secret or not self.token_endpoint:
551
- raise ValueError(
552
- "Client credentials flow requires client_id, client_secret, and token_endpoint"
553
- )
554
-
555
- data = {
556
- "grant_type": "client_credentials",
557
- "client_id": self.client_id,
558
- "client_secret": self.client_secret
559
- }
560
-
561
- if self.scope:
562
- data["scope"] = self.scope
563
-
564
- # Create httpx client with timeout only if specified
565
- client_kwargs = {"verify": self.verify_ssl}
566
- if self.timeout is not None:
567
- client_kwargs["timeout"] = self.timeout
568
-
569
- async with httpx.AsyncClient(**client_kwargs) as client:
570
- response = await client.post(
571
- self.token_endpoint,
572
- data=data,
573
- headers={"Content-Type": "application/x-www-form-urlencoded"}
574
- )
575
-
576
- if response.status_code == 200:
577
- token_data = response.json()
578
- self.access_token = token_data.get("access_token")
579
-
580
- # Handle token expiration
581
- expires_in = token_data.get("expires_in")
582
- if expires_in:
583
- self.token_expires = datetime.now() + timedelta(seconds=expires_in)
584
-
585
- # Store refresh token if provided
586
- if "refresh_token" in token_data:
587
- self.refresh_token = token_data["refresh_token"]
588
-
589
- self._authenticated = True
590
- return True
591
-
592
- return False
593
-
594
- async def _refresh_token_flow(self) -> bool:
595
- """Refresh access token using refresh token."""
596
- if not self.refresh_token or not self.token_endpoint:
597
- return False
598
-
599
- data = {
600
- "grant_type": "refresh_token",
601
- "refresh_token": self.refresh_token,
602
- "client_id": self.client_id
603
- }
604
-
605
- if self.client_secret:
606
- data["client_secret"] = self.client_secret
607
-
608
- # Create httpx client with timeout only if specified
609
- client_kwargs = {"verify": self.verify_ssl}
610
- if self.timeout is not None:
611
- client_kwargs["timeout"] = self.timeout
612
-
613
- async with httpx.AsyncClient(**client_kwargs) as client:
614
- response = await client.post(
615
- self.token_endpoint,
616
- data=data,
617
- headers={"Content-Type": "application/x-www-form-urlencoded"}
618
- )
619
-
620
- if response.status_code == 200:
621
- token_data = response.json()
622
- self.access_token = token_data.get("access_token")
623
-
624
- # Update expiration
625
- expires_in = token_data.get("expires_in")
626
- if expires_in:
627
- self.token_expires = datetime.now() + timedelta(seconds=expires_in)
628
-
629
- # Update refresh token if provided
630
- if "refresh_token" in token_data:
631
- self.refresh_token = token_data["refresh_token"]
632
-
633
- self._authenticated = True
634
- return True
635
-
636
- return False
637
-
638
- @staticmethod
639
- def _expiry_from_jwt(token: str) -> Optional[datetime]:
640
- """Best-effort read of a JWT access token's `exp` claim as a local naive datetime.
641
-
642
- Lets a provided access token (which carries no expires_in) still be expiry-checked so
643
- it can be refreshed when stale. Returns None for opaque (non-JWT) tokens or on any error.
644
- """
645
- try:
646
- parts = token.split(".")
647
- if len(parts) != 3:
648
- return None
649
- payload_b64 = parts[1]
650
- payload_b64 += "=" * (-len(payload_b64) % 4) # restore base64 padding
651
- payload = json.loads(base64.urlsafe_b64decode(payload_b64))
652
- exp = payload.get("exp")
653
- return datetime.fromtimestamp(exp) if exp else None
654
- except Exception:
655
- return None
656
-
657
- def _apply_token_response(self, token_data: Dict[str, Any]) -> None:
658
- """Apply an OAuth2 token-endpoint response to this util's token state."""
659
- self.access_token = token_data.get("access_token")
660
- expires_in = token_data.get("expires_in")
661
- if expires_in:
662
- self.token_expires = datetime.now() + timedelta(seconds=expires_in)
663
- elif self.access_token:
664
- self.token_expires = self._expiry_from_jwt(self.access_token)
665
- if "refresh_token" in token_data:
666
- self.refresh_token = token_data["refresh_token"]
667
- self._authenticated = bool(self.access_token)
668
-
669
- # --- Synchronous token acquisition ---------------------------------------
670
- # The sub-clients call get_auth_token() synchronously at client-build time, so the async
671
- # authenticate()/ensure_authenticated() above never fire on their own. These sync variants
672
- # let get_auth_token() lazily acquire/refresh a token so OAuth2 works end-to-end without the
673
- # caller having to pre-await authenticate().
674
-
675
- def _client_credentials_flow_sync(self) -> bool:
676
- """Synchronous OAuth2 client-credentials flow."""
677
- if not self.client_id or not self.client_secret or not self.token_endpoint:
678
- raise ValueError(
679
- "Client credentials flow requires client_id, client_secret, and token_endpoint"
680
- )
681
- data = {
682
- "grant_type": "client_credentials",
683
- "client_id": self.client_id,
684
- "client_secret": self.client_secret,
685
- }
686
- if self.scope:
687
- data["scope"] = self.scope
688
- client_kwargs = {"verify": self.verify_ssl}
689
- if self.timeout is not None:
690
- client_kwargs["timeout"] = self.timeout
691
- with httpx.Client(**client_kwargs) as client:
692
- response = client.post(
693
- self.token_endpoint, data=data,
694
- headers={"Content-Type": "application/x-www-form-urlencoded"},
695
- )
696
- if response.status_code == 200:
697
- self._apply_token_response(response.json())
698
- return True
699
- return False
700
-
701
- def _refresh_token_flow_sync(self) -> bool:
702
- """Synchronous refresh-token flow."""
703
- if not self.refresh_token or not self.token_endpoint:
704
- return False
705
- data = {
706
- "grant_type": "refresh_token",
707
- "refresh_token": self.refresh_token,
708
- "client_id": self.client_id,
709
- }
710
- if self.client_secret:
711
- data["client_secret"] = self.client_secret
712
- client_kwargs = {"verify": self.verify_ssl}
713
- if self.timeout is not None:
714
- client_kwargs["timeout"] = self.timeout
715
- with httpx.Client(**client_kwargs) as client:
716
- response = client.post(
717
- self.token_endpoint, data=data,
718
- headers={"Content-Type": "application/x-www-form-urlencoded"},
719
- )
720
- if response.status_code == 200:
721
- self._apply_token_response(response.json())
722
- return True
723
- return False
724
-
725
- def authenticate_sync(self) -> bool:
726
- """Synchronous authenticate() — dispatches by grant type."""
727
- try:
728
- if self.grant_type == "client_credentials":
729
- return self._client_credentials_flow_sync()
730
- elif self.grant_type == "refresh_token" and self.refresh_token:
731
- return self._refresh_token_flow_sync()
732
- elif self.grant_type == "authorization_code":
733
- # Requires prior user interaction; usable only if an access_token was supplied.
734
- return self.is_authenticated()
735
- else:
736
- raise ValueError(f"Unsupported grant type: {self.grant_type}")
737
- except Exception as e:
738
- print(f"OAuth2 authentication failed: {e}")
739
- self._authenticated = False
740
- return False
741
-
742
- def ensure_authenticated_sync(self) -> bool:
743
- """Synchronous ensure_authenticated() — refresh if possible, else authenticate."""
744
- if self.is_authenticated():
745
- return True
746
- if self.refresh_token:
747
- if self._refresh_token_flow_sync():
748
- return True
749
- return self.authenticate_sync()
750
-
751
- def is_authenticated(self) -> bool:
752
- """Check if currently authenticated with valid access token."""
753
- if not self._authenticated or not self.access_token:
754
- return False
755
-
756
- # Check token expiration (30s skew so we refresh just before it actually expires)
757
- if self.token_expires and datetime.now() >= (self.token_expires - timedelta(seconds=30)):
758
- self._authenticated = False
759
- return False
760
-
761
- return True
762
-
763
- def get_auth_headers(self) -> Dict[str, str]:
764
- """Get authentication headers for API requests (lazily acquiring a token if needed)."""
765
- if not self.is_authenticated():
766
- try:
767
- self.ensure_authenticated_sync()
768
- except Exception as e:
769
- print(f"OAuth2 token acquisition failed: {e}")
770
- if not self.is_authenticated():
771
- return {}
772
- return {
773
- "Authorization": f"Bearer {self.access_token}"
774
- }
775
-
776
- def get_basic_auth_header(self):
777
- """Compatibility method - returns Bearer token as auth header."""
778
- if self.is_authenticated():
779
- return f"Bearer {self.access_token}"
780
- return ""
781
-
782
- def get_auth_token(self):
783
- """Get the access token (no 'Bearer ' prefix) for AuthenticatedClient.
784
-
785
- Lazily acquires/refreshes the token synchronously when needed, so the sub-clients'
786
- synchronous build path produces a real Bearer header. Previously this returned "" unless
787
- the caller had manually pre-awaited authenticate(), which nothing in the build path did.
788
- """
789
- if not self.is_authenticated():
790
- try:
791
- self.ensure_authenticated_sync()
792
- except Exception as e:
793
- print(f"OAuth2 token acquisition failed: {e}")
794
- return self.access_token if self.is_authenticated() else ""
795
-
796
- def get_auth_prefix(self):
797
- """Get authentication prefix for headers. OAuth2AuthUtil uses Bearer tokens."""
798
- return "Bearer"
799
-
800
- async def ensure_authenticated(self) -> bool:
801
- """Ensure we have valid authentication, refresh if needed."""
802
- if self.is_authenticated():
803
- return True
804
-
805
- # Try refresh token first if available
806
- if self.refresh_token:
807
- if await self._refresh_token_flow():
808
- return True
809
-
810
- # Fall back to main authentication flow
811
- return await self.authenticate()
812
-
813
- @classmethod
814
- def from_env(
815
- cls,
816
- base_url: Optional[str] = None,
817
- env_file: Optional[str] = None,
818
- grant_type: str = "client_credentials",
819
- **kwargs
820
- ):
821
- """
822
- Create OAuth2AuthUtil from environment variables.
823
-
824
- Expected environment variables:
825
- - OAUTH2_CLIENT_ID or ALFRESCO_OAUTH2_CLIENT_ID
826
- - OAUTH2_CLIENT_SECRET or ALFRESCO_OAUTH2_CLIENT_SECRET
827
- - OAUTH2_TOKEN_ENDPOINT or ALFRESCO_OAUTH2_TOKEN_ENDPOINT
828
- - OAUTH2_SCOPE or ALFRESCO_OAUTH2_SCOPE (optional)
829
- """
830
- return cls(
831
- base_url=base_url or os.getenv('ALFRESCO_URL') or os.getenv('ALFRESCO_BASE_URL') or 'http://localhost:8080',
832
- client_id="", # Will be loaded from env
833
- grant_type=grant_type,
834
- load_env=True,
835
- env_file=env_file,
836
- **kwargs
837
- )
838
-
839
- def get_config_info(self) -> Dict[str, Any]:
840
- """Get configuration information for debugging (without sensitive data)."""
841
- return {
842
- "base_url": self.base_url,
843
- "client_id": self.client_id[:8] + "..." if self.client_id else None,
844
- "client_secret": "***" if self.client_secret else None,
845
- "token_endpoint": self.token_endpoint,
846
- "grant_type": self.grant_type,
847
- "scope": self.scope,
848
- "has_access_token": bool(self.access_token),
849
- "has_refresh_token": bool(self.refresh_token),
850
- "token_expires": self.token_expires.isoformat() if self.token_expires else None,
851
- "is_authenticated": self.is_authenticated(),
852
- "verify_ssl": self.verify_ssl
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
+ }