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