flask-advanced-auth 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+ """
3
+ Module contains proxy class for JWT token encoding and decoding.
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any, Optional
8
+ import jwt
9
+ from .jwt_rules import JWTAuthenticationRules
10
+ from datetime import datetime, timezone, timedelta
11
+
12
+
13
+ class JsonWebToken(ABC):
14
+ """Abstract base class for JWT encode/decode operations (Proxy pattern)."""
15
+
16
+ @abstractmethod
17
+ def encode_token(self, payload: dict, secret_key: str, algorithm: str) -> str:
18
+ """Encode a payload into a JWT token string."""
19
+ pass
20
+
21
+ @abstractmethod
22
+ def decode_token(self, token: str, secret_key: str, algorithm: str) -> Optional[dict]:
23
+ """Decode a JWT token string into a payload dict."""
24
+ pass
25
+
26
+
27
+ class PyJWT(JsonWebToken):
28
+ """Concrete JWT proxy using the PyJWT library."""
29
+
30
+ @property
31
+ def rule_handler(self) -> JWTAuthenticationRules:
32
+ return JWTAuthenticationRules()
33
+
34
+ def required_payload(self, rule_name: str, token_type: str) -> dict[str, Any]:
35
+ """Build the standard required payload fields (type + expiry)."""
36
+ token_lifetime: timedelta = self.rule_handler.get_token_lifetime(
37
+ rule_name, token_type
38
+ )
39
+ return {
40
+ "type": token_type,
41
+ "exp": datetime.now(timezone.utc) + token_lifetime,
42
+ }
43
+
44
+ def encode_token(
45
+ self,
46
+ payload: dict,
47
+ secret_key: str,
48
+ algorithm: str,
49
+ *args: Any,
50
+ **kwargs: Any,
51
+ ) -> str:
52
+ """Encode a payload dict into a signed JWT string."""
53
+ return jwt.encode(payload, key=secret_key, algorithm=algorithm, *args, **kwargs)
54
+
55
+ def decode_token(
56
+ self,
57
+ token: str,
58
+ secret_key: str,
59
+ algorithm: str,
60
+ *args: Any,
61
+ **kwargs: Any,
62
+ ) -> Optional[dict]:
63
+ """Decode and verify a JWT token."""
64
+ try:
65
+ return jwt.decode(
66
+ token, key=secret_key, algorithms=[algorithm], *args, **kwargs
67
+ )
68
+ except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
69
+ return None
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+ """
3
+ flask-advanced-auth
4
+ ===================
5
+ Advanced JWT authentication for Flask with multi-rule support,
6
+ token tracking, and blacklisting.
7
+
8
+ MIT License
9
+ """
10
+
11
+ from .initialize import initialize_jwt_authentication
12
+ from .jwt_rules import JWTAuthenticationRules
13
+ from .factory import TokenFactory
14
+ from .decorators import authenticate
15
+ from .blacklist_token import BlackListed
16
+ from .authentication_classes import (
17
+ BaseAuthentication,
18
+ AuthenticateJWT,
19
+ HeaderAuthentication,
20
+ CookieAuthentication,
21
+ QueryParamAuthentication,
22
+ )
23
+ from .helpers import refresh_access_token, logout
24
+ from .models import AbstractTrackModelMixin, AbstractBlackListModelMixin
25
+ from .database import Database
26
+ from .exceptions import (
27
+ InvalidDefaultKeyType,
28
+ JWTRulesEmpty,
29
+ DefaultRuleDoesNotExist,
30
+ InvalidJWTRule,
31
+ JWTFieldValueError,
32
+ TokenExpired,
33
+ TokenInvalid,
34
+ TokenBlacklisted,
35
+ TokenTypeMismatch,
36
+ )
37
+
38
+ __version__ = "0.1.0"
39
+ __author__ = "Waheed"
40
+ __license__ = "MIT"
41
+
42
+ __all__ = [
43
+ # Core bootstrap & rules
44
+ "initialize_jwt_authentication",
45
+ "JWTAuthenticationRules",
46
+ "Database",
47
+ # Factory & Decorator
48
+ "TokenFactory",
49
+ "authenticate",
50
+ "BlackListed",
51
+ # Authentication Strategies
52
+ "BaseAuthentication",
53
+ "AuthenticateJWT",
54
+ "HeaderAuthentication",
55
+ "CookieAuthentication",
56
+ "QueryParamAuthentication",
57
+ # Helpers
58
+ "refresh_access_token",
59
+ "logout",
60
+ # Model Mixins
61
+ "AbstractTrackModelMixin",
62
+ "AbstractBlackListModelMixin",
63
+ # Exceptions
64
+ "InvalidDefaultKeyType",
65
+ "JWTRulesEmpty",
66
+ "DefaultRuleDoesNotExist",
67
+ "InvalidJWTRule",
68
+ "JWTFieldValueError",
69
+ "TokenExpired",
70
+ "TokenInvalid",
71
+ "TokenBlacklisted",
72
+ "TokenTypeMismatch",
73
+ ]
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+ """
3
+ Module contains base and concrete authentication classes.
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any, Optional, Union
8
+ from flask import Request
9
+ from .blacklist_token import BlackListed
10
+ from .factory import TokenFactory
11
+
12
+
13
+ class BaseAuthentication(ABC):
14
+ """Abstract base class for authentication strategies.
15
+
16
+ Subclass this to implement custom token extraction and validation logic.
17
+
18
+ Args:
19
+ token_headers: The prefix expected in the Authorization header (e.g. 'Bearer ').
20
+ token_type: The expected token type field value (e.g. 'access', 'refresh').
21
+ rule_name: The JWT rule name to use for validation.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ token_headers: str = "Bearer ",
27
+ token_type: str = "access",
28
+ rule_name: Optional[str] = None,
29
+ ) -> None:
30
+ self.token_headers: str = token_headers
31
+ self.token_type: str = token_type
32
+ self.rule_name: Optional[str] = rule_name
33
+
34
+ def parse_token(self, request: Request) -> Optional[str]:
35
+ """Extract the JWT string from the request Authorization header.
36
+
37
+ Expects the header format: ``Authorization: <prefix><token>``
38
+ where ``prefix`` is ``self.token_headers`` (e.g. ``'Bearer '``).
39
+ """
40
+ auth_header: Optional[str] = request.headers.get("Authorization")
41
+ if not auth_header:
42
+ return None
43
+ if not auth_header.startswith(self.token_headers):
44
+ return None
45
+ token = auth_header[len(self.token_headers):]
46
+ if not token:
47
+ return None
48
+ return token
49
+
50
+ @abstractmethod
51
+ def authenticate(self, token: str) -> Optional[dict[str, Any]]:
52
+ """Validate a token and return its payload, or None if invalid."""
53
+ pass
54
+
55
+ def authenticate_request(self, request: Request) -> Optional[dict[str, Any]]:
56
+ """Extract and authenticate the token from the request.
57
+
58
+ Args:
59
+ request: The Flask request object.
60
+
61
+ Returns:
62
+ The decoded token payload dict if valid, None otherwise.
63
+ """
64
+ token = self.parse_token(request)
65
+ if token is None:
66
+ return None
67
+ return self.authenticate(token)
68
+
69
+
70
+ class AuthenticateJWT(BaseAuthentication):
71
+ """Standard JWT authentication — validates token extracted from Authorization header.
72
+
73
+ Checks:
74
+ 1. Token is not blacklisted (if blacklisting is enabled for the rule).
75
+ 2. Token signature and expiry are valid.
76
+ 3. Token type matches the expected type.
77
+ """
78
+
79
+ def validate_token(self, token: str) -> Optional[dict[str, Any]]:
80
+ """Decode and verify a token using the configured rule."""
81
+ try:
82
+ return TokenFactory().validate_token(token, self.rule_name)
83
+ except Exception:
84
+ return None
85
+
86
+ def validate_token_type(self, decoded_payload: dict[str, Any]) -> bool:
87
+ """Check that the decoded payload's 'type' field matches self.token_type."""
88
+ return decoded_payload.get("type") == self.token_type
89
+
90
+ def authenticate(self, token: str) -> Optional[dict[str, Any]]:
91
+ """Validate a JWT token string.
92
+
93
+ Args:
94
+ token: The raw JWT string.
95
+
96
+ Returns:
97
+ The decoded payload if all checks pass, None otherwise.
98
+ """
99
+ if BlackListed().is_blacklisted(self.rule_name, token):
100
+ return None
101
+
102
+ payload: Optional[dict[str, Any]] = self.validate_token(token)
103
+ if payload is None:
104
+ return None
105
+
106
+ if not self.validate_token_type(payload):
107
+ return None
108
+
109
+ return payload
110
+
111
+
112
+ class HeaderAuthentication(AuthenticateJWT):
113
+ """Explicit alias for Header-based JWT authentication."""
114
+ pass
115
+
116
+
117
+ class CookieAuthentication(AuthenticateJWT):
118
+ """JWT authentication that extracts tokens from HTTP cookies.
119
+
120
+ Args:
121
+ token_headers: Unused (kept for interface compatibility).
122
+ token_type: Expected token type ('access' or 'refresh').
123
+ rule_name: JWT rule name.
124
+ cookie_name: Name of the cookie containing the token (default: 'access_token').
125
+ """
126
+
127
+ cookie_name: str = "access_token"
128
+
129
+ def __init__(
130
+ self,
131
+ token_headers: str = "Bearer ",
132
+ token_type: str = "access",
133
+ rule_name: Optional[str] = None,
134
+ cookie_name: str = "access_token",
135
+ ) -> None:
136
+ super().__init__(token_headers=token_headers, token_type=token_type, rule_name=rule_name)
137
+ self.cookie_name = cookie_name
138
+
139
+ def parse_token(self, request: Request) -> Optional[str]:
140
+ """Extract the token from request cookies."""
141
+ return request.cookies.get(self.cookie_name)
142
+
143
+
144
+ class QueryParamAuthentication(AuthenticateJWT):
145
+ """JWT authentication that extracts tokens from URL query parameters.
146
+
147
+ Args:
148
+ token_headers: Unused (kept for interface compatibility).
149
+ token_type: Expected token type ('access' or 'refresh').
150
+ rule_name: JWT rule name.
151
+ param_name: Name of the query parameter (default: 'token').
152
+ """
153
+
154
+ param_name: str = "token"
155
+
156
+ def __init__(
157
+ self,
158
+ token_headers: str = "Bearer ",
159
+ token_type: str = "access",
160
+ rule_name: Optional[str] = None,
161
+ param_name: str = "token",
162
+ ) -> None:
163
+ super().__init__(token_headers=token_headers, token_type=token_type, rule_name=rule_name)
164
+ self.param_name = param_name
165
+
166
+ def parse_token(self, request: Request) -> Optional[str]:
167
+ """Extract the token from query arguments."""
168
+ return request.args.get(self.param_name)
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+ """
3
+ Module contains BlackListed class for token revocation.
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+ from .jwt_rules import JWTAuthenticationRules
8
+ from .model_managers import ModelManager
9
+ from typing import Any, Union, Optional
10
+
11
+
12
+ class AbstractBlackList(ABC):
13
+ """Abstract interface for token blacklisting."""
14
+
15
+ @abstractmethod
16
+ def blacklist(self, rule_name: str, token: str) -> None:
17
+ """Add a token to the blacklist."""
18
+ pass
19
+
20
+ @abstractmethod
21
+ def is_blacklisted(self, rule_name: str, token: str) -> bool:
22
+ """Check whether a token is blacklisted."""
23
+ pass
24
+
25
+
26
+ class BlackListed(AbstractBlackList):
27
+ """Blacklist manager that persists revoked tokens in a database table."""
28
+
29
+ def __init__(
30
+ self,
31
+ rule_handler: Optional[JWTAuthenticationRules] = None,
32
+ manager: Optional[ModelManager] = None,
33
+ ) -> None:
34
+ self.__rule_handler = rule_handler
35
+ self.__manager = manager
36
+
37
+ @property
38
+ def _rule_handler(self) -> JWTAuthenticationRules:
39
+ if self.__rule_handler is not None:
40
+ return self.__rule_handler
41
+ return JWTAuthenticationRules()
42
+
43
+ @_rule_handler.setter
44
+ def _rule_handler(self, value: JWTAuthenticationRules) -> None:
45
+ self.__rule_handler = value
46
+
47
+ @property
48
+ def _manager(self) -> ModelManager:
49
+ if self.__manager is None:
50
+ self.__manager = ModelManager()
51
+ return self.__manager
52
+
53
+ @_manager.setter
54
+ def _manager(self, value: ModelManager) -> None:
55
+ self.__manager = value
56
+
57
+ def blacklist_token(
58
+ self,
59
+ rule_name: str,
60
+ token: str,
61
+ *args: Any,
62
+ **kwargs: Any,
63
+ ) -> None:
64
+ """Persist a token to the rule's blacklist table."""
65
+ model_path: str = self._rule_handler.get_blacklisted_table_path(rule_name)
66
+ BlackListModel = self._manager.get_model(model_path=model_path)
67
+ model_instance = BlackListModel(token=token, **kwargs)
68
+ self._manager.register(model_instance)
69
+
70
+ def unregister_token_from_track_model(
71
+ self,
72
+ rule_name: str,
73
+ token: str,
74
+ ) -> None:
75
+ """Remove a token record from the rule's token-tracking table."""
76
+ model_path: str = self._rule_handler.get_track_created_table_path(rule_name)
77
+ model_instance = self._manager.get_instance(model_path=model_path, token=token)
78
+ if model_instance:
79
+ self._manager.unregister(model_instance)
80
+
81
+ def blacklist(
82
+ self,
83
+ rule_name: str,
84
+ token: str,
85
+ extra_blacklist_fields: dict = {},
86
+ remove_token_from_track_model: bool = False,
87
+ ) -> None:
88
+ """Blacklist a token and optionally remove it from the tracking table."""
89
+ self.blacklist_token(rule_name=rule_name, token=token, **extra_blacklist_fields)
90
+ if remove_token_from_track_model:
91
+ self.unregister_token_from_track_model(rule_name, token)
92
+
93
+ def is_blacklisted(self, rule_name: str, token: str) -> Union[Any, None]:
94
+ """Check whether a token is in the blacklist."""
95
+ if not self._rule_handler.get_blacklisted(rule_name):
96
+ return False
97
+ model_path: str = self._rule_handler.get_blacklisted_table_path(rule_name)
98
+ blacklisted_token = self._manager.get_instance(
99
+ model_path=model_path, token=token
100
+ )
101
+ return blacklisted_token
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+ """
3
+ Module contains the Database singleton — wraps a Flask-SQLAlchemy db instance.
4
+ """
5
+
6
+ from typing import Optional, Any, Type
7
+ from flask_sqlalchemy import SQLAlchemy
8
+ from .import_handler import import_handler
9
+
10
+ _UNSET = object()
11
+
12
+
13
+ class DatabasePathNotProvided(Exception):
14
+ """Raised when a database path is not provided on first initialization."""
15
+ pass
16
+
17
+
18
+ class Database:
19
+ """Singleton that holds and provides access to the Flask-SQLAlchemy db instance.
20
+
21
+ The database instance is discovered via its dotted import path, e.g.
22
+ ``"myapp.database.db"``.
23
+
24
+ Example:
25
+ >>> # First call — initialize with the path
26
+ >>> Database("myapp.database.db")
27
+ >>> # Subsequent calls — access the db
28
+ >>> db = Database().db
29
+ >>> db.session.query(MyModel).filter_by(id=1).first()
30
+ """
31
+
32
+ _instance: Type["Database"] = None
33
+
34
+ def __new__(cls, *args: Any, **kwargs: Any) -> Any:
35
+ if cls._instance is None:
36
+ cls._instance = super(Database, cls).__new__(cls)
37
+ return cls._instance
38
+
39
+ def __init__(self, path: Optional[str] = _UNSET) -> None:
40
+ if path is None:
41
+ raise DatabasePathNotProvided(
42
+ "Database path must be provided on first initialization."
43
+ )
44
+ if path is not _UNSET:
45
+ self.database_path = path
46
+ self._initialized = True
47
+
48
+ @classmethod
49
+ def _reset(cls) -> None:
50
+ """Reset singleton state. Intended for use in tests only."""
51
+ cls._instance = None
52
+
53
+ @property
54
+ def database_path(self) -> str:
55
+ """The dotted import path to the SQLAlchemy db instance."""
56
+ if not hasattr(self, "_database_path"):
57
+ raise DatabasePathNotProvided(
58
+ "Database path has not been configured. Call initialize_jwt_authentication() first."
59
+ )
60
+ return self._database_path
61
+
62
+ @database_path.setter
63
+ def database_path(self, value: str) -> None:
64
+ if not isinstance(value, str):
65
+ raise DatabasePathNotProvided(
66
+ f"Database path must be a string, got {type(value)}"
67
+ )
68
+ self._database_path = value
69
+
70
+ @property
71
+ def db(self) -> SQLAlchemy:
72
+ """The SQLAlchemy database instance."""
73
+ return self.grab_database(self.database_path)
74
+
75
+ @staticmethod
76
+ def grab_database(path: str) -> SQLAlchemy:
77
+ """Import and return the SQLAlchemy db from the given dotted path."""
78
+ return import_handler.grab(path=path)
79
+
80
+ def model_instance_exists(self, model_class: Any, **kwargs: Any) -> Any:
81
+ """Query the database for a model instance matching the given filters.
82
+
83
+ Args:
84
+ model_class: The SQLAlchemy model class.
85
+ **kwargs: Filter arguments passed to ``filter_by()``.
86
+
87
+ Returns:
88
+ The first matching instance, or None.
89
+ """
90
+ return self.db.session.query(model_class).filter_by(**kwargs).first()
91
+
92
+ def get_model_by_path(self, model_path: str) -> Type:
93
+ """Return a model class from its dotted import path."""
94
+ return import_handler.grab(model_path)
95
+
96
+ def create_instance(self, instance: Any) -> None:
97
+ """Add and commit a new model instance to the database."""
98
+ self.db.session.add(instance)
99
+ self.db.session.commit()
100
+
101
+ def delete(self, instance: Any) -> None:
102
+ """Delete and commit removal of a model instance from the database."""
103
+ self.db.session.delete(instance)
104
+ self.db.session.commit()
105
+
106
+ def update(self, *args: Any, **kwargs: Any) -> None:
107
+ """Commit any pending changes to the database session."""
108
+ self.db.session.commit()
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+ """
3
+ Module contains the @authenticate decorator for protecting Flask routes.
4
+ """
5
+
6
+ from functools import wraps
7
+ from flask import request, make_response, Response
8
+ from typing import Any, Callable, Tuple, Union, Type, Optional
9
+ from .authentication_classes import BaseAuthentication
10
+ from .jwt_rules import JWTAuthenticationRules
11
+ from .model_managers import ModelManager
12
+
13
+
14
+ def authenticate(
15
+ auth_classes: list = None,
16
+ rule_name: Optional[str] = None,
17
+ token_type: str = "access",
18
+ assign_to_request: bool = False,
19
+ assignment_key: str = "instance",
20
+ model_id_key_name: str = "id",
21
+ return_func: Callable = make_response,
22
+ return_func_args: Tuple[Any, ...] = ({"error": "Unauthorized access"}, 401),
23
+ jwt_rules: JWTAuthenticationRules = None,
24
+ ) -> Callable:
25
+ """Decorator that protects a Flask route with JWT authentication.
26
+
27
+ At least one authentication class in ``auth_classes`` must successfully
28
+ authenticate the request for the view to be called.
29
+
30
+ Args:
31
+ auth_classes: List of :class:`BaseAuthentication` subclass **classes**
32
+ (not instances) to try in order. If any succeeds, the route is called.
33
+ rule_name: JWT rule name (None -> default rule).
34
+ token_type: Expected token type ('access' or 'refresh').
35
+ assign_to_request: If True, attaches the authenticated model instance to
36
+ ``request.<assignment_key>``.
37
+ assignment_key: The attribute name on the request object (default 'instance').
38
+ model_id_key_name: The primary key field name on the model (default 'id').
39
+ return_func: Callable invoked on auth failure (default: ``make_response``).
40
+ return_func_args: Arguments passed to ``return_func`` on failure.
41
+ jwt_rules: Override for the JWTAuthenticationRules singleton (for testing).
42
+
43
+ Returns:
44
+ The decorated view function or a failure response.
45
+
46
+ Raises:
47
+ TypeError: If auth_classes, return_func, or return_func_args are invalid types.
48
+ """
49
+ # Default to AuthenticateJWT if no classes provided
50
+ if auth_classes is None:
51
+ from .authentication_classes import AuthenticateJWT
52
+ auth_classes = [AuthenticateJWT]
53
+
54
+ if jwt_rules is None:
55
+ jwt_rules = JWTAuthenticationRules()
56
+
57
+ # Validate arguments
58
+ if not callable(return_func):
59
+ raise TypeError("return_func must be a callable.")
60
+ if not isinstance(return_func_args, tuple):
61
+ raise TypeError("return_func_args must be a tuple.")
62
+ if not isinstance(auth_classes, list) or not all(
63
+ (isinstance(auth, type) and issubclass(auth, BaseAuthentication))
64
+ for auth in auth_classes
65
+ ):
66
+ raise TypeError(
67
+ "auth_classes must be a list of BaseAuthentication subclass classes (not instances)."
68
+ )
69
+
70
+ # Grab token header prefix for this rule
71
+ token_header: str = jwt_rules.get_token_header(rule_name)
72
+
73
+ def decorator(view: Callable) -> Callable:
74
+ # FIX T-04: corrected indentation of return wrapper
75
+ @wraps(view)
76
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
77
+ if not auth_classes:
78
+ return return_func(*return_func_args)
79
+
80
+ for auth_class in auth_classes:
81
+ auth_instance: BaseAuthentication = auth_class(
82
+ token_header, token_type, rule_name
83
+ )
84
+ payload: Optional[dict[str, Any]] = auth_instance.authenticate_request(request)
85
+ if payload:
86
+ if assign_to_request:
87
+ instance = _get_instance(rule_name, payload, model_id_key_name, jwt_rules)
88
+ setattr(request, assignment_key, instance)
89
+ return view(*args, **kwargs)
90
+
91
+ # None of the auth_classes authenticated the request
92
+ return return_func(*return_func_args)
93
+
94
+ return wrapper
95
+
96
+ return decorator
97
+
98
+
99
+ def _get_instance(
100
+ rule_name: Optional[str],
101
+ payload: dict[str, Any],
102
+ model_id_key_name: str = "id",
103
+ jwt_rules: Optional[JWTAuthenticationRules] = None,
104
+ model_manager: Optional[ModelManager] = None,
105
+ ) -> Any:
106
+ """Retrieve the model instance identified by the JWT payload.
107
+
108
+ Args:
109
+ rule_name: The JWT rule name.
110
+ payload: The decoded JWT payload dict.
111
+ model_id_key_name: The primary key field name (default 'id').
112
+ jwt_rules: JWTAuthenticationRules instance.
113
+ model_manager: ModelManager instance.
114
+
115
+ Returns:
116
+ The model instance, or None if not found.
117
+ """
118
+ if jwt_rules is None:
119
+ jwt_rules = JWTAuthenticationRules()
120
+ if model_manager is None:
121
+ model_manager = ModelManager()
122
+
123
+ table_class = model_manager.get_model(jwt_rules.get_table_path(rule_name))
124
+ id_key_name: str = f"{table_class.__tablename__}_{model_id_key_name}"
125
+ instance_id = payload.get(id_key_name)
126
+ return model_manager.get_instance(table_class, **{model_id_key_name: instance_id})