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.

Files changed (15) hide show
  1. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/PKG-INFO +1 -1
  2. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/pyproject.toml +1 -1
  3. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/auth.py +358 -89
  4. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/PKG-INFO +1 -1
  5. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/README.md +0 -0
  6. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/setup.cfg +0 -0
  7. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/__init__.py +0 -0
  8. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/db.py +0 -0
  9. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/decorators.py +0 -0
  10. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/exceptions.py +0 -0
  11. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/models.py +0 -0
  12. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/SOURCES.txt +0 -0
  13. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/dependency_links.txt +0 -0
  14. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/requires.txt +0 -0
  15. {the37lab_authlib-0.1.1756367559 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: the37lab_authlib
3
- Version: 0.1.1756367559
3
+ Version: 0.1.1756730814
4
4
  Summary: Python SDK for the Authlib
5
5
  Author-email: the37lab <info@the37lab.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "the37lab_authlib"
7
- version = "0.1.1756367559"
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"]
@@ -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.* FROM api_tokens t
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
- result = cur.fetchone()
107
- if not result:
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
- # Update last used timestamp
119
- cur.execute("""
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
- # Fetch roles
126
- cur.execute("""
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
- return {
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
- return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
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 roles r
453
- JOIN user_roles ur ON ur.role_id = r.id
454
- WHERE ur.user_id = %s
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
- roles = [row['name'] for row in cur.fetchall()]
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
- if provider == 'google':
516
- client_id = self.oauth_config['google']['client_id']
517
- scope = 'openid email profile'
518
- state = provider # Pass provider as state for callback
519
- return f'https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope={scope}&state={state}'
520
- raise AuthError('Invalid OAuth provider')
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
- if provider == 'google':
524
- client_id = self.oauth_config['google']['client_id']
525
- client_secret = self.oauth_config['google']['client_secret']
526
- redirect_uri = self.get_redirect_uri()
527
-
528
- # Exchange code for tokens
529
- token_url = 'https://oauth2.googleapis.com/token'
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
- token_response = requests.post(token_url, data=token_data)
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
- # Create or update user
552
- with self.db.get_cursor() as cur:
553
- cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
554
- user = cur.fetchone()
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
- if not user:
557
- # Create new user
558
- user = User(
559
- username=userinfo['email'],
560
- email=userinfo['email'],
561
- real_name=userinfo.get('name', userinfo['email']),
562
- id_generator=self.db.get_id_generator()
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
- return user
583
- raise AuthError('Invalid OAuth provider')
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')}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: the37lab_authlib
3
- Version: 0.1.1756367559
3
+ Version: 0.1.1756730814
4
4
  Summary: Python SDK for the Authlib
5
5
  Author-email: the37lab <info@the37lab.com>
6
6
  Classifier: Programming Language :: Python :: 3