admitiq 0.3.1__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.
admitiq-0.3.1/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: admitiq
3
+ Version: 0.3.1
4
+ Summary: AdmitiQ: signed, expiring, revocable tokens for QR codes and links. No AI, no required server.
5
+ Author: LogicLitz
6
+ License: MIT
7
+ Project-URL: Homepage, https://logiclitz.org
8
+ Project-URL: Repository, https://github.com/HyperXfury1873/admitiq
9
+ Project-URL: Issues, https://github.com/HyperXfury1873/admitiq/issues
10
+ Keywords: qr,qrcode,security,tokens,tickets,attendance,coupons
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ Provides-Extra: qr
17
+ Requires-Dist: qrcode[pil]>=7.4; extra == "qr"
18
+ Provides-Extra: ec
19
+ Requires-Dist: cryptography>=41.0; extra == "ec"
20
+ Provides-Extra: redis
21
+ Requires-Dist: redis>=4.5; extra == "redis"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Requires-Dist: fastapi>=0.100; extra == "dev"
25
+ Requires-Dist: flask>=2.3; extra == "dev"
26
+ Requires-Dist: httpx>=0.24; extra == "dev"
27
+
28
+ # AdmitiQ (Python)
29
+
30
+ **A [LogicLitz](https://logiclitz.org) open-source project.**
31
+
32
+ Signed, expiring, revocable tokens for QR codes and links.
33
+
34
+ > New here? Start with: [../docs/what-is-admitiq.md](../docs/what-is-admitiq.md)
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install admitiq
40
+ pip install admitiq[qr] # optional: QR images
41
+ pip install admitiq[ec] # optional: ES256
42
+ pip install admitiq[redis] # optional: Redis single-use store
43
+ ```
44
+
45
+ ## Tiny example
46
+
47
+ ```python
48
+ from admitiq import issue, verify, issue_url, issue_qr
49
+
50
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=3600, secret="your-secret-key")
51
+ url = issue_url("https://example.com/scan", {"ticket_id": "abc123"}, ttl_seconds=3600, secret="your-secret-key")
52
+ # issue_qr(..., output_path="ticket.png") # needs: pip install admitiq[qr]
53
+
54
+ payload = verify(token, secret="your-secret-key")
55
+ print(payload["data"])
56
+ ```
57
+
58
+ A Node app can verify this same token with the same secret (`npm install admitiq`).
59
+
60
+ ## More
61
+
62
+ | Topic | Link |
63
+ |-------|------|
64
+ | Full Python guide | [docs/python.md](../docs/python.md) |
65
+ | QR & URL delivery | [docs/delivering-tokens.md](../docs/delivering-tokens.md) |
66
+ | Key rotation | [docs/key-rotation.md](../docs/key-rotation.md) |
67
+ | Security | [SECURITY.md](../SECURITY.md) |
68
+ | Publish to PyPI | [PUBLISH.md](../PUBLISH.md) |
69
+ | Flask demo | [examples/flask-attendance](../examples/flask-attendance) |
70
+
71
+ ## License
72
+
73
+ MIT — [LogicLitz](https://logiclitz.org)
@@ -0,0 +1,46 @@
1
+ # AdmitiQ (Python)
2
+
3
+ **A [LogicLitz](https://logiclitz.org) open-source project.**
4
+
5
+ Signed, expiring, revocable tokens for QR codes and links.
6
+
7
+ > New here? Start with: [../docs/what-is-admitiq.md](../docs/what-is-admitiq.md)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install admitiq
13
+ pip install admitiq[qr] # optional: QR images
14
+ pip install admitiq[ec] # optional: ES256
15
+ pip install admitiq[redis] # optional: Redis single-use store
16
+ ```
17
+
18
+ ## Tiny example
19
+
20
+ ```python
21
+ from admitiq import issue, verify, issue_url, issue_qr
22
+
23
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=3600, secret="your-secret-key")
24
+ url = issue_url("https://example.com/scan", {"ticket_id": "abc123"}, ttl_seconds=3600, secret="your-secret-key")
25
+ # issue_qr(..., output_path="ticket.png") # needs: pip install admitiq[qr]
26
+
27
+ payload = verify(token, secret="your-secret-key")
28
+ print(payload["data"])
29
+ ```
30
+
31
+ A Node app can verify this same token with the same secret (`npm install admitiq`).
32
+
33
+ ## More
34
+
35
+ | Topic | Link |
36
+ |-------|------|
37
+ | Full Python guide | [docs/python.md](../docs/python.md) |
38
+ | QR & URL delivery | [docs/delivering-tokens.md](../docs/delivering-tokens.md) |
39
+ | Key rotation | [docs/key-rotation.md](../docs/key-rotation.md) |
40
+ | Security | [SECURITY.md](../SECURITY.md) |
41
+ | Publish to PyPI | [PUBLISH.md](../PUBLISH.md) |
42
+ | Flask demo | [examples/flask-attendance](../examples/flask-attendance) |
43
+
44
+ ## License
45
+
46
+ MIT — [LogicLitz](https://logiclitz.org)
@@ -0,0 +1,34 @@
1
+ from .core import (
2
+ AdmitiqError,
3
+ TokenExpiredError,
4
+ InvalidSignatureError,
5
+ TokenRevokedError,
6
+ UnsupportedTokenVersionError,
7
+ SUPPORTED_VERSIONS,
8
+ issue,
9
+ verify,
10
+ verify_with_secrets,
11
+ )
12
+ from .qr import generate_qr, issue_qr, issue_url_qr
13
+ from .url import DEFAULT_PARAM, embed_in_url, issue_url, token_from_url
14
+
15
+ __all__ = [
16
+ "issue",
17
+ "verify",
18
+ "verify_with_secrets",
19
+ "generate_qr",
20
+ "issue_qr",
21
+ "issue_url_qr",
22
+ "embed_in_url",
23
+ "issue_url",
24
+ "token_from_url",
25
+ "DEFAULT_PARAM",
26
+ "SUPPORTED_VERSIONS",
27
+ "AdmitiqError",
28
+ "TokenExpiredError",
29
+ "InvalidSignatureError",
30
+ "TokenRevokedError",
31
+ "UnsupportedTokenVersionError",
32
+ ]
33
+
34
+ __version__ = "0.3.1"
@@ -0,0 +1,170 @@
1
+ """
2
+ admitiq.core — signed, expiring, revocable tokens for QR codes.
3
+
4
+ No AI, no required server, no vendor lock-in. Pure HMAC signing + expiry
5
+ checking + an optional hook for revocation/single-use enforcement.
6
+ """
7
+ import base64
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import time
12
+ import uuid
13
+ from typing import Any, Callable, Dict, Optional, Sequence
14
+
15
+ # Wire-format versions this library can verify. Breaking changes require a new v.
16
+ SUPPORTED_VERSIONS = (1,)
17
+
18
+
19
+ class AdmitiqError(Exception):
20
+ """Base exception for all admitiq errors."""
21
+
22
+
23
+ class TokenExpiredError(AdmitiqError):
24
+ """Raised when a token's expiry timestamp has passed."""
25
+
26
+
27
+ class InvalidSignatureError(AdmitiqError):
28
+ """Raised when a token is malformed or its signature doesn't match."""
29
+
30
+
31
+ class TokenRevokedError(AdmitiqError):
32
+ """Raised when the is_revoked callback reports this token as revoked/used."""
33
+
34
+
35
+ class UnsupportedTokenVersionError(AdmitiqError):
36
+ """Raised when the token's header ``v`` is not supported by this library."""
37
+
38
+
39
+ def _b64url_encode(data: bytes) -> str:
40
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
41
+
42
+
43
+ def _b64url_decode(data: str) -> bytes:
44
+ padding = "=" * (-len(data) % 4)
45
+ return base64.urlsafe_b64decode(data + padding)
46
+
47
+
48
+ def _sign(message: bytes, secret: str) -> str:
49
+ digest = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
50
+ return _b64url_encode(digest)
51
+
52
+
53
+ def _assert_supported_version(header_b64: str) -> Dict[str, Any]:
54
+ try:
55
+ header = json.loads(_b64url_decode(header_b64))
56
+ except (json.JSONDecodeError, ValueError) as e:
57
+ raise InvalidSignatureError("Malformed token header") from e
58
+ v = header.get("v")
59
+ if v not in SUPPORTED_VERSIONS:
60
+ raise UnsupportedTokenVersionError(
61
+ f"Unsupported token version {v}. "
62
+ f"This library supports: {', '.join(str(x) for x in SUPPORTED_VERSIONS)}"
63
+ )
64
+ return header
65
+
66
+
67
+ def issue(payload: Dict[str, Any], ttl_seconds: int, secret: str) -> str:
68
+ """
69
+ Create a signed, expiring AdmitiQ token.
70
+
71
+ Args:
72
+ payload: arbitrary JSON-serializable data to embed (e.g. {"ticket_id": "abc123"})
73
+ ttl_seconds: how many seconds until this token expires
74
+ secret: shared HMAC secret used to sign the token (keep this server-side only)
75
+
76
+ Returns:
77
+ A compact token string, safe to encode directly into a QR code.
78
+ """
79
+ now = int(time.time())
80
+ header = {"alg": "HS256", "typ": "QRT", "v": 1}
81
+ body = {
82
+ "iat": now,
83
+ "exp": now + ttl_seconds,
84
+ "jti": uuid.uuid4().hex,
85
+ "data": payload,
86
+ }
87
+ header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode())
88
+ body_b64 = _b64url_encode(json.dumps(body, separators=(",", ":")).encode())
89
+ signing_input = f"{header_b64}.{body_b64}".encode()
90
+ signature = _sign(signing_input, secret)
91
+ return f"{header_b64}.{body_b64}.{signature}"
92
+
93
+
94
+ def verify(
95
+ token: str,
96
+ secret: str,
97
+ is_revoked: Optional[Callable[[str], bool]] = None,
98
+ ) -> Dict[str, Any]:
99
+ """
100
+ Verify a AdmitiQ token's signature, expiry, and (optionally) revocation status.
101
+
102
+ Args:
103
+ token: the token string decoded from the scanned QR code
104
+ secret: the same HMAC secret used to issue the token
105
+ is_revoked: optional callback that receives the token's jti (unique id)
106
+ and returns True if that specific token has been revoked or already
107
+ used. This is where you'd plug in your own database check, or a
108
+ call to a hosted revocation service.
109
+
110
+ Returns:
111
+ The embedded payload dict (the "data" field passed to issue()), plus
112
+ iat/exp/jti metadata.
113
+
114
+ Raises:
115
+ InvalidSignatureError: token is malformed or signature doesn't match
116
+ TokenExpiredError: token's expiry timestamp has passed
117
+ TokenRevokedError: is_revoked callback reported this token as revoked
118
+ UnsupportedTokenVersionError: token header ``v`` is not supported
119
+ """
120
+ try:
121
+ header_b64, body_b64, signature = token.split(".")
122
+ except ValueError:
123
+ raise InvalidSignatureError("Malformed token: expected 3 dot-separated parts")
124
+
125
+ signing_input = f"{header_b64}.{body_b64}".encode()
126
+ expected_signature = _sign(signing_input, secret)
127
+ if not hmac.compare_digest(expected_signature, signature):
128
+ raise InvalidSignatureError("Signature mismatch")
129
+
130
+ # Version is inside the signed header — check only after the signature matches.
131
+ _assert_supported_version(header_b64)
132
+
133
+ body = json.loads(_b64url_decode(body_b64))
134
+
135
+ if int(time.time()) > body["exp"]:
136
+ raise TokenExpiredError(f"Token expired at {body['exp']}")
137
+
138
+ if is_revoked is not None and is_revoked(body["jti"]):
139
+ raise TokenRevokedError(f"Token {body['jti']} has been revoked")
140
+
141
+ return body
142
+
143
+
144
+ def verify_with_secrets(
145
+ token: str,
146
+ secrets: Sequence[str],
147
+ is_revoked: Optional[Callable[[str], bool]] = None,
148
+ ) -> Dict[str, Any]:
149
+ """
150
+ Verify against multiple HMAC secrets (key-rotation window).
151
+
152
+ Tries secrets in order; succeeds on the first valid signature.
153
+ Non-signature errors (expired, revoked, unsupported version) propagate immediately.
154
+
155
+ Args:
156
+ token: token string from the QR scan
157
+ secrets: current secret first, then previous secrets still in the rotation window
158
+ is_revoked: optional revocation callback (same as ``verify``)
159
+ """
160
+ if not secrets:
161
+ raise InvalidSignatureError("At least one secret is required")
162
+
163
+ last_sig_error: Optional[InvalidSignatureError] = None
164
+ for secret in secrets:
165
+ try:
166
+ return verify(token, secret=secret, is_revoked=is_revoked)
167
+ except InvalidSignatureError as e:
168
+ last_sig_error = e
169
+ continue
170
+ raise last_sig_error or InvalidSignatureError("Signature mismatch")
@@ -0,0 +1,155 @@
1
+ """
2
+ admitiq.ec — optional asymmetric (ES256 / ECDSA P-256) signing support.
3
+
4
+ Use this instead of core.issue/verify when the verifier (e.g. a scanner app
5
+ running on someone's phone, or a public endpoint) should NOT hold the same
6
+ secret used to issue tokens. The issuer signs with a private key; anyone
7
+ holding only the public key can verify a token but cannot forge new ones.
8
+
9
+ Requires the 'cryptography' package:
10
+ pip install admitiq[ec]
11
+ """
12
+ import json
13
+ import time
14
+ import uuid
15
+ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
16
+
17
+ from .core import (
18
+ InvalidSignatureError,
19
+ TokenExpiredError,
20
+ TokenRevokedError,
21
+ _assert_supported_version,
22
+ _b64url_decode,
23
+ _b64url_encode,
24
+ )
25
+
26
+
27
+ def generate_keypair() -> Tuple[str, str]:
28
+ """
29
+ Generate a new EC (P-256 / secp256r1) keypair.
30
+
31
+ Returns:
32
+ (private_key_pem, public_key_pem) — both as PEM-encoded strings.
33
+ Keep the private key secret (it issues tokens); the public key can be
34
+ shared freely with anything that only needs to verify tokens.
35
+ """
36
+ try:
37
+ from cryptography.hazmat.primitives import serialization
38
+ from cryptography.hazmat.primitives.asymmetric import ec
39
+ except ImportError as e:
40
+ raise ImportError(
41
+ "Asymmetric signing requires the 'cryptography' package. "
42
+ "Install it with: pip install admitiq[ec]"
43
+ ) from e
44
+
45
+ private_key = ec.generate_private_key(ec.SECP256R1())
46
+ public_key = private_key.public_key()
47
+
48
+ private_pem = private_key.private_bytes(
49
+ encoding=serialization.Encoding.PEM,
50
+ format=serialization.PrivateFormat.PKCS8,
51
+ encryption_algorithm=serialization.NoEncryption(),
52
+ ).decode("ascii")
53
+ public_pem = public_key.public_bytes(
54
+ encoding=serialization.Encoding.PEM,
55
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
56
+ ).decode("ascii")
57
+ return private_pem, public_pem
58
+
59
+
60
+ def issue(payload: Dict[str, Any], ttl_seconds: int, private_key_pem: str) -> str:
61
+ """Create a token signed with an EC private key (algorithm: ES256)."""
62
+ try:
63
+ from cryptography.hazmat.primitives import hashes, serialization
64
+ from cryptography.hazmat.primitives.asymmetric import ec
65
+ except ImportError as e:
66
+ raise ImportError(
67
+ "Asymmetric signing requires the 'cryptography' package. "
68
+ "Install it with: pip install admitiq[ec]"
69
+ ) from e
70
+
71
+ private_key = serialization.load_pem_private_key(
72
+ private_key_pem.encode("ascii"), password=None
73
+ )
74
+
75
+ now = int(time.time())
76
+ header = {"alg": "ES256", "typ": "QRT", "v": 1}
77
+ body = {
78
+ "iat": now,
79
+ "exp": now + ttl_seconds,
80
+ "jti": uuid.uuid4().hex,
81
+ "data": payload,
82
+ }
83
+ header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode())
84
+ body_b64 = _b64url_encode(json.dumps(body, separators=(",", ":")).encode())
85
+ signing_input = f"{header_b64}.{body_b64}".encode()
86
+
87
+ signature = private_key.sign(signing_input, ec.ECDSA(hashes.SHA256()))
88
+ signature_b64 = _b64url_encode(signature)
89
+ return f"{header_b64}.{body_b64}.{signature_b64}"
90
+
91
+
92
+ def verify(
93
+ token: str,
94
+ public_key_pem: str,
95
+ is_revoked: Optional[Callable[[str], bool]] = None,
96
+ ) -> Dict[str, Any]:
97
+ """Verify a token created by issue() using the corresponding EC public key."""
98
+ try:
99
+ from cryptography.exceptions import InvalidSignature
100
+ from cryptography.hazmat.primitives import hashes, serialization
101
+ from cryptography.hazmat.primitives.asymmetric import ec
102
+ except ImportError as e:
103
+ raise ImportError(
104
+ "Asymmetric verification requires the 'cryptography' package. "
105
+ "Install it with: pip install admitiq[ec]"
106
+ ) from e
107
+
108
+ try:
109
+ header_b64, body_b64, signature_b64 = token.split(".")
110
+ except ValueError:
111
+ raise InvalidSignatureError("Malformed token: expected 3 dot-separated parts")
112
+
113
+ public_key = serialization.load_pem_public_key(public_key_pem.encode("ascii"))
114
+ signing_input = f"{header_b64}.{body_b64}".encode()
115
+ signature = _b64url_decode(signature_b64)
116
+
117
+ try:
118
+ public_key.verify(signature, signing_input, ec.ECDSA(hashes.SHA256()))
119
+ except InvalidSignature:
120
+ raise InvalidSignatureError("Signature mismatch")
121
+
122
+ _assert_supported_version(header_b64)
123
+
124
+ body = json.loads(_b64url_decode(body_b64))
125
+
126
+ if int(time.time()) > body["exp"]:
127
+ raise TokenExpiredError(f"Token expired at {body['exp']}")
128
+
129
+ if is_revoked is not None and is_revoked(body["jti"]):
130
+ raise TokenRevokedError(f"Token {body['jti']} has been revoked")
131
+
132
+ return body
133
+
134
+
135
+ def verify_with_public_keys(
136
+ token: str,
137
+ public_key_pems: Sequence[str],
138
+ is_revoked: Optional[Callable[[str], bool]] = None,
139
+ ) -> Dict[str, Any]:
140
+ """
141
+ Verify against multiple EC public keys (key-rotation window).
142
+
143
+ Tries keys in order; succeeds on the first valid signature.
144
+ """
145
+ if not public_key_pems:
146
+ raise InvalidSignatureError("At least one public key is required")
147
+
148
+ last_sig_error: Optional[InvalidSignatureError] = None
149
+ for public_key_pem in public_key_pems:
150
+ try:
151
+ return verify(token, public_key_pem=public_key_pem, is_revoked=is_revoked)
152
+ except InvalidSignatureError as e:
153
+ last_sig_error = e
154
+ continue
155
+ raise last_sig_error or InvalidSignatureError("Signature mismatch")
@@ -0,0 +1,75 @@
1
+ """
2
+ admitiq.frameworks — optional drop-in helpers for common web frameworks.
3
+
4
+ These are thin wrappers around verify() — you don't need them, the plain
5
+ verify() function works fine on its own. They just save a few lines of
6
+ boilerplate in the frameworks people reach for most often.
7
+
8
+ FastAPI support requires 'fastapi' installed (you already have it if you're
9
+ using FastAPI). Flask support requires 'flask' installed likewise. Neither
10
+ is a hard dependency of admitiq itself.
11
+ """
12
+ from functools import wraps
13
+ from typing import Callable, Optional
14
+
15
+ from .core import AdmitiqError, verify
16
+
17
+
18
+ def fastapi_dependency(secret: str, is_revoked: Optional[Callable[[str], bool]] = None):
19
+ """
20
+ Build a FastAPI dependency that verifies an AdmitiQ token from the JSON
21
+ request body (expects {"token": "..."}).
22
+
23
+ Example:
24
+ from fastapi import Depends
25
+ check_ticket = fastapi_dependency(secret="...", is_revoked=my_check)
26
+
27
+ @app.post("/scan")
28
+ async def scan(payload: dict = Depends(check_ticket)):
29
+ return {"ok": True, "data": payload["data"]}
30
+ """
31
+ from fastapi import HTTPException, Request
32
+
33
+ async def dependency(request: Request):
34
+ body = await request.json()
35
+ token = body.get("token")
36
+ if not token:
37
+ raise HTTPException(status_code=400, detail="Missing QR token")
38
+ try:
39
+ return verify(token, secret=secret, is_revoked=is_revoked)
40
+ except AdmitiqError as e:
41
+ raise HTTPException(status_code=401, detail=str(e))
42
+
43
+ return dependency
44
+
45
+
46
+ def flask_require_token(secret: str, is_revoked: Optional[Callable[[str], bool]] = None):
47
+ """
48
+ Flask route decorator that verifies an AdmitiQ token from the JSON request
49
+ body (expects {"token": "..."}) and passes the decoded payload as the
50
+ `admitiq_payload` keyword argument to your view function.
51
+
52
+ Example:
53
+ @app.route("/scan", methods=["POST"])
54
+ @flask_require_token(secret="...", is_revoked=my_check)
55
+ def scan(admitiq_payload):
56
+ return {"ok": True, "data": admitiq_payload["data"]}
57
+ """
58
+ from flask import jsonify, request
59
+
60
+ def decorator(view_func):
61
+ @wraps(view_func)
62
+ def wrapped(*args, **kwargs):
63
+ body = request.get_json(silent=True) or {}
64
+ token = body.get("token")
65
+ if not token:
66
+ return jsonify({"error": "Missing QR token"}), 400
67
+ try:
68
+ payload = verify(token, secret=secret, is_revoked=is_revoked)
69
+ except AdmitiqError as e:
70
+ return jsonify({"error": str(e)}), 401
71
+ return view_func(*args, admitiq_payload=payload, **kwargs)
72
+
73
+ return wrapped
74
+
75
+ return decorator
@@ -0,0 +1,67 @@
1
+ """
2
+ admitiq.qr — optional QR image generation helpers.
3
+
4
+ Requires the 'qrcode' package:
5
+ pip install admitiq[qr]
6
+
7
+ You can encode either a raw token or a full URL (from issue_url / embed_in_url).
8
+ """
9
+ from typing import Any, Dict, Optional
10
+
11
+ from .core import issue
12
+ from .url import issue_url, token_from_url
13
+
14
+
15
+ def generate_qr(content: str, output_path: Optional[str] = None):
16
+ """
17
+ Render a string (token or URL) as a QR code image.
18
+
19
+ Args:
20
+ content: token or URL to encode in the QR
21
+ output_path: if provided, saves the image and returns the path.
22
+ If omitted, returns a PIL Image object instead.
23
+ """
24
+ try:
25
+ import qrcode
26
+ except ImportError as e:
27
+ raise ImportError(
28
+ "Generating QR images requires the 'qrcode' package. "
29
+ "Install it with: pip install admitiq[qr]"
30
+ ) from e
31
+
32
+ img = qrcode.make(content)
33
+ if output_path:
34
+ img.save(output_path)
35
+ return output_path
36
+ return img
37
+
38
+
39
+ def issue_qr(
40
+ payload: Dict[str, Any],
41
+ ttl_seconds: int,
42
+ secret: str,
43
+ output_path: Optional[str] = None,
44
+ ) -> Dict[str, Any]:
45
+ """Issue a token and render it as a QR image in one step."""
46
+ token = issue(payload, ttl_seconds=ttl_seconds, secret=secret)
47
+ qr = generate_qr(token, output_path=output_path)
48
+ return {"token": token, "qr": qr}
49
+
50
+
51
+ def issue_url_qr(
52
+ base_url: str,
53
+ payload: Dict[str, Any],
54
+ ttl_seconds: int,
55
+ secret: str,
56
+ output_path: Optional[str] = None,
57
+ param: str = "token",
58
+ ) -> Dict[str, Any]:
59
+ """
60
+ Issue a token embedded in a URL, then render that URL as a QR image.
61
+
62
+ Scanning carries the full link (useful for web check-in pages).
63
+ """
64
+ url = issue_url(base_url, payload, ttl_seconds=ttl_seconds, secret=secret, param=param)
65
+ token = token_from_url(url, param=param)
66
+ qr = generate_qr(url, output_path=output_path)
67
+ return {"token": token, "url": url, "qr": qr}
@@ -0,0 +1,3 @@
1
+ from .redis_store import RedisRevocationStore
2
+
3
+ __all__ = ["RedisRevocationStore"]
@@ -0,0 +1,64 @@
1
+ """
2
+ admitiq.stores.redis_store — optional Redis-backed revocation store.
3
+
4
+ A ready-made, race-condition-safe "has this token been used" store for
5
+ anyone running multiple servers/processes checking the same tokens, without
6
+ needing the hosted AdmitiQ API. Still fully self-hosted and free.
7
+
8
+ Requires the 'redis' package:
9
+ pip install admitiq[redis]
10
+ """
11
+ from typing import Optional
12
+
13
+
14
+ class RedisRevocationStore:
15
+ """
16
+ Usage:
17
+ store = RedisRevocationStore(url="redis://localhost:6379/0")
18
+ payload = verify(token, secret=SECRET, is_revoked=store.is_revoked)
19
+ # only after you've decided to actually honor this scan:
20
+ store.mark_used(payload["jti"])
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ redis_client=None,
26
+ url: Optional[str] = None,
27
+ ttl_seconds: int = 86400,
28
+ prefix: str = "admitiq:used:",
29
+ ):
30
+ """
31
+ Args:
32
+ redis_client: an existing redis.Redis instance (preferred if you already have one)
33
+ url: alternatively, a redis:// URL to connect with
34
+ ttl_seconds: how long to remember a used jti — should be >= your longest token TTL
35
+ prefix: key prefix used in Redis
36
+ """
37
+ if redis_client is not None:
38
+ self._client = redis_client
39
+ else:
40
+ try:
41
+ import redis
42
+ except ImportError as e:
43
+ raise ImportError(
44
+ "RedisRevocationStore requires the 'redis' package. "
45
+ "Install it with: pip install admitiq[redis]"
46
+ ) from e
47
+ self._client = redis.Redis.from_url(url or "redis://localhost:6379/0")
48
+ self._ttl = ttl_seconds
49
+ self._prefix = prefix
50
+
51
+ def mark_used(self, jti: str) -> bool:
52
+ """
53
+ Atomically mark a token as used. Returns True if this was the FIRST
54
+ time it was marked (the scan should be allowed), False if it was
55
+ already used (this is a reuse attempt and should be blocked).
56
+ """
57
+ key = f"{self._prefix}{jti}"
58
+ was_set = self._client.set(key, "1", nx=True, ex=self._ttl)
59
+ return bool(was_set)
60
+
61
+ def is_revoked(self, jti: str) -> bool:
62
+ """Pass this method directly as the `is_revoked` argument to verify()."""
63
+ key = f"{self._prefix}{jti}"
64
+ return bool(self._client.exists(key))
@@ -0,0 +1,55 @@
1
+ """
2
+ admitiq.url — embed tokens in URLs / deep links (and pull them back out).
3
+
4
+ The token is still a plain signed string. These helpers only attach it to a
5
+ URL you provide (default query param: \"token\").
6
+ """
7
+ from typing import Any, Dict, Optional
8
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
9
+
10
+ from .core import issue
11
+
12
+ DEFAULT_PARAM = "token"
13
+
14
+
15
+ def embed_in_url(base_url: str, token: str, param: str = DEFAULT_PARAM) -> str:
16
+ """
17
+ Attach an existing token to a base URL as a query parameter.
18
+
19
+ Works with https:// links and custom schemes (e.g. myapp://checkin).
20
+ Existing query parameters are preserved; ``param`` is set/replaced.
21
+ """
22
+ if not base_url or not isinstance(base_url, str):
23
+ raise ValueError("base_url must be a non-empty string")
24
+ if not token or not isinstance(token, str):
25
+ raise ValueError("token must be a non-empty string")
26
+
27
+ parts = urlsplit(base_url)
28
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
29
+ query[param] = token
30
+ new_query = urlencode(query)
31
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
32
+
33
+
34
+ def issue_url(
35
+ base_url: str,
36
+ payload: Dict[str, Any],
37
+ ttl_seconds: int,
38
+ secret: str,
39
+ param: str = DEFAULT_PARAM,
40
+ ) -> str:
41
+ """Issue a token and return a URL with it embedded."""
42
+ token = issue(payload, ttl_seconds=ttl_seconds, secret=secret)
43
+ return embed_in_url(base_url, token, param=param)
44
+
45
+
46
+ def token_from_url(url: str, param: str = DEFAULT_PARAM) -> str:
47
+ """Extract a token from a URL's query string."""
48
+ if not url or not isinstance(url, str):
49
+ raise ValueError("url must be a non-empty string")
50
+ parts = urlsplit(url)
51
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
52
+ token = query.get(param)
53
+ if not token:
54
+ raise ValueError(f'No "{param}" query parameter found in URL')
55
+ return token
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: admitiq
3
+ Version: 0.3.1
4
+ Summary: AdmitiQ: signed, expiring, revocable tokens for QR codes and links. No AI, no required server.
5
+ Author: LogicLitz
6
+ License: MIT
7
+ Project-URL: Homepage, https://logiclitz.org
8
+ Project-URL: Repository, https://github.com/HyperXfury1873/admitiq
9
+ Project-URL: Issues, https://github.com/HyperXfury1873/admitiq/issues
10
+ Keywords: qr,qrcode,security,tokens,tickets,attendance,coupons
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ Provides-Extra: qr
17
+ Requires-Dist: qrcode[pil]>=7.4; extra == "qr"
18
+ Provides-Extra: ec
19
+ Requires-Dist: cryptography>=41.0; extra == "ec"
20
+ Provides-Extra: redis
21
+ Requires-Dist: redis>=4.5; extra == "redis"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Requires-Dist: fastapi>=0.100; extra == "dev"
25
+ Requires-Dist: flask>=2.3; extra == "dev"
26
+ Requires-Dist: httpx>=0.24; extra == "dev"
27
+
28
+ # AdmitiQ (Python)
29
+
30
+ **A [LogicLitz](https://logiclitz.org) open-source project.**
31
+
32
+ Signed, expiring, revocable tokens for QR codes and links.
33
+
34
+ > New here? Start with: [../docs/what-is-admitiq.md](../docs/what-is-admitiq.md)
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install admitiq
40
+ pip install admitiq[qr] # optional: QR images
41
+ pip install admitiq[ec] # optional: ES256
42
+ pip install admitiq[redis] # optional: Redis single-use store
43
+ ```
44
+
45
+ ## Tiny example
46
+
47
+ ```python
48
+ from admitiq import issue, verify, issue_url, issue_qr
49
+
50
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=3600, secret="your-secret-key")
51
+ url = issue_url("https://example.com/scan", {"ticket_id": "abc123"}, ttl_seconds=3600, secret="your-secret-key")
52
+ # issue_qr(..., output_path="ticket.png") # needs: pip install admitiq[qr]
53
+
54
+ payload = verify(token, secret="your-secret-key")
55
+ print(payload["data"])
56
+ ```
57
+
58
+ A Node app can verify this same token with the same secret (`npm install admitiq`).
59
+
60
+ ## More
61
+
62
+ | Topic | Link |
63
+ |-------|------|
64
+ | Full Python guide | [docs/python.md](../docs/python.md) |
65
+ | QR & URL delivery | [docs/delivering-tokens.md](../docs/delivering-tokens.md) |
66
+ | Key rotation | [docs/key-rotation.md](../docs/key-rotation.md) |
67
+ | Security | [SECURITY.md](../SECURITY.md) |
68
+ | Publish to PyPI | [PUBLISH.md](../PUBLISH.md) |
69
+ | Flask demo | [examples/flask-attendance](../examples/flask-attendance) |
70
+
71
+ ## License
72
+
73
+ MIT — [LogicLitz](https://logiclitz.org)
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ admitiq/__init__.py
4
+ admitiq/core.py
5
+ admitiq/ec.py
6
+ admitiq/frameworks.py
7
+ admitiq/qr.py
8
+ admitiq/url.py
9
+ admitiq.egg-info/PKG-INFO
10
+ admitiq.egg-info/SOURCES.txt
11
+ admitiq.egg-info/dependency_links.txt
12
+ admitiq.egg-info/requires.txt
13
+ admitiq.egg-info/top_level.txt
14
+ admitiq/stores/__init__.py
15
+ admitiq/stores/redis_store.py
16
+ tests/test_core.py
17
+ tests/test_ec.py
18
+ tests/test_url.py
@@ -0,0 +1,15 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
4
+ fastapi>=0.100
5
+ flask>=2.3
6
+ httpx>=0.24
7
+
8
+ [ec]
9
+ cryptography>=41.0
10
+
11
+ [qr]
12
+ qrcode[pil]>=7.4
13
+
14
+ [redis]
15
+ redis>=4.5
@@ -0,0 +1 @@
1
+ admitiq
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "admitiq"
7
+ version = "0.3.1"
8
+ description = "AdmitiQ: signed, expiring, revocable tokens for QR codes and links. No AI, no required server."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "LogicLitz" }]
13
+ keywords = ["qr", "qrcode", "security", "tokens", "tickets", "attendance", "coupons"]
14
+ dependencies = []
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ qr = ["qrcode[pil]>=7.4"]
23
+ ec = ["cryptography>=41.0"]
24
+ redis = ["redis>=4.5"]
25
+ dev = ["pytest>=7.0", "fastapi>=0.100", "flask>=2.3", "httpx>=0.24"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://logiclitz.org"
29
+ Repository = "https://github.com/HyperXfury1873/admitiq"
30
+ Issues = "https://github.com/HyperXfury1873/admitiq/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,64 @@
1
+ import pytest
2
+
3
+ from admitiq.core import (
4
+ InvalidSignatureError,
5
+ TokenExpiredError,
6
+ TokenRevokedError,
7
+ issue,
8
+ verify,
9
+ )
10
+
11
+ SECRET = "test-secret"
12
+
13
+
14
+ def test_issue_and_verify_roundtrip():
15
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=60, secret=SECRET)
16
+ payload = verify(token, secret=SECRET)
17
+ assert payload["data"]["ticket_id"] == "abc123"
18
+ assert "iat" in payload and "exp" in payload and "jti" in payload
19
+
20
+
21
+ def test_expired_token_raises():
22
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=-1, secret=SECRET)
23
+ with pytest.raises(TokenExpiredError):
24
+ verify(token, secret=SECRET)
25
+
26
+
27
+ def test_tampered_signature_raises():
28
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=60, secret=SECRET)
29
+ tampered = token[:-2] + "xx"
30
+ with pytest.raises(InvalidSignatureError):
31
+ verify(tampered, secret=SECRET)
32
+
33
+
34
+ def test_wrong_secret_raises():
35
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=60, secret=SECRET)
36
+ with pytest.raises(InvalidSignatureError):
37
+ verify(token, secret="wrong-secret")
38
+
39
+
40
+ def test_revoked_token_raises():
41
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=60, secret=SECRET)
42
+ with pytest.raises(TokenRevokedError):
43
+ verify(token, secret=SECRET, is_revoked=lambda jti: True)
44
+
45
+
46
+ def test_malformed_token_raises():
47
+ with pytest.raises(InvalidSignatureError):
48
+ verify("not-a-real-token", secret=SECRET)
49
+
50
+
51
+ def test_verify_with_secrets_rotation():
52
+ from admitiq.core import verify_with_secrets
53
+
54
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=60, secret="old-secret")
55
+ payload = verify_with_secrets(token, secrets=["new-secret", "old-secret"])
56
+ assert payload["data"]["ticket_id"] == "abc123"
57
+
58
+
59
+ def test_verify_with_secrets_all_fail():
60
+ from admitiq.core import verify_with_secrets
61
+
62
+ token = issue({"ticket_id": "abc123"}, ttl_seconds=60, secret=SECRET)
63
+ with pytest.raises(InvalidSignatureError):
64
+ verify_with_secrets(token, secrets=["a", "b"])
@@ -0,0 +1,38 @@
1
+ import pytest
2
+
3
+ cryptography = pytest.importorskip("cryptography")
4
+
5
+ from admitiq import ec
6
+ from admitiq.core import InvalidSignatureError, TokenExpiredError
7
+
8
+
9
+ def test_generate_keypair_and_roundtrip():
10
+ priv, pub = ec.generate_keypair()
11
+ token = ec.issue({"ticket_id": "abc123"}, ttl_seconds=60, private_key_pem=priv)
12
+ payload = ec.verify(token, public_key_pem=pub)
13
+ assert payload["data"]["ticket_id"] == "abc123"
14
+
15
+
16
+ def test_wrong_public_key_rejected():
17
+ priv, _pub = ec.generate_keypair()
18
+ _priv2, pub2 = ec.generate_keypair()
19
+ token = ec.issue({"ticket_id": "abc123"}, ttl_seconds=60, private_key_pem=priv)
20
+ with pytest.raises(InvalidSignatureError):
21
+ ec.verify(token, public_key_pem=pub2)
22
+
23
+
24
+ def test_expired_ec_token_raises():
25
+ priv, pub = ec.generate_keypair()
26
+ token = ec.issue({"ticket_id": "abc123"}, ttl_seconds=-1, private_key_pem=priv)
27
+ with pytest.raises(TokenExpiredError):
28
+ ec.verify(token, public_key_pem=pub)
29
+
30
+
31
+ def test_verify_with_public_keys_rotation():
32
+ old_priv, old_pub = ec.generate_keypair()
33
+ _new_priv, new_pub = ec.generate_keypair()
34
+ token = ec.issue({"ticket_id": "abc123"}, ttl_seconds=60, private_key_pem=old_priv)
35
+ payload = ec.verify_with_public_keys(
36
+ token, public_key_pems=[new_pub, old_pub]
37
+ )
38
+ assert payload["data"]["ticket_id"] == "abc123"
@@ -0,0 +1,34 @@
1
+ from admitiq import issue, verify
2
+ from admitiq.url import embed_in_url, issue_url, token_from_url
3
+
4
+ SECRET = "url-test-secret"
5
+
6
+
7
+ def test_issue_url_embeds_verifiable_token():
8
+ url = issue_url("https://example.com/scan", {"seat": "A1"}, ttl_seconds=60, secret=SECRET)
9
+ assert url.startswith("https://example.com/scan?token=")
10
+ token = token_from_url(url)
11
+ payload = verify(token, secret=SECRET)
12
+ assert payload["data"]["seat"] == "A1"
13
+
14
+
15
+ def test_embed_preserves_query():
16
+ token = issue({"x": 1}, ttl_seconds=60, secret=SECRET)
17
+ url = embed_in_url("https://example.com/scan?ref=web", token)
18
+ assert "ref=web" in url
19
+ assert "token=" in url
20
+ assert token_from_url(url) == token
21
+
22
+
23
+ def test_custom_scheme():
24
+ token = issue({"x": 1}, ttl_seconds=60, secret=SECRET)
25
+ url = embed_in_url("myapp://checkin", token)
26
+ assert url.startswith("myapp://checkin")
27
+ assert token_from_url(url) == token
28
+
29
+
30
+ def test_token_from_url_missing():
31
+ import pytest
32
+
33
+ with pytest.raises(ValueError, match='No "token"'):
34
+ token_from_url("https://example.com/scan")