the37lab-authlib 0.1.1749238112__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.

@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: the37lab_authlib
3
+ Version: 0.1.1749238112
4
+ Summary: Python SDK for the Authlib
5
+ Author-email: the37lab <info@the37lab.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: Other/Proprietary License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: flask
12
+ Requires-Dist: psycopg2-binary
13
+ Requires-Dist: pyjwt
14
+ Requires-Dist: python-dotenv
15
+ Requires-Dist: requests
16
+ Requires-Dist: authlib
17
+ Requires-Dist: bcrypt
18
+
19
+ # AuthLib
20
+
21
+ A Python authentication library that provides JWT, OAuth2, and API token authentication with PostgreSQL backend.
22
+
23
+ ## Table of Contents
24
+ - [Installation](#installation)
25
+ - [Quick Start](#quick-start)
26
+ - [Configuration](#configuration)
27
+ - [API Endpoints](#api-endpoints)
28
+ - [Development](#development)
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install -e .
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from flask import Flask
40
+ from authlib import AuthManager
41
+
42
+ app = Flask(__name__)
43
+
44
+ auth = AuthManager(
45
+ app=app,
46
+ db_dsn="postgresql://user:pass@localhost/dbname",
47
+ jwt_secret="your-secret-key",
48
+ oauth_config={
49
+ "google": {
50
+ "client_id": "your-client-id",
51
+ "client_secret": "your-client-secret"
52
+ }
53
+ }
54
+ )
55
+
56
+ @app.route("/protected")
57
+ @auth.require_auth(roles=["admin"])
58
+ def protected_route():
59
+ return "Protected content"
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ ### Required Parameters
65
+ - `app`: Flask application instance
66
+ - `db_dsn`: PostgreSQL connection string
67
+ - `jwt_secret`: Secret key for JWT signing
68
+
69
+ ### Optional Parameters
70
+ - `oauth_config`: Dictionary of OAuth provider configurations
71
+ - `token_expiry`: JWT token expiry time in seconds (default: 3600)
72
+ - `refresh_token_expiry`: Refresh token expiry time in seconds (default: 2592000)
73
+
74
+ ## API Endpoints
75
+
76
+ ### Authentication
77
+ - `POST /v1/users/login` - Login with username/password
78
+ - `POST /v1/users/login/oauth` - Get OAuth redirect URL
79
+ - `GET /v1/users/login/oauth2callback` - OAuth callback
80
+ - `POST /v1/users/token-refresh` - Refresh JWT token
81
+
82
+ ### User Management
83
+ - `POST /v1/users/register` - Register new user
84
+ - `GET /v1/users/login/profile` - Get user profile
85
+ - `GET /v1/users/roles` - Get available roles
86
+
87
+ ### API Tokens
88
+ - `POST /v1/users/{user}/api-tokens` - Create API token
89
+ - `GET /v1/users/{user}/api-tokens` - List API tokens
90
+ - `DELETE /v1/users/{user}/api-tokens/{token_id}` - Delete API token
91
+
92
+ ## Development
93
+
94
+ ### Setup
95
+ 1. Clone the repository
96
+ 2. Create virtual environment:
97
+ ```bash
98
+ python -m venv venv
99
+ venv\Scripts\activate
100
+ ```
101
+ 3. Install dependencies:
102
+ ```bash
103
+ pip install -e ".[dev]"
104
+ ```
105
+
106
+ ### Database Setup
107
+ ```bash
108
+ createdb authlib
109
+ python -m authlib.cli db init
110
+ ```
111
+
112
+ ### Running Tests
113
+ ```bash
114
+ pytest
115
+ ```
@@ -0,0 +1,97 @@
1
+ # AuthLib
2
+
3
+ A Python authentication library that provides JWT, OAuth2, and API token authentication with PostgreSQL backend.
4
+
5
+ ## Table of Contents
6
+ - [Installation](#installation)
7
+ - [Quick Start](#quick-start)
8
+ - [Configuration](#configuration)
9
+ - [API Endpoints](#api-endpoints)
10
+ - [Development](#development)
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install -e .
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from flask import Flask
22
+ from authlib import AuthManager
23
+
24
+ app = Flask(__name__)
25
+
26
+ auth = AuthManager(
27
+ app=app,
28
+ db_dsn="postgresql://user:pass@localhost/dbname",
29
+ jwt_secret="your-secret-key",
30
+ oauth_config={
31
+ "google": {
32
+ "client_id": "your-client-id",
33
+ "client_secret": "your-client-secret"
34
+ }
35
+ }
36
+ )
37
+
38
+ @app.route("/protected")
39
+ @auth.require_auth(roles=["admin"])
40
+ def protected_route():
41
+ return "Protected content"
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ ### Required Parameters
47
+ - `app`: Flask application instance
48
+ - `db_dsn`: PostgreSQL connection string
49
+ - `jwt_secret`: Secret key for JWT signing
50
+
51
+ ### Optional Parameters
52
+ - `oauth_config`: Dictionary of OAuth provider configurations
53
+ - `token_expiry`: JWT token expiry time in seconds (default: 3600)
54
+ - `refresh_token_expiry`: Refresh token expiry time in seconds (default: 2592000)
55
+
56
+ ## API Endpoints
57
+
58
+ ### Authentication
59
+ - `POST /v1/users/login` - Login with username/password
60
+ - `POST /v1/users/login/oauth` - Get OAuth redirect URL
61
+ - `GET /v1/users/login/oauth2callback` - OAuth callback
62
+ - `POST /v1/users/token-refresh` - Refresh JWT token
63
+
64
+ ### User Management
65
+ - `POST /v1/users/register` - Register new user
66
+ - `GET /v1/users/login/profile` - Get user profile
67
+ - `GET /v1/users/roles` - Get available roles
68
+
69
+ ### API Tokens
70
+ - `POST /v1/users/{user}/api-tokens` - Create API token
71
+ - `GET /v1/users/{user}/api-tokens` - List API tokens
72
+ - `DELETE /v1/users/{user}/api-tokens/{token_id}` - Delete API token
73
+
74
+ ## Development
75
+
76
+ ### Setup
77
+ 1. Clone the repository
78
+ 2. Create virtual environment:
79
+ ```bash
80
+ python -m venv venv
81
+ venv\Scripts\activate
82
+ ```
83
+ 3. Install dependencies:
84
+ ```bash
85
+ pip install -e ".[dev]"
86
+ ```
87
+
88
+ ### Database Setup
89
+ ```bash
90
+ createdb authlib
91
+ python -m authlib.cli db init
92
+ ```
93
+
94
+ ### Running Tests
95
+ ```bash
96
+ pytest
97
+ ```
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "the37lab_authlib"
7
+ version = "0.1.1749238112"
8
+ description = "Python SDK for the Authlib"
9
+ authors = [{name = "the37lab", email = "info@the37lab.com"}]
10
+ dependencies = ["flask", "psycopg2-binary", "pyjwt", "python-dotenv", "requests", "authlib", "bcrypt"]
11
+ requires-python = ">=3.9"
12
+ readme = "README.md"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: Other/Proprietary License",
16
+ "Operating System :: OS Independent",
17
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .auth import AuthManager
2
+ from .decorators import require_auth
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["AuthManager", "require_auth"]
@@ -0,0 +1,513 @@
1
+ import inspect
2
+ from flask import Blueprint, request, jsonify, current_app, url_for, redirect
3
+ import jwt
4
+ from datetime import datetime, timedelta
5
+ from .db import Database
6
+ from .models import User, Role, ApiToken
7
+ from .exceptions import AuthError
8
+ import uuid
9
+ import requests
10
+ import bcrypt
11
+ import logging
12
+ import os
13
+ from functools import wraps
14
+
15
+ logging.basicConfig(level=logging.DEBUG)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ def handle_auth_errors(f):
19
+ @wraps(f)
20
+ def decorated(*args, **kwargs):
21
+ try:
22
+ return f(*args, **kwargs)
23
+ except AuthError as e:
24
+ response = jsonify(e.to_dict())
25
+ response.status_code = e.status_code
26
+ return response
27
+ return decorated
28
+
29
+ class AuthManager:
30
+ def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer'):
31
+ self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
32
+ self.jwt_secret = jwt_secret
33
+ logger.debug(f"Initializing AuthManager with JWT secret: {jwt_secret[:5]}..." if jwt_secret else "No JWT secret provided")
34
+ self.oauth_config = oauth_config or {}
35
+
36
+ if app:
37
+ self.init_app(app)
38
+
39
+ def _extract_token_from_header(self):
40
+ auth_header = request.headers.get('Authorization')
41
+ if not auth_header:
42
+ raise AuthError('No authorization header', 401)
43
+
44
+ try:
45
+ scheme, token = auth_header.split()
46
+ if scheme.lower() != 'bearer':
47
+ raise AuthError('Invalid authorization scheme', 401)
48
+
49
+ if not token:
50
+ raise AuthError('No token provided', 401)
51
+
52
+ return token
53
+ except ValueError:
54
+ raise AuthError('Invalid authorization header format', 401)
55
+
56
+ def _validate_api_token(self, api_token):
57
+ try:
58
+ parsed = ApiToken.parse_token(api_token)
59
+ with self.db.get_cursor() as cur:
60
+ # First get the API token record
61
+ cur.execute("""
62
+ SELECT t.*, u.* FROM api_tokens t
63
+ JOIN users u ON t.user_id = u.id
64
+ WHERE t.id = %s
65
+ """, (parsed['id'],))
66
+ result = cur.fetchone()
67
+ if not result:
68
+ raise AuthError('Invalid API token')
69
+
70
+ # Verify the nonce
71
+ if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
72
+ raise AuthError('Invalid API token')
73
+
74
+ # Check if token is expired
75
+ if result['expires_at'] and result['expires_at'] < datetime.utcnow():
76
+ raise AuthError('API token has expired')
77
+
78
+ # Update last used timestamp
79
+ cur.execute("""
80
+ UPDATE api_tokens
81
+ SET last_used_at = %s
82
+ WHERE id = %s
83
+ """, (datetime.utcnow(), parsed['id']))
84
+
85
+ # Fetch roles
86
+ cur.execute("""
87
+ SELECT r.name FROM roles r
88
+ JOIN user_roles ur ON ur.role_id = r.id
89
+ WHERE ur.user_id = %s
90
+ """, (result['user_id'],))
91
+ roles = [row['name'] for row in cur.fetchall()]
92
+
93
+ # Construct user object
94
+ return {
95
+ 'id': result['user_id'],
96
+ 'username': result['username'],
97
+ 'email': result['email'],
98
+ 'real_name': result['real_name'],
99
+ 'roles': roles
100
+ }
101
+ except ValueError:
102
+ raise AuthError('Invalid token format')
103
+
104
+ def _authenticate_request(self):
105
+ auth_header = request.headers.get('Authorization')
106
+ api_token = request.headers.get('X-API-Token')
107
+
108
+ if auth_header and auth_header.startswith('Bearer '):
109
+ # JWT authentication
110
+ token = self._extract_token_from_header()
111
+ return self.validate_token(token)
112
+ elif api_token:
113
+ # API token authentication
114
+ return self._validate_api_token(api_token)
115
+ else:
116
+ raise AuthError('No authentication provided', 401)
117
+
118
+ def require_auth(self, f):
119
+ @wraps(f)
120
+ def decorated(*args, **kwargs):
121
+ user = self._authenticate_request()
122
+ sig = inspect.signature(f)
123
+ if 'requesting_user' in sig.parameters:
124
+ kwargs['requesting_user'] = user
125
+
126
+ return f(*args, **kwargs)
127
+ return decorated
128
+
129
+ def init_app(self, app):
130
+ app.auth_manager = self
131
+ app.register_blueprint(self.create_blueprint())
132
+
133
+ def create_blueprint(self):
134
+ bp = Blueprint('auth', __name__, url_prefix='/v1/users')
135
+
136
+ @bp.route('/login', methods=['POST'])
137
+ @handle_auth_errors
138
+ def login():
139
+ data = request.get_json()
140
+ username = data.get('username')
141
+ password = data.get('password')
142
+
143
+ if not username or not password:
144
+ raise AuthError('Username and password required', 400)
145
+
146
+ with self.db.get_cursor() as cur:
147
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
148
+ user = cur.fetchone()
149
+
150
+ if not user or not self._verify_password(password, user['password_hash']):
151
+ raise AuthError('Invalid username or password', 401)
152
+
153
+ # Fetch roles
154
+ cur.execute("""
155
+ SELECT r.name FROM roles r
156
+ JOIN user_roles ur ON ur.role_id = r.id
157
+ WHERE ur.user_id = %s
158
+ """, (user['id'],))
159
+ roles = [row['name'] for row in cur.fetchall()]
160
+ user['roles'] = roles
161
+
162
+ token = self._create_token(user)
163
+ refresh_token = self._create_refresh_token(user)
164
+
165
+ return jsonify({
166
+ 'token': token,
167
+ 'refresh_token': refresh_token,
168
+ 'user': user
169
+ })
170
+
171
+ @bp.route('/login/oauth', methods=['POST'])
172
+ @handle_auth_errors
173
+ def oauth_login():
174
+ provider = request.json.get('provider')
175
+ if provider not in self.oauth_config:
176
+ raise AuthError('Invalid OAuth provider', 400)
177
+
178
+ redirect_uri = url_for('auth.oauth_callback', _external=True)
179
+ return jsonify({
180
+ 'redirect_url': self._get_oauth_url(provider, redirect_uri)
181
+ })
182
+
183
+ @bp.route('/login/oauth2callback')
184
+ @handle_auth_errors
185
+ def oauth_callback():
186
+ code = request.args.get('code')
187
+ provider = request.args.get('state')
188
+
189
+ if not code or not provider:
190
+ raise AuthError('Invalid OAuth callback', 400)
191
+
192
+ user_info = self._get_oauth_user_info(provider, code)
193
+ token = self._create_token(user_info)
194
+ refresh_token = self._create_refresh_token(user_info)
195
+
196
+ # Redirect to frontend with tokens
197
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
198
+ return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
199
+
200
+ @bp.route('/login/profile')
201
+ @handle_auth_errors
202
+ def profile():
203
+ token = request.headers.get('Authorization', '').split(' ')[-1]
204
+ user = self.validate_token(token)
205
+ return jsonify(user)
206
+
207
+ @bp.route('/api-tokens', methods=['GET'])
208
+ @handle_auth_errors
209
+ @self.require_auth
210
+ def get_tokens(requesting_user):
211
+ tokens = self.get_user_api_tokens(requesting_user['id'])
212
+ return jsonify(tokens)
213
+
214
+ @bp.route('/api-tokens', methods=['POST'])
215
+ @handle_auth_errors
216
+ @self.require_auth
217
+ def create_token(requesting_user):
218
+ name = request.json.get('name')
219
+ expires_in_days = request.json.get('expires_in_days')
220
+ if not name:
221
+ raise AuthError('Token name is required', 400)
222
+ api_token = self.create_api_token(requesting_user['id'], name, expires_in_days)
223
+ return jsonify({
224
+ 'id': api_token.id,
225
+ 'name': api_token.name,
226
+ 'token': api_token.get_full_token(),
227
+ 'created_at': api_token.created_at,
228
+ 'expires_at': api_token.expires_at
229
+ })
230
+
231
+ @bp.route('/token-refresh', methods=['POST'])
232
+ @handle_auth_errors
233
+ def refresh_token():
234
+ refresh_token = request.json.get('refresh_token')
235
+ if not refresh_token:
236
+ raise AuthError('No refresh token provided', 400)
237
+
238
+ try:
239
+ payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
240
+ user_id = payload['sub']
241
+
242
+ with self.db.get_cursor() as cur:
243
+ cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
244
+ user = cur.fetchone()
245
+
246
+ if not user:
247
+ raise AuthError('User not found', 404)
248
+
249
+ return jsonify({
250
+ 'token': self._create_token(user),
251
+ 'refresh_token': self._create_refresh_token(user)
252
+ })
253
+ except jwt.InvalidTokenError:
254
+ raise AuthError('Invalid refresh token', 401)
255
+
256
+ @bp.route('/api-tokens', methods=['POST'])
257
+ @handle_auth_errors
258
+ @self.require_auth
259
+ def create_api_token(requesting_user):
260
+ name = request.json.get('name')
261
+ if not name:
262
+ raise AuthError('Token name required', 400)
263
+
264
+ token = self.create_api_token(requesting_user['id'], name)
265
+ return jsonify({'token': token.token})
266
+
267
+ @bp.route('/api-tokens/validate', methods=['GET'])
268
+ @handle_auth_errors
269
+ @self.require_auth
270
+ def validate_api_token(requesting_user):
271
+ token = request.json.get('token')
272
+ if not token:
273
+ raise AuthError('No API token provided', 401)
274
+ token = ApiToken.parse_token_id(token)
275
+
276
+ with self.db.get_cursor() as cur:
277
+ cur.execute("""
278
+ SELECT * FROM api_tokens
279
+ WHERE user_id = %s AND id = %s
280
+ """, (requesting_user['id'], token))
281
+ api_token = cur.fetchone()
282
+
283
+ if not api_token:
284
+ raise AuthError('Invalid API token', 401)
285
+
286
+ # Check if token is expired
287
+ if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
288
+ raise AuthError('API token has expired', 401)
289
+
290
+ # Update last used timestamp
291
+ with self.db.get_cursor() as cur:
292
+ cur.execute("""
293
+ UPDATE api_tokens
294
+ SET last_used_at = %s
295
+ WHERE id = %s
296
+ """, (datetime.utcnow(), api_token['id']))
297
+
298
+ return jsonify({'valid': True})
299
+
300
+ @bp.route('/api-tokens', methods=['DELETE'])
301
+ @handle_auth_errors
302
+ @self.require_auth
303
+ def delete_api_token(requesting_user):
304
+ token = request.json.get('token')
305
+ if not token:
306
+ raise AuthError('Token required', 400)
307
+ token = ApiToken.parse_token_id(token)
308
+
309
+ with self.db.get_cursor() as cur:
310
+ cur.execute("""
311
+ DELETE FROM api_tokens
312
+ WHERE user_id = %s AND id = %s
313
+ RETURNING id
314
+ """, (requesting_user['id'], token))
315
+ deleted_id = cur.fetchone()
316
+ if not deleted_id:
317
+ raise ValueError('Token not found or already deleted')
318
+
319
+ return jsonify({'deleted': True})
320
+
321
+ @bp.route('/register', methods=['POST'])
322
+ @handle_auth_errors
323
+ def register():
324
+ data = request.get_json()
325
+
326
+ # Hash the password
327
+ password = data.get('password')
328
+ if not password:
329
+ raise AuthError('Password is required', 400)
330
+
331
+ salt = bcrypt.gensalt()
332
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
333
+
334
+ user = User(
335
+ username=data['username'],
336
+ email=data['email'],
337
+ real_name=data['real_name'],
338
+ roles=data.get('roles', []),
339
+ id_generator=self.db.get_id_generator()
340
+ )
341
+
342
+ with self.db.get_cursor() as cur:
343
+ if user.id is None:
344
+ cur.execute("""
345
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
346
+ VALUES (%s, %s, %s, %s, %s, %s)
347
+ RETURNING id
348
+ """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
349
+ user.created_at, user.updated_at))
350
+ user.id = cur.fetchone()['id']
351
+ else:
352
+ cur.execute("""
353
+ INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
354
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
355
+ """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
356
+ user.created_at, user.updated_at))
357
+
358
+ return jsonify({'id': user.id}), 201
359
+
360
+ @bp.route('/roles', methods=['GET'])
361
+ @handle_auth_errors
362
+ def get_roles():
363
+ with self.db.get_cursor() as cur:
364
+ cur.execute("SELECT * FROM roles")
365
+ roles = cur.fetchall()
366
+ return jsonify(roles)
367
+
368
+ return bp
369
+
370
+ def validate_token(self, token):
371
+ try:
372
+ logger.debug(f"Validating token: {token}")
373
+ payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
374
+ logger.debug(f"Token payload: {payload}")
375
+ user_id = int(payload['sub']) # Convert string ID back to integer
376
+
377
+ with self.db.get_cursor() as cur:
378
+ cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
379
+ user = cur.fetchone()
380
+ if not user:
381
+ logger.error(f"User not found for ID: {user_id}")
382
+ raise AuthError('User not found', 404)
383
+ # Fetch roles
384
+ cur.execute("""
385
+ SELECT r.name FROM roles r
386
+ JOIN user_roles ur ON ur.role_id = r.id
387
+ WHERE ur.user_id = %s
388
+ """, (user_id,))
389
+ roles = [row['name'] for row in cur.fetchall()]
390
+ user['roles'] = roles
391
+
392
+ return user
393
+ except jwt.InvalidTokenError as e:
394
+ logger.error(f"Invalid token error: {str(e)}")
395
+ raise AuthError('Invalid token', 401)
396
+ except Exception as e:
397
+ logger.error(f"Unexpected error during token validation: {str(e)}")
398
+ raise AuthError(str(e), 500)
399
+
400
+ def get_current_user(self):
401
+ return self._authenticate_request()
402
+
403
+ def get_user_api_tokens(self, user_id):
404
+ """Get all API tokens for a user."""
405
+ with self.db.get_cursor() as cur:
406
+ cur.execute("""
407
+ SELECT id, name, created_at, expires_at, last_used_at
408
+ FROM api_tokens
409
+ WHERE user_id = %s
410
+ ORDER BY created_at DESC
411
+ """, (user_id,))
412
+ return cur.fetchall()
413
+
414
+ def create_api_token(self, user_id, name, expires_in_days=None):
415
+ """Create a new API token for a user."""
416
+ token = ApiToken(user_id, name, expires_in_days)
417
+
418
+ with self.db.get_cursor() as cur:
419
+ cur.execute("""
420
+ INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
421
+ VALUES (%s, %s, %s, %s, %s, %s)
422
+ """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
423
+ return token
424
+
425
+ def _create_token(self, user):
426
+ payload = {
427
+ 'sub': str(user['id']),
428
+ 'exp': datetime.utcnow() + timedelta(hours=1),
429
+ 'iat': datetime.utcnow()
430
+ }
431
+ logger.debug(f"Creating token with payload: {payload}")
432
+ token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
433
+ logger.debug(f"Created token: {token}")
434
+ return token
435
+
436
+ def _create_refresh_token(self, user):
437
+ payload = {
438
+ 'sub': str(user['id']),
439
+ 'exp': datetime.utcnow() + timedelta(days=30),
440
+ 'iat': datetime.utcnow()
441
+ }
442
+ return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
443
+
444
+ def _verify_password(self, password, password_hash):
445
+ return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
446
+
447
+ def _get_oauth_url(self, provider, redirect_uri):
448
+ if provider == 'google':
449
+ client_id = self.oauth_config['google']['client_id']
450
+ scope = 'openid email profile'
451
+ state = provider # Pass provider as state for callback
452
+ 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}'
453
+ raise AuthError('Invalid OAuth provider')
454
+
455
+ def _get_oauth_user_info(self, provider, code):
456
+ if provider == 'google':
457
+ client_id = self.oauth_config['google']['client_id']
458
+ client_secret = self.oauth_config['google']['client_secret']
459
+ redirect_uri = url_for('auth.oauth_callback', _external=True)
460
+
461
+ # Exchange code for tokens
462
+ token_url = 'https://oauth2.googleapis.com/token'
463
+ token_data = {
464
+ 'client_id': client_id,
465
+ 'client_secret': client_secret,
466
+ 'code': code,
467
+ 'grant_type': 'authorization_code',
468
+ 'redirect_uri': redirect_uri
469
+ }
470
+ token_response = requests.post(token_url, data=token_data)
471
+ token_response.raise_for_status()
472
+ tokens = token_response.json()
473
+
474
+ # Get user info
475
+ userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
476
+ userinfo_response = requests.get(
477
+ userinfo_url,
478
+ headers={'Authorization': f"Bearer {tokens['access_token']}"}
479
+ )
480
+ userinfo_response.raise_for_status()
481
+ userinfo = userinfo_response.json()
482
+
483
+ # Create or update user
484
+ with self.db.get_cursor() as cur:
485
+ cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
486
+ user = cur.fetchone()
487
+
488
+ if not user:
489
+ # Create new user
490
+ user = User(
491
+ username=userinfo['email'],
492
+ email=userinfo['email'],
493
+ real_name=userinfo.get('name', userinfo['email']),
494
+ id_generator=self.db.get_id_generator()
495
+ )
496
+ cur.execute("""
497
+ INSERT INTO users (id, username, email, real_name, created_at, updated_at)
498
+ VALUES (%s, %s, %s, %s, %s, %s)
499
+ """, (user.id, user.username, user.email, user.real_name,
500
+ user.created_at, user.updated_at))
501
+ user = {'id': user.id, 'username': user.username, 'email': user.email,
502
+ 'real_name': user.real_name, 'roles': []}
503
+ else:
504
+ # Update existing user
505
+ cur.execute("""
506
+ UPDATE users
507
+ SET real_name = %s, updated_at = %s
508
+ WHERE email = %s
509
+ """, (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
510
+ user['real_name'] = userinfo.get('name', userinfo['email'])
511
+
512
+ return user
513
+ raise AuthError('Invalid OAuth provider')
@@ -0,0 +1,74 @@
1
+ import psycopg2
2
+ from psycopg2.extras import RealDictCursor
3
+ from contextlib import contextmanager
4
+ from .models import UUIDGenerator, IntegerGenerator
5
+
6
+ class Database:
7
+ def __init__(self, dsn, id_type='uuid'):
8
+ self.dsn = dsn
9
+ self.id_generator = UUIDGenerator() if id_type == 'uuid' else IntegerGenerator()
10
+ self.id_type = id_type
11
+ self._init_db()
12
+
13
+ def _init_db(self):
14
+ with self.get_connection() as conn:
15
+ with conn.cursor() as cur:
16
+ # Create users table with configurable ID type
17
+ cur.execute(f"""
18
+ CREATE TABLE IF NOT EXISTS users (
19
+ id {self._get_id_type()} PRIMARY KEY,
20
+ username VARCHAR(255) UNIQUE NOT NULL,
21
+ email VARCHAR(255) UNIQUE NOT NULL,
22
+ real_name VARCHAR(255) NOT NULL,
23
+ password_hash VARCHAR(255),
24
+ created_at TIMESTAMP NOT NULL,
25
+ updated_at TIMESTAMP NOT NULL
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS roles (
29
+ id {self._get_id_type()} PRIMARY KEY,
30
+ name VARCHAR(255) UNIQUE NOT NULL,
31
+ description TEXT,
32
+ created_at TIMESTAMP NOT NULL
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS user_roles (
36
+ user_id {self._get_id_type()} REFERENCES users(id),
37
+ role_id {self._get_id_type()} REFERENCES roles(id),
38
+ PRIMARY KEY (user_id, role_id)
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS api_tokens (
42
+ id VARCHAR(8) PRIMARY KEY,
43
+ user_id INTEGER REFERENCES users(id),
44
+ name VARCHAR(255) NOT NULL,
45
+ token VARCHAR(255) NOT NULL,
46
+ created_at TIMESTAMP NOT NULL,
47
+ expires_at TIMESTAMP,
48
+ last_used_at TIMESTAMP
49
+ );
50
+ """)
51
+
52
+ def _get_id_type(self):
53
+ return 'UUID' if self.id_type == 'uuid' else 'SERIAL'
54
+
55
+ @contextmanager
56
+ def get_connection(self):
57
+ conn = psycopg2.connect(self.dsn, cursor_factory=RealDictCursor)
58
+ try:
59
+ yield conn
60
+ conn.commit()
61
+ except Exception:
62
+ conn.rollback()
63
+ raise
64
+ finally:
65
+ conn.close()
66
+
67
+ @contextmanager
68
+ def get_cursor(self):
69
+ with self.get_connection() as conn:
70
+ with conn.cursor() as cur:
71
+ yield cur
72
+
73
+ def get_id_generator(self):
74
+ return self.id_generator
@@ -0,0 +1,31 @@
1
+ from functools import wraps
2
+ from flask import request, current_app, jsonify
3
+ from .exceptions import AuthError
4
+
5
+ def require_auth(roles=None):
6
+ def decorator(f):
7
+ @wraps(f)
8
+ def decorated(*args, **kwargs):
9
+ try:
10
+ # Get the require_auth decorator from AuthManager
11
+ user = current_app.auth_manager.get_current_user()
12
+ if not user:
13
+ raise AuthError('User not authenticated', 401)
14
+
15
+ auth_decorator = current_app.auth_manager.require_auth
16
+
17
+ # Apply the AuthManager's decorator and get the result
18
+ decorated_func = auth_decorator(f)
19
+
20
+ # Check roles if specified
21
+ if roles and not any(role in user['roles'] for role in roles):
22
+ raise AuthError('Insufficient permissions', 403)
23
+
24
+ # Now execute the function
25
+ return decorated_func(*args, **kwargs)
26
+ except AuthError as e:
27
+ response = jsonify(e.to_dict())
28
+ response.status_code = e.status_code
29
+ return response
30
+ return decorated
31
+ return decorator
@@ -0,0 +1,11 @@
1
+ class AuthError(Exception):
2
+ def __init__(self, message, status_code=401):
3
+ self.message = message
4
+ self.status_code = status_code
5
+ super().__init__(self.message)
6
+
7
+ def to_dict(self):
8
+ return {
9
+ 'error': self.message,
10
+ 'status_code': self.status_code
11
+ }
@@ -0,0 +1,95 @@
1
+ from datetime import datetime, timedelta
2
+ import uuid
3
+ import bcrypt
4
+ import secrets
5
+ import string
6
+ from abc import ABC, abstractmethod
7
+
8
+ def generate_random_string(length, alphabet=string.ascii_letters + string.digits):
9
+ """Generate a random string of specified length using the given alphabet."""
10
+ return ''.join(secrets.choice(alphabet) for _ in range(length))
11
+
12
+ class IDGenerator(ABC):
13
+ @abstractmethod
14
+ def generate(self):
15
+ pass
16
+
17
+ class UUIDGenerator(IDGenerator):
18
+ def generate(self):
19
+ return str(uuid.uuid4())
20
+
21
+ class IntegerGenerator(IDGenerator):
22
+ def generate(self):
23
+ return None # Let the database handle ID generation with SERIAL
24
+
25
+ class User:
26
+ def __init__(self, username, email, real_name, roles=None, id_generator=None):
27
+ self.id = id_generator.generate() if id_generator else str(uuid.uuid4())
28
+ if self.id is None: # Let database handle ID generation
29
+ self.id = None
30
+ self.username = username
31
+ self.email = email
32
+ self.real_name = real_name
33
+ self.roles = roles or []
34
+ self.created_at = datetime.utcnow()
35
+ self.updated_at = datetime.utcnow()
36
+
37
+ class Role:
38
+ def __init__(self, name, description=None, id_generator=None):
39
+ self.id = id_generator.generate() if id_generator else str(uuid.uuid4())
40
+ self.name = name
41
+ self.description = description
42
+ self.created_at = datetime.utcnow()
43
+
44
+ class ApiToken:
45
+ def __init__(self, user_id, name, expires_in_days=None):
46
+ self.id = generate_random_string(8) # 8 character ID
47
+ self.user_id = user_id
48
+ self.name = name
49
+ self.nonce = generate_random_string(32) # 32 character nonce
50
+ self.token = self._hash_nonce(self.nonce) # Hash the nonce
51
+ self.created_at = datetime.utcnow()
52
+ self.expires_at = datetime.utcnow() + timedelta(days=expires_in_days) if expires_in_days else None
53
+ self.last_used_at = None
54
+
55
+ def _hash_nonce(self, nonce):
56
+ """Hash the nonce using bcrypt."""
57
+ salt = bcrypt.gensalt()
58
+ return bcrypt.hashpw(nonce.encode('utf-8'), salt).decode('utf-8')
59
+
60
+ def get_full_token(self):
61
+ """Get the full token string in the format api_IDNONCE."""
62
+ return f"api_{self.id}{self.nonce}"
63
+
64
+ @staticmethod
65
+ def parse_token(token_string):
66
+ """Parse a token string into its components."""
67
+ if not token_string.startswith('api_'):
68
+ raise ValueError('Invalid token format')
69
+
70
+ token_string = token_string[4:] # Remove 'api_' prefix
71
+ if len(token_string) != 40: # 8 (id) + 32 (nonce)
72
+ raise ValueError('Invalid token length')
73
+
74
+ return {
75
+ 'id': token_string[:8],
76
+ 'nonce': token_string[8:]
77
+ }
78
+
79
+ @staticmethod
80
+ def parse_token_id(token_string):
81
+ if len(token_string) == 8:
82
+ return token_string
83
+ if not token_string.startswith('api_'):
84
+ raise ValueError('Invalid token format')
85
+ return token_string[4:][:8]
86
+
87
+ def verify_token(self, token_string):
88
+ """Verify if a token string matches this token."""
89
+ try:
90
+ parsed = self.parse_token(token_string)
91
+ if parsed['id'] != self.id:
92
+ return False
93
+ return bcrypt.checkpw(parsed['nonce'].encode('utf-8'), self.token.encode('utf-8'))
94
+ except ValueError:
95
+ return False
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: the37lab_authlib
3
+ Version: 0.1.1749238112
4
+ Summary: Python SDK for the Authlib
5
+ Author-email: the37lab <info@the37lab.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: Other/Proprietary License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: flask
12
+ Requires-Dist: psycopg2-binary
13
+ Requires-Dist: pyjwt
14
+ Requires-Dist: python-dotenv
15
+ Requires-Dist: requests
16
+ Requires-Dist: authlib
17
+ Requires-Dist: bcrypt
18
+
19
+ # AuthLib
20
+
21
+ A Python authentication library that provides JWT, OAuth2, and API token authentication with PostgreSQL backend.
22
+
23
+ ## Table of Contents
24
+ - [Installation](#installation)
25
+ - [Quick Start](#quick-start)
26
+ - [Configuration](#configuration)
27
+ - [API Endpoints](#api-endpoints)
28
+ - [Development](#development)
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install -e .
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from flask import Flask
40
+ from authlib import AuthManager
41
+
42
+ app = Flask(__name__)
43
+
44
+ auth = AuthManager(
45
+ app=app,
46
+ db_dsn="postgresql://user:pass@localhost/dbname",
47
+ jwt_secret="your-secret-key",
48
+ oauth_config={
49
+ "google": {
50
+ "client_id": "your-client-id",
51
+ "client_secret": "your-client-secret"
52
+ }
53
+ }
54
+ )
55
+
56
+ @app.route("/protected")
57
+ @auth.require_auth(roles=["admin"])
58
+ def protected_route():
59
+ return "Protected content"
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ ### Required Parameters
65
+ - `app`: Flask application instance
66
+ - `db_dsn`: PostgreSQL connection string
67
+ - `jwt_secret`: Secret key for JWT signing
68
+
69
+ ### Optional Parameters
70
+ - `oauth_config`: Dictionary of OAuth provider configurations
71
+ - `token_expiry`: JWT token expiry time in seconds (default: 3600)
72
+ - `refresh_token_expiry`: Refresh token expiry time in seconds (default: 2592000)
73
+
74
+ ## API Endpoints
75
+
76
+ ### Authentication
77
+ - `POST /v1/users/login` - Login with username/password
78
+ - `POST /v1/users/login/oauth` - Get OAuth redirect URL
79
+ - `GET /v1/users/login/oauth2callback` - OAuth callback
80
+ - `POST /v1/users/token-refresh` - Refresh JWT token
81
+
82
+ ### User Management
83
+ - `POST /v1/users/register` - Register new user
84
+ - `GET /v1/users/login/profile` - Get user profile
85
+ - `GET /v1/users/roles` - Get available roles
86
+
87
+ ### API Tokens
88
+ - `POST /v1/users/{user}/api-tokens` - Create API token
89
+ - `GET /v1/users/{user}/api-tokens` - List API tokens
90
+ - `DELETE /v1/users/{user}/api-tokens/{token_id}` - Delete API token
91
+
92
+ ## Development
93
+
94
+ ### Setup
95
+ 1. Clone the repository
96
+ 2. Create virtual environment:
97
+ ```bash
98
+ python -m venv venv
99
+ venv\Scripts\activate
100
+ ```
101
+ 3. Install dependencies:
102
+ ```bash
103
+ pip install -e ".[dev]"
104
+ ```
105
+
106
+ ### Database Setup
107
+ ```bash
108
+ createdb authlib
109
+ python -m authlib.cli db init
110
+ ```
111
+
112
+ ### Running Tests
113
+ ```bash
114
+ pytest
115
+ ```
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/the37lab_authlib/__init__.py
4
+ src/the37lab_authlib/auth.py
5
+ src/the37lab_authlib/db.py
6
+ src/the37lab_authlib/decorators.py
7
+ src/the37lab_authlib/exceptions.py
8
+ src/the37lab_authlib/models.py
9
+ src/the37lab_authlib.egg-info/PKG-INFO
10
+ src/the37lab_authlib.egg-info/SOURCES.txt
11
+ src/the37lab_authlib.egg-info/dependency_links.txt
12
+ src/the37lab_authlib.egg-info/requires.txt
13
+ src/the37lab_authlib.egg-info/top_level.txt
@@ -0,0 +1,7 @@
1
+ flask
2
+ psycopg2-binary
3
+ pyjwt
4
+ python-dotenv
5
+ requests
6
+ authlib
7
+ bcrypt