tina4-python 0.2.133__tar.gz → 0.2.134__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.
Files changed (60) hide show
  1. {tina4_python-0.2.133 → tina4_python-0.2.134}/.gitignore +1 -0
  2. tina4_python-0.2.134/PKG-INFO +84 -0
  3. tina4_python-0.2.134/README.md +65 -0
  4. {tina4_python-0.2.133 → tina4_python-0.2.134}/pyproject.toml +62 -44
  5. tina4_python-0.2.134/tina4_python/Auth.py +323 -0
  6. tina4_python-0.2.134/tina4_python/CRUD.py +388 -0
  7. tina4_python-0.2.134/tina4_python/Constant.py +90 -0
  8. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Database.py +23 -21
  9. tina4_python-0.2.134/tina4_python/Debug.py +119 -0
  10. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Env.py +2 -0
  11. tina4_python-0.2.134/tina4_python/FieldTypes.py +272 -0
  12. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/HtmlElement.py +7 -0
  13. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Localization.py +1 -1
  14. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Migration.py +6 -6
  15. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/ORM.py +1 -246
  16. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Queue.py +2 -2
  17. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Router.py +13 -14
  18. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Session.py +14 -14
  19. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Template.py +4 -4
  20. tina4_python-0.2.134/tina4_python/Webserver.py +583 -0
  21. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/__init__.py +191 -100
  22. tina4_python-0.2.134/tina4_python/cli.py +95 -0
  23. tina4_python-0.2.133/PKG-INFO +0 -465
  24. tina4_python-0.2.133/README.md +0 -446
  25. tina4_python-0.2.133/tina4_python/Auth.py +0 -223
  26. tina4_python-0.2.133/tina4_python/CRUD.py +0 -299
  27. tina4_python-0.2.133/tina4_python/Constant.py +0 -43
  28. tina4_python-0.2.133/tina4_python/Debug.py +0 -126
  29. tina4_python-0.2.133/tina4_python/Webserver.py +0 -432
  30. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/DatabaseResult.py +0 -0
  31. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/DatabaseTypes.py +0 -0
  32. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Messages.py +0 -0
  33. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/MiddleWare.py +0 -0
  34. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Request.py +0 -0
  35. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Response.py +0 -0
  36. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/ShellColors.py +0 -0
  37. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Swagger.py +0 -0
  38. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/Websocket.py +0 -0
  39. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/messages.pot +0 -0
  40. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/css/readme.md +0 -0
  41. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/favicon.ico +0 -0
  42. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/403.png +0 -0
  43. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/404.png +0 -0
  44. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/500.png +0 -0
  45. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/logo.png +0 -0
  46. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/images/readme.md +0 -0
  47. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/js/readme.md +0 -0
  48. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/js/reconnecting-websocket.js +0 -0
  49. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/js/tina4helper.js +0 -0
  50. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/swagger/index.html +0 -0
  51. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  52. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/components/crud.twig +0 -0
  53. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/errors/403.twig +0 -0
  54. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/errors/404.twig +0 -0
  55. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/errors/500.twig +0 -0
  56. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/templates/readme.md +0 -0
  57. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  58. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  59. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  60. {tina4_python-0.2.133 → tina4_python-0.2.134}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
@@ -32,3 +32,4 @@
32
32
  /migrations/__test_user_item.sql
33
33
  /tina4_python.egg-info/
34
34
  /src/templates/crud/
