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.
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/PKG-INFO +1 -1
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/pyproject.toml +1 -1
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/auth.py +213 -62
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/PKG-INFO +1 -1
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/README.md +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/setup.cfg +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/__init__.py +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/db.py +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/decorators.py +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/exceptions.py +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/models.py +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/SOURCES.txt +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/dependency_links.txt +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib.egg-info/requires.txt +0 -0
- {the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/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.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"]
|
{the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/auth.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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')}
|
|
File without changes
|
|
File without changes
|
{the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/__init__.py
RENAMED
|
File without changes
|
{the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/db.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{the37lab_authlib-0.1.1756371198 → the37lab_authlib-0.1.1756739540}/src/the37lab_authlib/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|