the37lab-authlib 0.1.1756367559__tar.gz → 0.1.1756730814__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of the37lab-authlib might be problematic. Click here for more details.
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/PKG-INFO +1 -1
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/pyproject.toml +1 -1
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/auth.py +358 -89
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/PKG-INFO +1 -1
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/README.md +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/setup.cfg +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/__init__.py +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/db.py +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/decorators.py +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/exceptions.py +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/models.py +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/SOURCES.txt +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/dependency_links.txt +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/requires.txt +0 -0
- {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "the37lab_authlib"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1756730814"
|
|
8
8
|
description = "Python SDK for the Authlib"
|
|
9
9
|
authors = [{name = "the37lab", email = "info@the37lab.com"}]
|
|
10
10
|
dependencies = ["flask", "psycopg2-binary", "pyjwt", "python-dotenv", "requests", "authlib", "bcrypt"]
|
{the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/auth.py
RENAMED
|
@@ -12,13 +12,25 @@ import logging
|
|
|
12
12
|
import os
|
|
13
13
|
from functools import wraps
|
|
14
14
|
from isodate import parse_duration
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import msal
|
|
15
18
|
|
|
16
19
|
logging.basicConfig(level=logging.DEBUG)
|
|
17
20
|
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
class AuthManager:
|
|
20
|
-
def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None, api_tokens=None):
|
|
23
|
+
def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None, api_tokens=None, cache_ttl=10, allow_oauth_auto_create=False):
|
|
21
24
|
self.user_override = None
|
|
25
|
+
self._user_cache = {}
|
|
26
|
+
self._cache_ttl = cache_ttl or 10 # 10 seconds
|
|
27
|
+
self._last_used_updates = {} # Track pending updates
|
|
28
|
+
self._update_lock = threading.Lock()
|
|
29
|
+
self._update_thread = None
|
|
30
|
+
self._shutdown_event = threading.Event()
|
|
31
|
+
# OAuth user creation policy (can be controlled by env)
|
|
32
|
+
self.allow_oauth_auto_create = allow_oauth_auto_create
|
|
33
|
+
|
|
22
34
|
if environment_prefix:
|
|
23
35
|
prefix = environment_prefix.upper() + '_'
|
|
24
36
|
db_dsn = os.getenv(f'{prefix}DATABASE_URL')
|
|
@@ -31,6 +43,10 @@ class AuthManager:
|
|
|
31
43
|
'client_id': google_client_id,
|
|
32
44
|
'client_secret': google_client_secret
|
|
33
45
|
}
|
|
46
|
+
# Allow control via prefixed env var (defaults to True)
|
|
47
|
+
auto_create_env = os.getenv(f'{prefix}OAUTH_ALLOW_AUTO_CREATE')
|
|
48
|
+
if auto_create_env is not None:
|
|
49
|
+
self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
|
|
34
50
|
api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
|
|
35
51
|
if api_tokens_env:
|
|
36
52
|
api_tokens = {}
|
|
@@ -65,6 +81,9 @@ class AuthManager:
|
|
|
65
81
|
|
|
66
82
|
if app:
|
|
67
83
|
self.init_app(app)
|
|
84
|
+
|
|
85
|
+
# Start the background update thread
|
|
86
|
+
self._start_update_thread()
|
|
68
87
|
|
|
69
88
|
def _extract_token_from_header(self):
|
|
70
89
|
auth = request.authorization
|
|
@@ -96,17 +115,34 @@ class AuthManager:
|
|
|
96
115
|
}
|
|
97
116
|
try:
|
|
98
117
|
parsed = ApiToken.parse_token(api_token)
|
|
118
|
+
|
|
119
|
+
# Check cache first
|
|
120
|
+
cache_key = f"api_token_{parsed['id']}"
|
|
121
|
+
current_time = datetime.utcnow()
|
|
122
|
+
|
|
123
|
+
if cache_key in self._user_cache:
|
|
124
|
+
cached_data, cache_time = self._user_cache[cache_key]
|
|
125
|
+
if (current_time - cache_time).total_seconds() < self._cache_ttl:
|
|
126
|
+
logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
|
|
127
|
+
return cached_data.copy() # Return a copy to avoid modifying cache
|
|
128
|
+
|
|
129
|
+
# Cache miss or expired, fetch from database
|
|
99
130
|
with self.db.get_cursor() as cur:
|
|
100
131
|
# First get the API token record
|
|
101
132
|
cur.execute("""
|
|
102
|
-
SELECT t.*, u
|
|
133
|
+
SELECT t.*, u.*, r.name as role_name FROM api_tokens t
|
|
103
134
|
JOIN users u ON t.user_id = u.id
|
|
135
|
+
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
136
|
+
LEFT JOIN roles r ON ur.role_id = r.id
|
|
104
137
|
WHERE t.id = %s
|
|
105
138
|
""", (parsed['id'],))
|
|
106
|
-
|
|
107
|
-
if not
|
|
139
|
+
results = cur.fetchall()
|
|
140
|
+
if not results:
|
|
108
141
|
raise AuthError('Invalid API token')
|
|
109
142
|
|
|
143
|
+
# Get the first row for token/user data (all rows will have same token/user data)
|
|
144
|
+
result = results[0]
|
|
145
|
+
|
|
110
146
|
# Verify the nonce
|
|
111
147
|
if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
|
|
112
148
|
raise AuthError('Invalid API token')
|
|
@@ -115,29 +151,28 @@ class AuthManager:
|
|
|
115
151
|
if result['expires_at'] and result['expires_at'] < datetime.utcnow():
|
|
116
152
|
raise AuthError('API token has expired')
|
|
117
153
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
UPDATE api_tokens
|
|
121
|
-
SET last_used_at = %s
|
|
122
|
-
WHERE id = %s
|
|
123
|
-
""", (datetime.utcnow(), parsed['id']))
|
|
154
|
+
# Schedule last used timestamp update (asynchronous with 10s delay)
|
|
155
|
+
self._schedule_last_used_update(parsed['id'])
|
|
124
156
|
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
SELECT r.name FROM roles r
|
|
128
|
-
JOIN user_roles ur ON ur.role_id = r.id
|
|
129
|
-
WHERE ur.user_id = %s
|
|
130
|
-
""", (result['user_id'],))
|
|
131
|
-
roles = [row['name'] for row in cur.fetchall()]
|
|
157
|
+
# Extract roles from results
|
|
158
|
+
roles = [row['role_name'] for row in results if row['role_name'] is not None]
|
|
132
159
|
|
|
133
160
|
# Construct user object
|
|
134
|
-
|
|
161
|
+
user_data = {
|
|
135
162
|
'id': result['user_id'],
|
|
136
163
|
'username': result['username'],
|
|
137
164
|
'email': result['email'],
|
|
138
165
|
'real_name': result['real_name'],
|
|
139
166
|
'roles': roles
|
|
140
167
|
}
|
|
168
|
+
|
|
169
|
+
# Cache the result
|
|
170
|
+
self._user_cache[cache_key] = (user_data.copy(), current_time)
|
|
171
|
+
|
|
172
|
+
# Clean up expired cache entries
|
|
173
|
+
self._cleanup_cache()
|
|
174
|
+
|
|
175
|
+
return user_data
|
|
141
176
|
except ValueError:
|
|
142
177
|
raise AuthError('Invalid token format')
|
|
143
178
|
|
|
@@ -270,14 +305,30 @@ class AuthManager:
|
|
|
270
305
|
|
|
271
306
|
if not code or not provider:
|
|
272
307
|
raise AuthError('Invalid OAuth callback', 400)
|
|
273
|
-
|
|
274
|
-
user_info = self._get_oauth_user_info(provider, code)
|
|
275
|
-
token = self._create_token(user_info)
|
|
276
|
-
refresh_token = self._create_refresh_token(user_info)
|
|
277
|
-
|
|
278
|
-
# Redirect to frontend with tokens
|
|
308
|
+
from urllib.parse import urlencode
|
|
279
309
|
frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
|
|
280
|
-
|
|
310
|
+
|
|
311
|
+
#if provider == 'microsoft':
|
|
312
|
+
# client = msal.ConfidentialClientApplication(
|
|
313
|
+
# self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
|
|
314
|
+
# )
|
|
315
|
+
# result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
|
|
316
|
+
# code = result['access_token']
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
user_info = self._get_oauth_user_info(provider, code)
|
|
320
|
+
token = self._create_token(user_info)
|
|
321
|
+
refresh_token = self._create_refresh_token(user_info)
|
|
322
|
+
# Redirect to frontend with tokens
|
|
323
|
+
return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
|
|
324
|
+
except AuthError as e:
|
|
325
|
+
# Surface error to frontend for user-friendly messaging
|
|
326
|
+
params = {
|
|
327
|
+
'error': str(e.message) if hasattr(e, 'message') else str(e),
|
|
328
|
+
'status': getattr(e, 'status_code', 500),
|
|
329
|
+
'provider': provider,
|
|
330
|
+
}
|
|
331
|
+
return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
|
|
281
332
|
|
|
282
333
|
@bp.route('/login/profile')
|
|
283
334
|
def profile():
|
|
@@ -441,21 +492,42 @@ class AuthManager:
|
|
|
441
492
|
logger.debug(f"Token payload: {payload}")
|
|
442
493
|
user_id = int(payload['sub']) # Convert string ID back to integer
|
|
443
494
|
|
|
495
|
+
# Check cache first
|
|
496
|
+
cache_key = f"user_{user_id}"
|
|
497
|
+
current_time = datetime.utcnow()
|
|
498
|
+
|
|
499
|
+
if cache_key in self._user_cache:
|
|
500
|
+
cached_data, cache_time = self._user_cache[cache_key]
|
|
501
|
+
if (current_time - cache_time).total_seconds() < self._cache_ttl:
|
|
502
|
+
logger.debug(f"Returning cached user data for ID: {user_id}")
|
|
503
|
+
return cached_data.copy() # Return a copy to avoid modifying cache
|
|
504
|
+
|
|
505
|
+
# Cache miss or expired, fetch from database
|
|
444
506
|
with self.db.get_cursor() as cur:
|
|
445
|
-
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
446
|
-
user = cur.fetchone()
|
|
447
|
-
if not user:
|
|
448
|
-
logger.error(f"User not found for ID: {user_id}")
|
|
449
|
-
raise AuthError('User not found', 404)
|
|
450
|
-
# Fetch roles
|
|
451
507
|
cur.execute("""
|
|
452
|
-
SELECT r.name FROM
|
|
453
|
-
JOIN user_roles ur ON ur.
|
|
454
|
-
|
|
508
|
+
SELECT u.*, r.name as role_name FROM users u
|
|
509
|
+
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
510
|
+
LEFT JOIN roles r ON ur.role_id = r.id
|
|
511
|
+
WHERE u.id = %s
|
|
455
512
|
""", (user_id,))
|
|
456
|
-
|
|
513
|
+
results = cur.fetchall()
|
|
514
|
+
if not results:
|
|
515
|
+
logger.error(f"User not found for ID: {user_id}")
|
|
516
|
+
raise AuthError('User not found', 404)
|
|
517
|
+
|
|
518
|
+
# Get the first row for user data (all rows will have same user data)
|
|
519
|
+
user = results[0]
|
|
520
|
+
|
|
521
|
+
# Extract roles from results
|
|
522
|
+
roles = [row['role_name'] for row in results if row['role_name'] is not None]
|
|
457
523
|
user['roles'] = roles
|
|
458
524
|
|
|
525
|
+
# Cache the result
|
|
526
|
+
self._user_cache[cache_key] = (user.copy(), current_time)
|
|
527
|
+
|
|
528
|
+
# Clean up expired cache entries
|
|
529
|
+
self._cleanup_cache()
|
|
530
|
+
|
|
459
531
|
return user
|
|
460
532
|
except jwt.InvalidTokenError as e:
|
|
461
533
|
logger.error(f"Invalid token error: {str(e)}")
|
|
@@ -464,6 +536,78 @@ class AuthManager:
|
|
|
464
536
|
logger.error(f"Unexpected error during token validation: {str(e)}")
|
|
465
537
|
raise AuthError(str(e), 500)
|
|
466
538
|
|
|
539
|
+
def _cleanup_cache(self):
|
|
540
|
+
"""Remove expired cache entries."""
|
|
541
|
+
current_time = datetime.utcnow()
|
|
542
|
+
expired_keys = [
|
|
543
|
+
key for key, (_, cache_time) in self._user_cache.items()
|
|
544
|
+
if (current_time - cache_time).total_seconds() >= self._cache_ttl
|
|
545
|
+
]
|
|
546
|
+
for key in expired_keys:
|
|
547
|
+
del self._user_cache[key]
|
|
548
|
+
|
|
549
|
+
def _start_update_thread(self):
|
|
550
|
+
"""Start the background thread for processing last_used_at updates."""
|
|
551
|
+
if self._update_thread is None or not self._update_thread.is_alive():
|
|
552
|
+
self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
|
|
553
|
+
self._update_thread.start()
|
|
554
|
+
logger.debug("Started background update thread")
|
|
555
|
+
|
|
556
|
+
def _schedule_last_used_update(self, token_id):
|
|
557
|
+
"""Schedule a last_used_at update for an API token with 10s delay."""
|
|
558
|
+
with self._update_lock:
|
|
559
|
+
self._last_used_updates[token_id] = time.time()
|
|
560
|
+
logger.debug(f"Scheduled last_used update for token {token_id}")
|
|
561
|
+
|
|
562
|
+
def _update_worker(self):
|
|
563
|
+
"""Background worker that processes last_used_at updates."""
|
|
564
|
+
while not self._shutdown_event.is_set():
|
|
565
|
+
try:
|
|
566
|
+
current_time = time.time()
|
|
567
|
+
tokens_to_update = []
|
|
568
|
+
|
|
569
|
+
# Collect tokens that need updating (older than 10 seconds)
|
|
570
|
+
with self._update_lock:
|
|
571
|
+
for token_id, schedule_time in list(self._last_used_updates.items()):
|
|
572
|
+
if current_time - schedule_time >= 10: # 10 second delay
|
|
573
|
+
tokens_to_update.append(token_id)
|
|
574
|
+
del self._last_used_updates[token_id]
|
|
575
|
+
|
|
576
|
+
# Perform batch update
|
|
577
|
+
if tokens_to_update:
|
|
578
|
+
self._perform_batch_update(tokens_to_update)
|
|
579
|
+
|
|
580
|
+
# Sleep for a short interval
|
|
581
|
+
time.sleep(10)
|
|
582
|
+
|
|
583
|
+
except Exception as e:
|
|
584
|
+
logger.error(f"Error in update worker: {e}")
|
|
585
|
+
time.sleep(5) # Wait longer on error
|
|
586
|
+
|
|
587
|
+
def _perform_batch_update(self, token_ids):
|
|
588
|
+
"""Perform batch update of last_used_at for multiple tokens."""
|
|
589
|
+
try:
|
|
590
|
+
with self.db.get_cursor() as cur:
|
|
591
|
+
# Update all tokens in a single query
|
|
592
|
+
placeholders = ','.join(['%s'] * len(token_ids))
|
|
593
|
+
cur.execute(f"""
|
|
594
|
+
UPDATE api_tokens
|
|
595
|
+
SET last_used_at = %s
|
|
596
|
+
WHERE id IN ({placeholders})
|
|
597
|
+
""", [datetime.utcnow()] + token_ids)
|
|
598
|
+
|
|
599
|
+
logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
|
|
600
|
+
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.error(f"Error performing batch update: {e}")
|
|
603
|
+
|
|
604
|
+
def shutdown(self):
|
|
605
|
+
"""Shutdown the background update thread."""
|
|
606
|
+
self._shutdown_event.set()
|
|
607
|
+
if self._update_thread and self._update_thread.is_alive():
|
|
608
|
+
self._update_thread.join(timeout=5)
|
|
609
|
+
logger.debug("Background update thread shutdown complete")
|
|
610
|
+
|
|
467
611
|
def get_current_user(self):
|
|
468
612
|
return self._authenticate_request()
|
|
469
613
|
|
|
@@ -512,72 +656,197 @@ class AuthManager:
|
|
|
512
656
|
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
|
513
657
|
|
|
514
658
|
def _get_oauth_url(self, provider, redirect_uri):
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
659
|
+
meta = self._get_provider_meta(provider)
|
|
660
|
+
client_id = self.oauth_config[provider]['client_id']
|
|
661
|
+
scope = self.oauth_config[provider].get('scope', meta['default_scope'])
|
|
662
|
+
state = provider # Pass provider as state for callback
|
|
663
|
+
# Some providers require additional params
|
|
664
|
+
params = {
|
|
665
|
+
'client_id': client_id,
|
|
666
|
+
'redirect_uri': redirect_uri,
|
|
667
|
+
'response_type': 'code',
|
|
668
|
+
'scope': scope,
|
|
669
|
+
'state': state
|
|
670
|
+
}
|
|
671
|
+
# Facebook requires display; GitHub supports prompt
|
|
672
|
+
if provider == 'facebook':
|
|
673
|
+
params['display'] = 'page'
|
|
674
|
+
# Build URL
|
|
675
|
+
from urllib.parse import urlencode
|
|
676
|
+
return f"{meta['auth_url']}?{urlencode(params)}"
|
|
521
677
|
|
|
522
678
|
def _get_oauth_user_info(self, provider, code):
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
679
|
+
meta = self._get_provider_meta(provider)
|
|
680
|
+
client_id = self.oauth_config[provider]['client_id']
|
|
681
|
+
client_secret = self.oauth_config[provider]['client_secret']
|
|
682
|
+
redirect_uri = self.get_redirect_uri()
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
if provider == 'microsoft':
|
|
686
|
+
import msal
|
|
687
|
+
client = msal.ConfidentialClientApplication(
|
|
688
|
+
client_id,
|
|
689
|
+
client_credential=client_secret,
|
|
690
|
+
authority="https://login.microsoftonline.com/common"
|
|
691
|
+
)
|
|
692
|
+
tokens = client.acquire_token_by_authorization_code(
|
|
693
|
+
code,
|
|
694
|
+
scopes=["email"],
|
|
695
|
+
redirect_uri=redirect_uri
|
|
696
|
+
)
|
|
697
|
+
else:
|
|
698
|
+
# Standard OAuth flow for other providers
|
|
530
699
|
token_data = {
|
|
531
700
|
'client_id': client_id,
|
|
532
701
|
'client_secret': client_secret,
|
|
533
702
|
'code': code,
|
|
534
703
|
'grant_type': 'authorization_code',
|
|
535
|
-
'redirect_uri': redirect_uri
|
|
704
|
+
'redirect_uri': redirect_uri,
|
|
705
|
+
'scope': meta['default_scope']
|
|
536
706
|
}
|
|
537
|
-
|
|
707
|
+
token_headers = {}
|
|
708
|
+
if provider == 'github':
|
|
709
|
+
token_headers['Accept'] = 'application/json'
|
|
710
|
+
token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
|
|
538
711
|
logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
|
|
539
712
|
token_response.raise_for_status()
|
|
540
713
|
tokens = token_response.json()
|
|
541
714
|
|
|
542
|
-
# Get user info
|
|
543
|
-
userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
|
544
|
-
userinfo_response = requests.get(
|
|
545
|
-
userinfo_url,
|
|
546
|
-
headers={'Authorization': f"Bearer {tokens['access_token']}"}
|
|
547
|
-
)
|
|
548
|
-
userinfo_response.raise_for_status()
|
|
549
|
-
userinfo = userinfo_response.json()
|
|
550
715
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
716
|
+
access_token = tokens.get('access_token') or tokens.get('id_token')
|
|
717
|
+
if not access_token:
|
|
718
|
+
# Some providers return id_token separately but require access_token for userinfo
|
|
719
|
+
access_token = tokens.get('access_token')
|
|
555
720
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
)
|
|
564
|
-
cur.execute("""
|
|
565
|
-
INSERT INTO users (username, email, real_name, created_at, updated_at)
|
|
566
|
-
VALUES (%s, %s, %s, %s, %s)
|
|
567
|
-
RETURNING id
|
|
568
|
-
""", (user.username, user.email, user.real_name,
|
|
569
|
-
user.created_at, user.updated_at))
|
|
570
|
-
user.id = cur.fetchone()['id']
|
|
571
|
-
user = {'id': user.id, 'username': user.username, 'email': user.email,
|
|
572
|
-
'real_name': user.real_name, 'roles': []}
|
|
573
|
-
else:
|
|
574
|
-
# Update existing user
|
|
575
|
-
cur.execute("""
|
|
576
|
-
UPDATE users
|
|
577
|
-
SET real_name = %s, updated_at = %s
|
|
578
|
-
WHERE email = %s
|
|
579
|
-
""", (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
|
|
580
|
-
user['real_name'] = userinfo.get('name', userinfo['email'])
|
|
721
|
+
# Build userinfo request
|
|
722
|
+
userinfo_url = meta['userinfo_url']
|
|
723
|
+
userinfo_headers = {'Authorization': f"Bearer {access_token}"}
|
|
724
|
+
if provider == 'facebook':
|
|
725
|
+
# Ensure fields
|
|
726
|
+
from urllib.parse import urlencode
|
|
727
|
+
userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
|
|
581
728
|
|
|
582
|
-
|
|
583
|
-
|
|
729
|
+
userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
|
|
730
|
+
userinfo_response.raise_for_status()
|
|
731
|
+
raw_userinfo = userinfo_response.json()
|
|
732
|
+
|
|
733
|
+
# Special handling for GitHub missing email
|
|
734
|
+
if provider == 'github' and not raw_userinfo.get('email'):
|
|
735
|
+
emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
|
|
736
|
+
if emails_resp.ok:
|
|
737
|
+
emails = emails_resp.json()
|
|
738
|
+
primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
|
|
739
|
+
raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# Normalize
|
|
745
|
+
norm = self._normalize_userinfo(provider, raw_userinfo)
|
|
746
|
+
if not norm.get('email'):
|
|
747
|
+
# Fallback pseudo-email if allowed
|
|
748
|
+
norm['email'] = f"{norm['sub']}@{provider}.local"
|
|
749
|
+
|
|
750
|
+
# Create or update user
|
|
751
|
+
with self.db.get_cursor() as cur:
|
|
752
|
+
cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
|
|
753
|
+
user = cur.fetchone()
|
|
754
|
+
|
|
755
|
+
if not user:
|
|
756
|
+
if not self.allow_oauth_auto_create:
|
|
757
|
+
raise AuthError('User not found and auto-create disabled', 403)
|
|
758
|
+
# Create new user (auto-create enabled)
|
|
759
|
+
user_obj = User(
|
|
760
|
+
username=norm['email'],
|
|
761
|
+
email=norm['email'],
|
|
762
|
+
real_name=norm.get('name', norm['email']),
|
|
763
|
+
id_generator=self.db.get_id_generator()
|
|
764
|
+
)
|
|
765
|
+
cur.execute("""
|
|
766
|
+
INSERT INTO users (username, email, real_name, created_at, updated_at)
|
|
767
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
768
|
+
RETURNING id
|
|
769
|
+
""", (user_obj.username, user_obj.email, user_obj.real_name,
|
|
770
|
+
user_obj.created_at, user_obj.updated_at))
|
|
771
|
+
new_id = cur.fetchone()['id']
|
|
772
|
+
user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
|
|
773
|
+
'real_name': user_obj.real_name, 'roles': []}
|
|
774
|
+
else:
|
|
775
|
+
# Update existing user
|
|
776
|
+
cur.execute("""
|
|
777
|
+
UPDATE users
|
|
778
|
+
SET real_name = %s, updated_at = %s
|
|
779
|
+
WHERE email = %s
|
|
780
|
+
""", (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
|
|
781
|
+
user['real_name'] = norm.get('name', norm['email'])
|
|
782
|
+
|
|
783
|
+
return user
|
|
784
|
+
|
|
785
|
+
def _get_provider_meta(self, provider):
|
|
786
|
+
providers = {
|
|
787
|
+
'google': {
|
|
788
|
+
'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
789
|
+
'token_url': 'https://oauth2.googleapis.com/token',
|
|
790
|
+
'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
791
|
+
'default_scope': 'openid email profile'
|
|
792
|
+
},
|
|
793
|
+
'github': {
|
|
794
|
+
'auth_url': 'https://github.com/login/oauth/authorize',
|
|
795
|
+
'token_url': 'https://github.com/login/oauth/access_token',
|
|
796
|
+
'userinfo_url': 'https://api.github.com/user',
|
|
797
|
+
'default_scope': 'read:user user:email'
|
|
798
|
+
},
|
|
799
|
+
'facebook': {
|
|
800
|
+
'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
|
|
801
|
+
'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
|
|
802
|
+
'userinfo_url': 'https://graph.facebook.com/me',
|
|
803
|
+
'default_scope': 'email public_profile'
|
|
804
|
+
},
|
|
805
|
+
'microsoft': {
|
|
806
|
+
'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
807
|
+
'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
808
|
+
'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
|
|
809
|
+
'default_scope': 'openid email profile'
|
|
810
|
+
},
|
|
811
|
+
'linkedin': {
|
|
812
|
+
'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
|
|
813
|
+
'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
814
|
+
'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
|
|
815
|
+
'default_scope': 'openid profile email'
|
|
816
|
+
},
|
|
817
|
+
'slack': {
|
|
818
|
+
'auth_url': 'https://slack.com/openid/connect/authorize',
|
|
819
|
+
'token_url': 'https://slack.com/api/openid.connect.token',
|
|
820
|
+
'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
|
|
821
|
+
'default_scope': 'openid profile email'
|
|
822
|
+
},
|
|
823
|
+
'apple': {
|
|
824
|
+
'auth_url': 'https://appleid.apple.com/auth/authorize',
|
|
825
|
+
'token_url': 'https://appleid.apple.com/auth/token',
|
|
826
|
+
'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
|
|
827
|
+
'default_scope': 'name email'
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if provider not in providers:
|
|
831
|
+
raise AuthError('Invalid OAuth provider')
|
|
832
|
+
return providers[provider]
|
|
833
|
+
|
|
834
|
+
def _normalize_userinfo(self, provider, info):
|
|
835
|
+
# Map into a common structure: sub, email, name
|
|
836
|
+
if provider == 'google':
|
|
837
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
838
|
+
if provider == 'github':
|
|
839
|
+
return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
|
|
840
|
+
if provider == 'facebook':
|
|
841
|
+
return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
|
|
842
|
+
if provider == 'microsoft':
|
|
843
|
+
# OIDC userinfo
|
|
844
|
+
return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
|
|
845
|
+
if provider == 'linkedin':
|
|
846
|
+
return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
|
|
847
|
+
if provider == 'slack':
|
|
848
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
849
|
+
if provider == 'apple':
|
|
850
|
+
# Apple email may be private relay; name not always present
|
|
851
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
852
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
File without changes
|
|
File without changes
|
{the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/__init__.py
RENAMED
|
File without changes
|
{the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/db.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|