35
+ /.venv/
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: tina4-python
3
+ Version: 0.2.134
4
+ Summary: Tina4Python - This is not another framework for Python
5
+ Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
+ Requires-Python: <4.0,>=3.12
7
+ Requires-Dist: asyncer>=0.0.8
8
+ Requires-Dist: bcrypt<5.0.0,>=4.2.1
9
+ Requires-Dist: cryptography<45.0.0,>=44.0.0
10
+ Requires-Dist: jinja2<4.0.0,>=3.1.5
11
+ Requires-Dist: libsass<0.24.0,>=0.23.0
12
+ Requires-Dist: litequeue<0.10,>=0.9
13
+ Requires-Dist: pyjwt<3.0.0,>=2.10.1
14
+ Requires-Dist: python-dotenv<2.0.0,>=1.0.1
15
+ Requires-Dist: simple-websocket<2.0.0,>=1.1.0
16
+ Requires-Dist: uvicorn>=0.38.0
17
+ Requires-Dist: watchdog<7.0.0,>=6.0.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Tina4 Python — This is not a framework
21
+
22
+ Laravel joy. Python speed. 10× less code.
23
+
24
+ ```python app.py
25
+ from tina4_python import run_web_server
26
+ from tina4_python.Router import get
27
+
28
+
29
+ @get("/")
30
+ async def get_hello_world(request, response):
31
+ return response("Hello World!")
32
+
33
+
34
+ def app():
35
+ run_web_server()
36
+
37
+
38
+ if __name__ == "__main__":
39
+ app()
40
+ ```
41
+
42
+ That’s it. Save the code above as `app.py`, run it, and you have a fully working Tina4 Python web server.
43
+
44
+ ```bash
45
+ pip install tina4-python
46
+ python app.py
47
+ # → http://localhost:7145
48
+ ```
49
+
50
+ You just built your first Tina4 app — zero configuration, zero classes, zero boilerplate.
51
+
52
+ ## Features
53
+
54
+ - Full ASGI compliance, use any ASGI compliant webserver
55
+ - Full async support out of the box
56
+ - Built in JWT and Session handling
57
+ - Automatic Swagger docs at `/swagger`
58
+ - Instant CRUD interfaces with one line: `result.to_crud(request)`
59
+ - Built-in Twig templating, migrations, WebSockets, authentication and middleware
60
+ - Works with SQLite, PostgreSQL, MySQL, MariaDB, MSSQL, Firebird
61
+ - Hot reload in development (`uv run python -m jurigged app.py`)
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install tina4-python
67
+ ```
68
+
69
+ ## Documentation
70
+
71
+ https://tina4.com/
72
+
73
+ ## Community
74
+
75
+ - GitHub: https://github.com/tina4stack/tina4-python
76
+
77
+ ## License
78
+
79
+ MIT © 2007 – 2025 Tina4 Stack
80
+ https://opensource.org/licenses/MIT
81
+
82
+ ---
83
+
84
+ **Tina4** – The framework that keeps out of the way of your coding.
@@ -0,0 +1,65 @@
1
+ # Tina4 Python — This is not a framework
2
+
3
+ Laravel joy. Python speed. 10× less code.
4
+
5
+ ```python app.py
6
+ from tina4_python import run_web_server
7
+ from tina4_python.Router import get
8
+
9
+
10
+ @get("/")
11
+ async def get_hello_world(request, response):
12
+ return response("Hello World!")
13
+
14
+
15
+ def app():
16
+ run_web_server()
17
+
18
+
19
+ if __name__ == "__main__":
20
+ app()
21
+ ```
22
+
23
+ That’s it. Save the code above as `app.py`, run it, and you have a fully working Tina4 Python web server.
24
+
25
+ ```bash
26
+ pip install tina4-python
27
+ python app.py
28
+ # → http://localhost:7145
29
+ ```
30
+
31
+ You just built your first Tina4 app — zero configuration, zero classes, zero boilerplate.
32
+
33
+ ## Features
34
+
35
+ - Full ASGI compliance, use any ASGI compliant webserver
36
+ - Full async support out of the box
37
+ - Built in JWT and Session handling
38
+ - Automatic Swagger docs at `/swagger`
39
+ - Instant CRUD interfaces with one line: `result.to_crud(request)`
40
+ - Built-in Twig templating, migrations, WebSockets, authentication and middleware
41
+ - Works with SQLite, PostgreSQL, MySQL, MariaDB, MSSQL, Firebird
42
+ - Hot reload in development (`uv run python -m jurigged app.py`)
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install tina4-python
48
+ ```
49
+
50
+ ## Documentation
51
+
52
+ https://tina4.com/
53
+
54
+ ## Community
55
+
56
+ - GitHub: https://github.com/tina4stack/tina4-python
57
+
58
+ ## License
59
+
60
+ MIT © 2007 – 2025 Tina4 Stack
61
+ https://opensource.org/licenses/MIT
62
+
63
+ ---
64
+
65
+ **Tina4** – The framework that keeps out of the way of your coding.
@@ -1,44 +1,62 @@
1
- [project]
2
- name = "tina4-python"
3
- version = "0.2.133"
4
- description = "Tina4Python - This is not another framework for Python"
5
- authors = [
6
- {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
7
- ]
8
- readme = "README.md"
9
- requires-python = ">=3.12,<4.0"
10
- dependencies = [
11
- "jinja2>=3.1.5,<4.0.0",
12
- "libsass (>=0.23.0,<0.24.0)",
13
- "python-dotenv (>=1.0.1,<2.0.0)",
14
- "pyjwt (>=2.10.1,<3.0.0)",
15
- "cryptography (>=44.0.0,<45.0.0)",
16
- "watchdog (>=6.0.0,<7.0.0)",
17
- "bcrypt (>=4.2.1,<5.0.0)",
18
- "litequeue (>=0.9,<0.10)",
19
- "simple-websocket (>=1.1.0,<2.0.0)",
20
- "hypercorn (>=0.17.3,<0.18.0)",
21
- "asyncer>=0.0.8",
22
- ]
23
-
24
- [dependency-groups]
25
- dev = [
26
- "flake8>=7.2.0",
27
- "jurigged>=0.6.0",
28
- "mysql-connector-python>=9.3.0",
29
- "psycopg2-binary>=2.9.10",
30
- "pytest>=8.3.5",
31
- "ruff>=0.11.9",
32
- "safety>=3.5.0",
33
- ]
34
-
35
- [build-system]
36
- requires = ["hatchling"]
37
- build-backend = "hatchling.build"
38
-
39
- [tool.hatch.build]
40
- include = [
41
- "tina4_python/**/*"
42
- ]
43
-
44
-
1
+ [project]
2
+ name = "tina4-python"
3
+ version = "0.2.134"
4
+ description = "Tina4Python - This is not another framework for Python"
5
+ authors = [
6
+ {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12,<4.0"
10
+ dependencies = [
11
+ "jinja2>=3.1.5,<4.0.0",
12
+ "libsass (>=0.23.0,<0.24.0)",
13
+ "python-dotenv (>=1.0.1,<2.0.0)",
14
+ "pyjwt (>=2.10.1,<3.0.0)",
15
+ "cryptography (>=44.0.0,<45.0.0)",
16
+ "watchdog (>=6.0.0,<7.0.0)",
17
+ "bcrypt (>=4.2.1,<5.0.0)",
18
+ "litequeue (>=0.9,<0.10)",
19
+ "simple-websocket (>=1.1.0,<2.0.0)",
20
+ "asyncer>=0.0.8",
21
+ "uvicorn>=0.38.0",
22
+ ]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "flake8>=7.2.0",
27
+ "jurigged>=0.6.0",
28
+ "mkdocs>=1.6.1",
29
+ "mysql-connector-python>=9.3.0",
30
+ "psycopg2-binary>=2.9.10",
31
+ "pydoc-markdown>=4.8.2",
32
+ "pytest>=8.3.5",
33
+ "ruff>=0.11.9",
34
+ "safety>=3.5.0",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build]
42
+ include = [
43
+ "tina4_python/**/*"
44
+ ]
45
+
46
+ [project.scripts]
47
+ tina4 = "tina4_python.cli:main"
48
+
49
+ [[tool.pydoc-markdown.loaders]]
50
+ type = "python"
51
+ search_path = [ "./tina4_python" ]
52
+
53
+ [tool.pydoc-markdown.renderer]
54
+ type = "mkdocs"
55
+ site_name= "Tina4Python"
56
+
57
+ [[tool.pydoc-markdown.renderer.pages]]
58
+ title = "API Documentation"
59
+ name = "index"
60
+
61
+ contents = [ "*" ]
62
+
@@ -0,0 +1,323 @@
1
+ #
2
+ # Tina4 - This is not a 4ramework.
3
+ # Copy-right 2007 - current Tina4
4
+ # License: MIT https://opensource.org/licenses/MIT
5
+ #
6
+ # flake8: noqa: E501
7
+ import datetime
8
+ import os
9
+ import jwt
10
+ import bcrypt
11
+ from json import JSONEncoder
12
+ from cryptography import x509
13
+ from cryptography.x509 import NameOID
14
+ from cryptography.hazmat.primitives import serialization, hashes
15
+ from cryptography.hazmat.primitives.asymmetric import rsa
16
+ from cryptography.hazmat.backends import default_backend
17
+
18
+ class AuthJSONSerializer(JSONEncoder):
19
+ """
20
+ Custom JSON encoder used by PyJWT when serializing payload objects.
21
+
22
+ Ensures that ``datetime.datetime`` instances are correctly converted to ISO-8601
23
+ strings so they can be embedded in JWT claims.
24
+ """
25
+
26
+ def default(self, o):
27
+ if isinstance(o, datetime.datetime):
28
+ return o.isoformat()
29
+ return super().default(o)
30
+
31
+
32
+ class Auth:
33
+ """
34
+ Authentication & authorization helper for Tina4 projects.
35
+
36
+ Handles:
37
+ - Password hashing/verification (bcrypt)
38
+ - Automatic creation and loading of an RSA private/public key pair
39
+ - Self-signed certificate generation for local HTTPS development
40
+ - Signing JWT tokens with RS256 (private key)
41
+ - Verifying JWT tokens with RS256 (public key)
42
+ - Simple API-KEY fallback validation
43
+
44
+ Keys and certificates are stored in ``<root_path>/secrets/``:
45
+ - private.key → encrypted PEM private key
46
+ - public.key → PEM public key (unencrypted)
47
+ - domain.cert → self-signed certificate (for local dev servers)
48
+
49
+ Environment variables used:
50
+ - ``SECRET`` → passphrase used to encrypt the private key
51
+ - ``API_KEY`` → optional static API key (checked before JWT)
52
+ - ``TINA4_TOKEN_LIMIT`` → default token lifetime in minutes (default: 2)
53
+ - Country/State/City/Organization/Domain variables for the cert
54
+ """
55
+
56
+ # ------------------------------------------------------------------
57
+ # Class-level attributes (set in __init__)
58
+ # ------------------------------------------------------------------
59
+ secret: str | None = None # Passphrase for private key encryption
60
+ private_key: str = None # Path to encrypted private key file
61
+ public_key: str = None # Path to public key file
62
+ self_signed: str = None # Path to self-signed cert file
63
+ root_path: str = None # Project root directory
64
+
65
+ # Cached key objects (avoid reloading from disk on every request)
66
+ loaded_private_key = None
67
+ loaded_public_key = None
68
+
69
+ # ------------------------------------------------------------------
70
+ # Password handling (bcrypt)
71
+ # ------------------------------------------------------------------
72
+ def hash_password(self, text: str) -> str:
73
+ """
74
+ Generate a bcrypt hash for the given plain-text password.
75
+
76
+ Args:
77
+ text (str): Plain-text password.
78
+
79
+ Returns:
80
+ str: bcrypt hash (as UTF-8 string). Safe to store in a database.
81
+ """
82
+ password_bytes = text.encode("utf-8")
83
+ salt = bcrypt.gensalt()
84
+ return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
85
+
86
+ def check_password(self, password_hash: str, text: str) -> bool:
87
+ """
88
+ Verify a plain-text password against a previously created bcrypt hash.
89
+
90
+ Args:
91
+ password_hash (str): Hash returned by :meth:`hash_password`.
92
+ text (str): Plain-text password to verify.
93
+
94
+ Returns:
95
+ bool: ``True`` if the password matches the hash.
96
+ """
97
+ password_bytes = text.encode("utf-8")
98
+ return bcrypt.checkpw(password_bytes, password_hash.encode("utf-8"))
99
+
100
+ # ------------------------------------------------------------------
101
+ # Private / public key loading (with caching)
102
+ # ------------------------------------------------------------------
103
+ def load_private_key(self):
104
+ """
105
+ Load (and cache) the RSA private key from ``secrets/private.key``.
106
+
107
+ The key is encrypted with the passphrase stored in ``self.secret``
108
+ (which comes from the environment variable ``SECRET``).
109
+
110
+ Returns:
111
+ cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey
112
+ """
113
+ if self.loaded_private_key:
114
+ return self.loaded_private_key
115
+
116
+ with open(self.private_key, "rb") as f:
117
+ private_key = serialization.load_pem_private_key(
118
+ f.read(),
119
+ password=self.secret.encode(),
120
+ backend=default_backend(),
121
+ )
122
+ self.loaded_private_key = private_key
123
+ return private_key
124
+
125
+ def load_public_key(self):
126
+ """
127
+ Load (and cache) the RSA public key from ``secrets/public.key``.
128
+
129
+ Returns:
130
+ cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey
131
+ """
132
+ if self.loaded_public_key:
133
+ return self.loaded_public_key
134
+
135
+ with open(self.public_key, "rb") as f:
136
+ public_key = serialization.load_pem_public_key(
137
+ f.read(), backend=default_backend()
138
+ )
139
+ self.loaded_public_key = public_key
140
+ return public_key
141
+
142
+ # ------------------------------------------------------------------
143
+ # Constructor – creates secrets folder & keys if missing
144
+ # ------------------------------------------------------------------
145
+ def __init__(self, root_path: str):
146
+ """
147
+ Initialise the Auth helper and ensure cryptographic material exists.
148
+
149
+ Args:
150
+ root_path (str): Absolute path to the project root (where the
151
+ ``secrets`` folder will be created).
152
+ """
153
+ self.root_path = root_path
154
+ self.secret = os.environ.get("SECRET", "{self.secret}")
155
+ self.private_key = os.path.join(root_path, "secrets", "private.key")
156
+ self.public_key = os.path.join(root_path, "secrets", "public.key")
157
+ self.self_signed = os.path.join(root_path, "secrets", "domain.cert")
158
+
159
+ # Ensure secrets directory exists
160
+ os.makedirs(os.path.join(root_path, "secrets"), exist_ok=True)
161
+
162
+ # ------------------------------------------------------------------
163
+ # 1. Private key – generate if missing
164
+ # ------------------------------------------------------------------
165
+ if not os.path.isfile(self.private_key):
166
+ private_key = rsa.generate_private_key(
167
+ public_exponent=65537,
168
+ key_size=2048,
169
+ )
170
+ with open(self.private_key, "wb") as f:
171
+ f.write(private_key.private_bytes(
172
+ encoding=serialization.Encoding.PEM,
173
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
174
+ encryption_algorithm=serialization.BestAvailableEncryption(self.secret.encode()),
175
+ ))
176
+ else:
177
+ private_key = self.load_private_key()
178
+
179
+ # ------------------------------------------------------------------
180
+ # 2. Public key – derive from private key if missing
181
+ # ------------------------------------------------------------------
182
+ if not os.path.isfile(self.public_key):
183
+ public_key = private_key.public_key()
184
+ public_pem = public_key.public_bytes(
185
+ encoding=serialization.Encoding.PEM,
186
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
187
+ )
188
+ with open(self.public_key, "wb") as f:
189
+ f.write(public_pem)
190
+
191
+ # ------------------------------------------------------------------
192
+ # 3. Self-signed certificate (useful for local HTTPS servers)
193
+ # ------------------------------------------------------------------
194
+ if not os.path.isfile(self.self_signed):
195
+ subject = issuer = x509.Name(
196
+ [
197
+ x509.NameAttribute(NameOID.COUNTRY_NAME, os.environ.get("COUNTRY", "ZA")),
198
+ x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, os.environ.get("STATE", "WESTERN CAPE")),
199
+ x509.NameAttribute(NameOID.LOCALITY_NAME, os.environ.get("CITY", "CAPE TOWN")),
200
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, os.environ.get("ORGANIZATION", "Tina4")),
201
+ x509.NameAttribute(NameOID.COMMON_NAME, os.environ.get("DOMAIN_NAME", "localhost")),
202
+ ]
203
+ )
204
+ cert = (
205
+ x509.CertificateBuilder()
206
+ .subject_name(subject)
207
+ .issuer_name(issuer)
208
+ .public_key(private_key.public_key())
209
+ .serial_number(x509.random_serial_number())
210
+ .not_valid_before(datetime.datetime.now(datetime.timezone.utc))
211
+ .not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=99999))
212
+ .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
213
+ .sign(private_key, hashes.SHA256())
214
+ )
215
+
216
+ with open(self.self_signed, "wb") as f:
217
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
218
+
219
+ # ------------------------------------------------------------------
220
+ # JWT creation & verification
221
+ # ------------------------------------------------------------------
222
+ def get_token(self, payload_data: dict, expiry_minutes: int = 0) -> str:
223
+ """
224
+ Create a signed JWT (RS256) containing the supplied payload.
225
+
226
+ If ``expires`` is not present in ``payload_data`` an expiration claim
227
+ will be added automatically using ``TINA4_TOKEN_LIMIT`` (default 2 minutes)
228
+ or the value supplied via ``expiry_minutes``.
229
+
230
+ Args:
231
+ payload_data (dict): Claims to embed in the token.
232
+ expiry_minutes (int): Override default token lifetime (0 = use env default).
233
+
234
+ Returns:
235
+ str: Signed JWT (compact serialization).
236
+ """
237
+ private_key = self.load_private_key()
238
+ now = datetime.datetime.now(datetime.timezone.utc)
239
+
240
+ if "expires" not in payload_data:
241
+ token_limit_minutes = int(os.environ.get("TINA4_TOKEN_LIMIT", 2))
242
+ if expiry_minutes != 0:
243
+ token_limit_minutes = expiry_minutes
244
+ expiry_time = now + datetime.timedelta(minutes=token_limit_minutes)
245
+ payload_data["expires"] = expiry_time.isoformat()
246
+
247
+ token = jwt.encode(
248
+ payload=payload_data,
249
+ key=private_key,
250
+ algorithm="RS256",
251
+ json_encoder=AuthJSONSerializer,
252
+ )
253
+ return token
254
+
255
+ def get_payload(self, token: str) -> dict | None:
256
+ """
257
+ Decode a JWT and return its payload (without verification of expiry).
258
+
259
+ Used when you only need the claims and will perform your own validation.
260
+
261
+ Args:
262
+ token (str): JWT to decode.
263
+
264
+ Returns:
265
+ dict | None: Payload dictionary or ``None`` on invalid signature.
266
+ """
267
+ public_key = self.load_public_key()
268
+ try:
269
+ payload = jwt.decode(token, key=public_key, algorithms=["RS256"])
270
+ return payload
271
+ except jwt.InvalidSignatureError:
272
+ return None
273
+
274
+ def validate(self, token: str) -> bool:
275
+ """
276
+ Full token validation.
277
+
278
+ Checks:
279
+ 1. Optional static ``API_KEY`` environment variable (quick bypass)
280
+ 2. RS256 signature using the public key
281
+ 3. Presence and validity of the ``expires`` claim
282
+
283
+ Args:
284
+ token (str): Bearer token.
285
+
286
+ Returns:
287
+ bool: ``True`` if the token is valid and not expired.
288
+ """
289
+ # Simple API-KEY fallback (useful for quick internal scripts)
290
+ if os.environ.get("API_KEY") and token == os.environ.get("API_KEY"):
291
+ return True
292
+
293
+ public_key = self.load_public_key()
294
+ try:
295
+ payload = jwt.decode(token, key=public_key, algorithms=["RS256"])
296
+
297
+ if "expires" not in payload:
298
+ return False
299
+
300
+ expiry_time = datetime.datetime.fromisoformat(payload["expires"])
301
+ if expiry_time.tzinfo is None:
302
+ # Treat naive datetime as UTC for backward compatibility
303
+ expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc)
304
+
305
+ return datetime.datetime.now(datetime.timezone.utc) <= expiry_time
306
+
307
+ except Exception: # noqa: BLE001 – we intentionally catch everything here
308
+ return False
309
+
310
+ # ------------------------------------------------------------------
311
+ # Alias for backward compatibility
312
+ # ------------------------------------------------------------------
313
+ def valid(self, token: str) -> bool:
314
+ """
315
+ Alias of :meth:`validate`. Kept for older codebases.
316
+
317
+ Args:
318
+ token (str): Bearer token.
319
+
320
+ Returns:
321
+ bool: ``True`` if token is valid.
322
+ """
323
+ return self.validate(token)