the37lab-authlib 0.1.1756371198__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.1756371198 → the37lab_authlib-0.1.1756730814}/PKG-INFO +1 -1
  2. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/pyproject.toml +1 -1
  3. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/auth.py +211 -62
  4. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/PKG-INFO +1 -1
  5. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/README.md +0 -0
  6. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/setup.cfg +0 -0
  7. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/__init__.py +0 -0
  8. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/db.py +0 -0
  9. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/decorators.py +0 -0
  10. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/exceptions.py +0 -0
  11. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib/models.py +0 -0
  12. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/SOURCES.txt +0 -0
  13. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/dependency_links.txt +0 -0
  14. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756730814}/src/the37lab_authlib.egg-info/requires.txt +0 -0
  15. {the37lab_authlib-0.1.1756371198 → 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.1756371198
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.1756371198"
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"]
@@ -14,12 +14,13 @@ from functools import wraps
14
14
  from isodate import parse_duration
15
15
  import threading
16
16
  import time
17
+ import msal
17
18
 
18
19
  logging.basicConfig(level=logging.DEBUG)
19
20
  logger = logging.getLogger(__name__)
20
21
 
21
22
  class AuthManager:
22
- 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):
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):
23
24
  self.user_override = None
24
25
  self._user_cache = {}
25
26
  self._cache_ttl = cache_ttl or 10 # 10 seconds
@@ -27,6 +28,9 @@ class AuthManager:
27
28
  self._update_lock = threading.Lock()
28
29
  self._update_thread = None
29
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
+
30
34
  if environment_prefix:
31
35
  prefix = environment_prefix.upper() + '_'
32
36
  db_dsn = os.getenv(f'{prefix}DATABASE_URL')
@@ -39,6 +43,10 @@ class AuthManager:
39
43
  'client_id': google_client_id,
40
44
  'client_secret': google_client_secret
41
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']
42
50
  api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
43
51
  if api_tokens_env:
44
52
  api_tokens = {}
@@ -297,14 +305,30 @@ class AuthManager:
297
305
 
298
306
  if not code or not provider:
299
307
  raise AuthError('Invalid OAuth callback', 400)
300
-
301
- user_info = self._get_oauth_user_info(provider, code)
302
- token = self._create_token(user_info)
303
- refresh_token = self._create_refresh_token(user_info)
304
-
305
- # Redirect to frontend with tokens
308
+ from urllib.parse import urlencode
306
309
  frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
307
- 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))
308
332
 
309
333
  @bp.route('/login/profile')
310
334
  def profile():
@@ -632,72 +656,197 @@ class AuthManager:
632
656
  return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
633
657
 
634
658
  def _get_oauth_url(self, provider, redirect_uri):
635
- if provider == 'google':
636
- client_id = self.oauth_config['google']['client_id']
637
- scope = 'openid email profile'
638
- state = provider # Pass provider as state for callback
639
- 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}'
640
- 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)}"
641
677
 
642
678
  def _get_oauth_user_info(self, provider, code):
643
- if provider == 'google':
644
- client_id = self.oauth_config['google']['client_id']
645
- client_secret = self.oauth_config['google']['client_secret']
646
- redirect_uri = self.get_redirect_uri()
647
-
648
- # Exchange code for tokens
649
- 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
650
699
  token_data = {
651
700
  'client_id': client_id,
652
701
  'client_secret': client_secret,
653
702
  'code': code,
654
703
  'grant_type': 'authorization_code',
655
- 'redirect_uri': redirect_uri
704
+ 'redirect_uri': redirect_uri,
705
+ 'scope': meta['default_scope']
656
706
  }
657
- 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)
658
711
  logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
659
712
  token_response.raise_for_status()
660
713
  tokens = token_response.json()
661
714
 
662
- # Get user info
663
- userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
664
- userinfo_response = requests.get(
665
- userinfo_url,
666
- headers={'Authorization': f"Bearer {tokens['access_token']}"}
667
- )
668
- userinfo_response.raise_for_status()
669
- userinfo = userinfo_response.json()
670
715
 
671
- # Create or update user
672
- with self.db.get_cursor() as cur:
673
- cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
674
- 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')
675
720
 
676
- if not user:
677
- # Create new user
678
- user = User(
679
- username=userinfo['email'],
680
- email=userinfo['email'],
681
- real_name=userinfo.get('name', userinfo['email']),
682
- id_generator=self.db.get_id_generator()
683
- )
684
- cur.execute("""
685
- INSERT INTO users (username, email, real_name, created_at, updated_at)
686
- VALUES (%s, %s, %s, %s, %s)
687
- RETURNING id
688
- """, (user.username, user.email, user.real_name,
689
- user.created_at, user.updated_at))
690
- user.id = cur.fetchone()['id']
691
- user = {'id': user.id, 'username': user.username, 'email': user.email,
692
- 'real_name': user.real_name, 'roles': []}
693
- else:
694
- # Update existing user
695
- cur.execute("""
696
- UPDATE users
697
- SET real_name = %s, updated_at = %s
698
- WHERE email = %s
699
- """, (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
700
- 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'})}"
701
728
 
702
- return user
703
- 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.1756371198
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