the37lab-authlib 0.1.1751369506__tar.gz → 0.1.1756371198__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.1751369506 → the37lab_authlib-0.1.1756371198}/PKG-INFO +16 -1
  2. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/README.md +15 -0
  3. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/pyproject.toml +1 -1
  4. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib/auth.py +169 -30
  5. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib/db.py +21 -4
  6. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib.egg-info/PKG-INFO +16 -1
  7. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/setup.cfg +0 -0
  8. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib/__init__.py +0 -0
  9. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib/decorators.py +0 -0
  10. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib/exceptions.py +0 -0
  11. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib/models.py +0 -0
  12. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib.egg-info/SOURCES.txt +0 -0
  13. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib.egg-info/dependency_links.txt +0 -0
  14. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/src/the37lab_authlib.egg-info/requires.txt +0 -0
  15. {the37lab_authlib-0.1.1751369506 → the37lab_authlib-0.1.1756371198}/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.1751369506
3
+ Version: 0.1.1756371198
4
4
  Summary: Python SDK for the Authlib
5
5
  Author-email: the37lab <info@the37lab.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -42,6 +42,9 @@ A Python authentication library that provides JWT, OAuth2, and API token authent
42
42
  - [API Token Override for Testing](#api-token-override-for-testing)
43
43
  - [Usage](#usage)
44
44
  - [Warning](#warning)
45
+ - [User Override for Testing](#user-override-for-testing)
46
+ - [Usage](#usage-1)
47
+ - [Warning](#warning-1)
45
48
 
46
49
  ## Installation
47
50
 
@@ -233,3 +236,15 @@ For testing purposes, you can bypass the database and provide a static mapping o
233
236
  Replace `MYAPP` with your environment prefix.
234
237
 
235
238
  **Warning:** This method is intended only for testing and development. Do not use this approach in production environments.
239
+
240
+ ## User Override for Testing
241
+
242
+ For testing purposes, you can force all authentication to return a specific user by setting the `{PREFIX}USER_OVERRIDE` environment variable:
243
+
244
+ ```bash
245
+ export MYAPP_USER_OVERRIDE="testuser"
246
+ ```
247
+
248
+ If set, all requests will be authenticated as the specified user, regardless of any tokens or credentials provided. This cannot be combined with `api_tokens` or `db_dsn`.
249
+
250
+ **Warning:** This method is intended only for testing and development. Do not use this approach in production environments.
@@ -25,6 +25,9 @@ A Python authentication library that provides JWT, OAuth2, and API token authent
25
25
  - [API Token Override for Testing](#api-token-override-for-testing)
26
26
  - [Usage](#usage)
27
27
  - [Warning](#warning)
28
+ - [User Override for Testing](#user-override-for-testing)
29
+ - [Usage](#usage-1)
30
+ - [Warning](#warning-1)
28
31
 
29
32
  ## Installation
30
33
 
@@ -216,3 +219,15 @@ For testing purposes, you can bypass the database and provide a static mapping o
216
219
  Replace `MYAPP` with your environment prefix.
217
220
 
218
221
  **Warning:** This method is intended only for testing and development. Do not use this approach in production environments.
222
+
223
+ ## User Override for Testing
224
+
225
+ For testing purposes, you can force all authentication to return a specific user by setting the `{PREFIX}USER_OVERRIDE` environment variable:
226
+
227
+ ```bash
228
+ export MYAPP_USER_OVERRIDE="testuser"
229
+ ```
230
+
231
+ If set, all requests will be authenticated as the specified user, regardless of any tokens or credentials provided. This cannot be combined with `api_tokens` or `db_dsn`.
232
+
233
+ **Warning:** This method is intended only for testing and development. Do not use this approach in production environments.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "the37lab_authlib"
7
- version = "0.1.1751369506"
7
+ version = "0.1.1756371198"
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"]
@@ -11,12 +11,22 @@ import bcrypt
11
11
  import logging
12
12
  import os
13
13
  from functools import wraps
14
+ from isodate import parse_duration
15
+ import threading
16
+ import time
14
17
 
15
18
  logging.basicConfig(level=logging.DEBUG)
16
19
  logger = logging.getLogger(__name__)
17
20
 
18
21
  class AuthManager:
19
- def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None, api_tokens=None):
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
+ self.user_override = None
24
+ self._user_cache = {}
25
+ self._cache_ttl = cache_ttl or 10 # 10 seconds
26
+ self._last_used_updates = {} # Track pending updates
27
+ self._update_lock = threading.Lock()
28
+ self._update_thread = None
29
+ self._shutdown_event = threading.Event()
20
30
  if environment_prefix:
21
31
  prefix = environment_prefix.upper() + '_'
22
32
  db_dsn = os.getenv(f'{prefix}DATABASE_URL')
@@ -36,6 +46,15 @@ class AuthManager:
36
46
  if ':' in entry:
37
47
  key, user = entry.split(':', 1)
38
48
  api_tokens[key.strip()] = user.strip()
49
+ user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
50
+ if user_override_env:
51
+ self.user_override = user_override_env
52
+ else:
53
+ prefix = ''
54
+
55
+ self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
56
+ if self.user_override and (api_tokens or db_dsn):
57
+ raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
39
58
  if api_tokens and db_dsn:
40
59
  raise ValueError('Cannot set both api_tokens and db_dsn')
41
60
  self.api_tokens = api_tokens or None
@@ -54,6 +73,9 @@ class AuthManager:
54
73
 
55
74
  if app:
56
75
  self.init_app(app)
76
+
77
+ # Start the background update thread
78
+ self._start_update_thread()
57
79
 
58
80
  def _extract_token_from_header(self):
59
81
  auth = request.authorization
@@ -85,17 +107,34 @@ class AuthManager:
85
107
  }
86
108
  try:
87
109
  parsed = ApiToken.parse_token(api_token)
110
+
111
+ # Check cache first
112
+ cache_key = f"api_token_{parsed['id']}"
113
+ current_time = datetime.utcnow()
114
+
115
+ if cache_key in self._user_cache:
116
+ cached_data, cache_time = self._user_cache[cache_key]
117
+ if (current_time - cache_time).total_seconds() < self._cache_ttl:
118
+ logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
119
+ return cached_data.copy() # Return a copy to avoid modifying cache
120
+
121
+ # Cache miss or expired, fetch from database
88
122
  with self.db.get_cursor() as cur:
89
123
  # First get the API token record
90
124
  cur.execute("""
91
- SELECT t.*, u.* FROM api_tokens t
125
+ SELECT t.*, u.*, r.name as role_name FROM api_tokens t
92
126
  JOIN users u ON t.user_id = u.id
127
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
128
+ LEFT JOIN roles r ON ur.role_id = r.id
93
129
  WHERE t.id = %s
94
130
  """, (parsed['id'],))
95
- result = cur.fetchone()
96
- if not result:
131
+ results = cur.fetchall()
132
+ if not results:
97
133
  raise AuthError('Invalid API token')
98
134
 
135
+ # Get the first row for token/user data (all rows will have same token/user data)
136
+ result = results[0]
137
+
99
138
  # Verify the nonce
100
139
  if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
101
140
  raise AuthError('Invalid API token')
@@ -104,33 +143,40 @@ class AuthManager:
104
143
  if result['expires_at'] and result['expires_at'] < datetime.utcnow():
105
144
  raise AuthError('API token has expired')
106
145
 
107
- # Update last used timestamp
108
- cur.execute("""
109
- UPDATE api_tokens
110
- SET last_used_at = %s
111
- WHERE id = %s
112
- """, (datetime.utcnow(), parsed['id']))
146
+ # Schedule last used timestamp update (asynchronous with 10s delay)
147
+ self._schedule_last_used_update(parsed['id'])
113
148
 
114
- # Fetch roles
115
- cur.execute("""
116
- SELECT r.name FROM roles r
117
- JOIN user_roles ur ON ur.role_id = r.id
118
- WHERE ur.user_id = %s
119
- """, (result['user_id'],))
120
- roles = [row['name'] for row in cur.fetchall()]
149
+ # Extract roles from results
150
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
121
151
 
122
152
  # Construct user object
123
- return {
153
+ user_data = {
124
154
  'id': result['user_id'],
125
155
  'username': result['username'],
126
156
  'email': result['email'],
127
157
  'real_name': result['real_name'],
128
158
  'roles': roles
129
159
  }
160
+
161
+ # Cache the result
162
+ self._user_cache[cache_key] = (user_data.copy(), current_time)
163
+
164
+ # Clean up expired cache entries
165
+ self._cleanup_cache()
166
+
167
+ return user_data
130
168
  except ValueError:
131
169
  raise AuthError('Invalid token format')
132
170
 
133
171
  def _authenticate_request(self):
172
+ if self.user_override:
173
+ return {
174
+ 'id': self.user_override,
175
+ 'username': self.user_override,
176
+ 'email': '',
177
+ 'real_name': self.user_override,
178
+ 'roles': []
179
+ }
134
180
  auth_header = request.headers.get('Authorization')
135
181
  api_token = request.headers.get('X-API-Token')
136
182
 
@@ -422,21 +468,42 @@ class AuthManager:
422
468
  logger.debug(f"Token payload: {payload}")
423
469
  user_id = int(payload['sub']) # Convert string ID back to integer
424
470
 
471
+ # Check cache first
472
+ cache_key = f"user_{user_id}"
473
+ current_time = datetime.utcnow()
474
+
475
+ if cache_key in self._user_cache:
476
+ cached_data, cache_time = self._user_cache[cache_key]
477
+ if (current_time - cache_time).total_seconds() < self._cache_ttl:
478
+ logger.debug(f"Returning cached user data for ID: {user_id}")
479
+ return cached_data.copy() # Return a copy to avoid modifying cache
480
+
481
+ # Cache miss or expired, fetch from database
425
482
  with self.db.get_cursor() as cur:
426
- cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
427
- user = cur.fetchone()
428
- if not user:
429
- logger.error(f"User not found for ID: {user_id}")
430
- raise AuthError('User not found', 404)
431
- # Fetch roles
432
483
  cur.execute("""
433
- SELECT r.name FROM roles r
434
- JOIN user_roles ur ON ur.role_id = r.id
435
- WHERE ur.user_id = %s
484
+ SELECT u.*, r.name as role_name FROM users u
485
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
486
+ LEFT JOIN roles r ON ur.role_id = r.id
487
+ WHERE u.id = %s
436
488
  """, (user_id,))
437
- roles = [row['name'] for row in cur.fetchall()]
489
+ results = cur.fetchall()
490
+ if not results:
491
+ logger.error(f"User not found for ID: {user_id}")
492
+ raise AuthError('User not found', 404)
493
+
494
+ # Get the first row for user data (all rows will have same user data)
495
+ user = results[0]
496
+
497
+ # Extract roles from results
498
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
438
499
  user['roles'] = roles
439
500
 
501
+ # Cache the result
502
+ self._user_cache[cache_key] = (user.copy(), current_time)
503
+
504
+ # Clean up expired cache entries
505
+ self._cleanup_cache()
506
+
440
507
  return user
441
508
  except jwt.InvalidTokenError as e:
442
509
  logger.error(f"Invalid token error: {str(e)}")
@@ -445,6 +512,78 @@ class AuthManager:
445
512
  logger.error(f"Unexpected error during token validation: {str(e)}")
446
513
  raise AuthError(str(e), 500)
447
514
 
515
+ def _cleanup_cache(self):
516
+ """Remove expired cache entries."""
517
+ current_time = datetime.utcnow()
518
+ expired_keys = [
519
+ key for key, (_, cache_time) in self._user_cache.items()
520
+ if (current_time - cache_time).total_seconds() >= self._cache_ttl
521
+ ]
522
+ for key in expired_keys:
523
+ del self._user_cache[key]
524
+
525
+ def _start_update_thread(self):
526
+ """Start the background thread for processing last_used_at updates."""
527
+ if self._update_thread is None or not self._update_thread.is_alive():
528
+ self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
529
+ self._update_thread.start()
530
+ logger.debug("Started background update thread")
531
+
532
+ def _schedule_last_used_update(self, token_id):
533
+ """Schedule a last_used_at update for an API token with 10s delay."""
534
+ with self._update_lock:
535
+ self._last_used_updates[token_id] = time.time()
536
+ logger.debug(f"Scheduled last_used update for token {token_id}")
537
+
538
+ def _update_worker(self):
539
+ """Background worker that processes last_used_at updates."""
540
+ while not self._shutdown_event.is_set():
541
+ try:
542
+ current_time = time.time()
543
+ tokens_to_update = []
544
+
545
+ # Collect tokens that need updating (older than 10 seconds)
546
+ with self._update_lock:
547
+ for token_id, schedule_time in list(self._last_used_updates.items()):
548
+ if current_time - schedule_time >= 10: # 10 second delay
549
+ tokens_to_update.append(token_id)
550
+ del self._last_used_updates[token_id]
551
+
552
+ # Perform batch update
553
+ if tokens_to_update:
554
+ self._perform_batch_update(tokens_to_update)
555
+
556
+ # Sleep for a short interval
557
+ time.sleep(10)
558
+
559
+ except Exception as e:
560
+ logger.error(f"Error in update worker: {e}")
561
+ time.sleep(5) # Wait longer on error
562
+
563
+ def _perform_batch_update(self, token_ids):
564
+ """Perform batch update of last_used_at for multiple tokens."""
565
+ try:
566
+ with self.db.get_cursor() as cur:
567
+ # Update all tokens in a single query
568
+ placeholders = ','.join(['%s'] * len(token_ids))
569
+ cur.execute(f"""
570
+ UPDATE api_tokens
571
+ SET last_used_at = %s
572
+ WHERE id IN ({placeholders})
573
+ """, [datetime.utcnow()] + token_ids)
574
+
575
+ logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
576
+
577
+ except Exception as e:
578
+ logger.error(f"Error performing batch update: {e}")
579
+
580
+ def shutdown(self):
581
+ """Shutdown the background update thread."""
582
+ self._shutdown_event.set()
583
+ if self._update_thread and self._update_thread.is_alive():
584
+ self._update_thread.join(timeout=5)
585
+ logger.debug("Background update thread shutdown complete")
586
+
448
587
  def get_current_user(self):
449
588
  return self._authenticate_request()
450
589
 
@@ -473,12 +612,12 @@ class AuthManager:
473
612
  def _create_token(self, user):
474
613
  payload = {
475
614
  'sub': str(user['id']),
476
- 'exp': datetime.utcnow() + timedelta(hours=1),
615
+ 'exp': datetime.utcnow() + self.expiry_time,
477
616
  'iat': datetime.utcnow()
478
617
  }
479
618
  logger.debug(f"Creating token with payload: {payload}")
480
619
  token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
481
- logger.debug(f"Created token: {token}")
620
+ logger.info(f"Created token: {token}")
482
621
  return token
483
622
 
484
623
  def _create_refresh_token(self, user):
@@ -1,15 +1,28 @@
1
1
  import psycopg2
2
2
  from psycopg2.extras import RealDictCursor
3
+ from psycopg2 import pool
3
4
  from contextlib import contextmanager
4
5
  from .models import UUIDGenerator, IntegerGenerator
5
6
 
6
7
  class Database:
7
- def __init__(self, dsn, id_type='uuid'):
8
+ def __init__(self, dsn, id_type='uuid', min_conn=1, max_conn=10):
8
9
  self.dsn = dsn
9
10
  self.id_generator = UUIDGenerator() if id_type == 'uuid' else IntegerGenerator()
10
11
  self.id_type = id_type
12
+ self.min_conn = min_conn
13
+ self.max_conn = max_conn
14
+ self._pool = None
15
+ self._init_pool()
11
16
  self._init_db()
12
17
 
18
+ def _init_pool(self):
19
+ self._pool = pool.ThreadedConnectionPool(
20
+ self.min_conn,
21
+ self.max_conn,
22
+ self.dsn,
23
+ cursor_factory=RealDictCursor
24
+ )
25
+
13
26
  def _init_db(self):
14
27
  with self.get_connection() as conn:
15
28
  with conn.cursor() as cur:
@@ -54,7 +67,7 @@ class Database:
54
67
 
55
68
  @contextmanager
56
69
  def get_connection(self):
57
- conn = psycopg2.connect(self.dsn, cursor_factory=RealDictCursor)
70
+ conn = self._pool.getconn()
58
71
  try:
59
72
  yield conn
60
73
  conn.commit()
@@ -62,7 +75,7 @@ class Database:
62
75
  conn.rollback()
63
76
  raise
64
77
  finally:
65
- conn.close()
78
+ self._pool.putconn(conn)
66
79
 
67
80
  @contextmanager
68
81
  def get_cursor(self):
@@ -71,4 +84,8 @@ class Database:
71
84
  yield cur
72
85
 
73
86
  def get_id_generator(self):
74
- return self.id_generator
87
+ return self.id_generator
88
+
89
+ def close(self):
90
+ if self._pool:
91
+ self._pool.closeall()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: the37lab_authlib
3
- Version: 0.1.1751369506
3
+ Version: 0.1.1756371198
4
4
  Summary: Python SDK for the Authlib
5
5
  Author-email: the37lab <info@the37lab.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -42,6 +42,9 @@ A Python authentication library that provides JWT, OAuth2, and API token authent
42
42
  - [API Token Override for Testing](#api-token-override-for-testing)
43
43
  - [Usage](#usage)
44
44
  - [Warning](#warning)
45
+ - [User Override for Testing](#user-override-for-testing)
46
+ - [Usage](#usage-1)
47
+ - [Warning](#warning-1)
45
48
 
46
49
  ## Installation
47
50
 
@@ -233,3 +236,15 @@ For testing purposes, you can bypass the database and provide a static mapping o
233
236
  Replace `MYAPP` with your environment prefix.
234
237
 
235
238
  **Warning:** This method is intended only for testing and development. Do not use this approach in production environments.
239
+
240
+ ## User Override for Testing
241
+
242
+ For testing purposes, you can force all authentication to return a specific user by setting the `{PREFIX}USER_OVERRIDE` environment variable:
243
+
244
+ ```bash
245
+ export MYAPP_USER_OVERRIDE="testuser"
246
+ ```
247
+
248
+ If set, all requests will be authenticated as the specified user, regardless of any tokens or credentials provided. This cannot be combined with `api_tokens` or `db_dsn`.
249
+
250
+ **Warning:** This method is intended only for testing and development. Do not use this approach in production environments.