the37lab-authlib 0.1.1756371198__tar.gz → 0.1.1756739540__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.1756739540}/PKG-INFO +1 -1
  2. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/pyproject.toml +1 -1
  3. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/auth.py +213 -62
  4. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/PKG-INFO +1 -1
  5. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/README.md +0 -0
  6. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/setup.cfg +0 -0
  7. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/__init__.py +0 -0
  8. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/db.py +0 -0
  9. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/decorators.py +0 -0
  10. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/exceptions.py +0 -0
  11. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/models.py +0 -0
  12. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/SOURCES.txt +0 -0
  13. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/dependency_links.txt +0 -0
  14. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/requires.txt +0 -0
  15. {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/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.1756739540
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.1756739540"
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 = {}
@@ -283,6 +291,8 @@ class AuthManager:
283
291
  def oauth_login():
284
292
  provider = request.json.get('provider')
285
293
  if provider not in self.oauth_config:
294
+ logger.error(f"Invalid OAuth provider: {provider}")
295
+ logger.error(f"These are the known ones: {self.oauth_config.keys()}")
286
296
  raise AuthError('Invalid OAuth provider', 400)
287
297
 
288
298
  redirect_uri = self.get_redirect_uri()
@@ -297,14 +307,30 @@ class AuthManager:
297
307
 
298
308
  if not code or not provider:
299
309
  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
310
+ from urllib.parse import urlencode
306
311
  frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
307
- return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
312
+
313
+ #if provider == 'microsoft':
314
+ # client = msal.ConfidentialClientApplication(
315
+ # self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
316
+ # )
317
+ # result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
318
+ # code = result['access_token']
319
+
320
+ try:
321
+ user_info = self._get_oauth_user_info(provider, code)
322
+ token = self._create_token(user_info)
323
+ refresh_token = self._create_refresh_token(user_info)
324
+ # Redirect to frontend with tokens
325
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
326
+ except AuthError as e:
327
+ # Surface error to frontend for user-friendly messaging
328
+ params = {
329
+ 'error': str(e.message) if hasattr(e, 'message') else str(e),
330
+ 'status': getattr(e, 'status_code', 500),
331
+ 'provider': provider,
332
+ }
333
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
308
334
 
309
335
  @bp.route('/login/profile')
310
336
  def profile():
@@ -632,72 +658,197 @@ class AuthManager:
632
658
  return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
633
659
 
634
660
  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')
661
+ meta = self._get_provider_meta(provider)
662
+ client_id = self.oauth_config[provider]['client_id']
663
+ scope = self.oauth_config[provider].get('scope', meta['default_scope'])
664
+ state = provider # Pass provider as state for callback
665
+ # Some providers require additional params
666
+ params = {
667
+ 'client_id': client_id,
668
+ 'redirect_uri': redirect_uri,
669
+ 'response_type': 'code',
670
+ 'scope': scope,
671
+ 'state': state
672
+ }
673
+ # Facebook requires display; GitHub supports prompt
674
+ if provider == 'facebook':
675
+ params['display'] = 'page'
676
+ # Build URL
677
+ from urllib.parse import urlencode
678
+ return f"{meta['auth_url']}?{urlencode(params)}"
641
679
 
642
680
  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'
681
+ meta = self._get_provider_meta(provider)
682
+ client_id = self.oauth_config[provider]['client_id']
683
+ client_secret = self.oauth_config[provider]['client_secret']
684
+ redirect_uri = self.get_redirect_uri()
685
+
686
+
687
+ if provider == 'microsoft':
688
+ import msal
689
+ client = msal.ConfidentialClientApplication(
690
+ client_id,
691
+ client_credential=client_secret,
692
+ authority="https://login.microsoftonline.com/common"
693
+ )
694
+ tokens = client.acquire_token_by_authorization_code(
695
+ code,
696
+ scopes=["email"],
697
+ redirect_uri=redirect_uri
698
+ )
699
+ else:
700
+ # Standard OAuth flow for other providers
650
701
  token_data = {
651
702
  'client_id': client_id,
652
703
  'client_secret': client_secret,
653
704
  'code': code,
654
705
  'grant_type': 'authorization_code',
655
- 'redirect_uri': redirect_uri
706
+ 'redirect_uri': redirect_uri,
707
+ 'scope': meta['default_scope']
656
708
  }
657
- token_response = requests.post(token_url, data=token_data)
709
+ token_headers = {}
710
+ if provider == 'github':
711
+ token_headers['Accept'] = 'application/json'
712
+ token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
658
713
  logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
659
714
  token_response.raise_for_status()
660
715
  tokens = token_response.json()
661
716
 
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
717
 
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()
718
+ access_token = tokens.get('access_token') or tokens.get('id_token')
719
+ if not access_token:
720
+ # Some providers return id_token separately but require access_token for userinfo
721
+ access_token = tokens.get('access_token')
675
722
 
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'])
723
+ # Build userinfo request
724
+ userinfo_url = meta['userinfo_url']
725
+ userinfo_headers = {'Authorization': f"Bearer {access_token}"}
726
+ if provider == 'facebook':
727
+ # Ensure fields
728
+ from urllib.parse import urlencode
729
+ userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
701
730
 
702
- return user
703
- raise AuthError('Invalid OAuth provider')
731
+ userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
732
+ userinfo_response.raise_for_status()
733
+ raw_userinfo = userinfo_response.json()
734
+
735
+ # Special handling for GitHub missing email
736
+ if provider == 'github' and not raw_userinfo.get('email'):
737
+ emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
738
+ if emails_resp.ok:
739
+ emails = emails_resp.json()
740
+ primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
741
+ raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
742
+
743
+
744
+
745
+
746
+ # Normalize
747
+ norm = self._normalize_userinfo(provider, raw_userinfo)
748
+ if not norm.get('email'):
749
+ # Fallback pseudo-email if allowed
750
+ norm['email'] = f"{norm['sub']}@{provider}.local"
751
+
752
+ # Create or update user
753
+ with self.db.get_cursor() as cur:
754
+ cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
755
+ user = cur.fetchone()
756
+
757
+ if not user:
758
+ if not self.allow_oauth_auto_create:
759
+ raise AuthError('User not found and auto-create disabled', 403)
760
+ # Create new user (auto-create enabled)
761
+ user_obj = User(
762
+ username=norm['email'],
763
+ email=norm['email'],
764
+ real_name=norm.get('name', norm['email']),
765
+ id_generator=self.db.get_id_generator()
766
+ )
767
+ cur.execute("""
768
+ INSERT INTO users (username, email, real_name, created_at, updated_at)
769
+ VALUES (%s, %s, %s, %s, %s)
770
+ RETURNING id
771
+ """, (user_obj.username, user_obj.email, user_obj.real_name,
772
+ user_obj.created_at, user_obj.updated_at))
773
+ new_id = cur.fetchone()['id']
774
+ user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
775
+ 'real_name': user_obj.real_name, 'roles': []}
776
+ else:
777
+ # Update existing user
778
+ cur.execute("""
779
+ UPDATE users
780
+ SET real_name = %s, updated_at = %s
781
+ WHERE email = %s
782
+ """, (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
783
+ user['real_name'] = norm.get('name', norm['email'])
784
+
785
+ return user
786
+
787
+ def _get_provider_meta(self, provider):
788
+ providers = {
789
+ 'google': {
790
+ 'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
791
+ 'token_url': 'https://oauth2.googleapis.com/token',
792
+ 'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
793
+ 'default_scope': 'openid email profile'
794
+ },
795
+ 'github': {
796
+ 'auth_url': 'https://github.com/login/oauth/authorize',
797
+ 'token_url': 'https://github.com/login/oauth/access_token',
798
+ 'userinfo_url': 'https://api.github.com/user',
799
+ 'default_scope': 'read:user user:email'
800
+ },
801
+ 'facebook': {
802
+ 'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
803
+ 'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
804
+ 'userinfo_url': 'https://graph.facebook.com/me',
805
+ 'default_scope': 'email public_profile'
806
+ },
807
+ 'microsoft': {
808
+ 'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
809
+ 'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
810
+ 'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
811
+ 'default_scope': 'openid email profile'
812
+ },
813
+ 'linkedin': {
814
+ 'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
815
+ 'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
816
+ 'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
817
+ 'default_scope': 'openid profile email'
818
+ },
819
+ 'slack': {
820
+ 'auth_url': 'https://slack.com/openid/connect/authorize',
821
+ 'token_url': 'https://slack.com/api/openid.connect.token',
822
+ 'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
823
+ 'default_scope': 'openid profile email'
824
+ },
825
+ 'apple': {
826
+ 'auth_url': 'https://appleid.apple.com/auth/authorize',
827
+ 'token_url': 'https://appleid.apple.com/auth/token',
828
+ 'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
829
+ 'default_scope': 'name email'
830
+ }
831
+ }
832
+ if provider not in providers:
833
+ raise AuthError('Invalid OAuth provider ' + provider)
834
+ return providers[provider]
835
+
836
+ def _normalize_userinfo(self, provider, info):
837
+ # Map into a common structure: sub, email, name
838
+ if provider == 'google':
839
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
840
+ if provider == 'github':
841
+ return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
842
+ if provider == 'facebook':
843
+ return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
844
+ if provider == 'microsoft':
845
+ # OIDC userinfo
846
+ return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
847
+ if provider == 'linkedin':
848
+ return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
849
+ if provider == 'slack':
850
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
851
+ if provider == 'apple':
852
+ # Apple email may be private relay; name not always present
853
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
854
+ 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.1756739540
4
4
  Summary: Python SDK for the Authlib
5
5
  Author-email: the37lab <info@the37lab.com>
6
6
  Classifier: Programming Language :: Python :: 3