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 +73 -0
- admitiq-0.3.1/README.md +46 -0
- admitiq-0.3.1/admitiq/__init__.py +34 -0
- admitiq-0.3.1/admitiq/core.py +170 -0
- admitiq-0.3.1/admitiq/ec.py +155 -0
- admitiq-0.3.1/admitiq/frameworks.py +75 -0
- admitiq-0.3.1/admitiq/qr.py +67 -0
- admitiq-0.3.1/admitiq/stores/__init__.py +3 -0
- admitiq-0.3.1/admitiq/stores/redis_store.py +64 -0
- admitiq-0.3.1/admitiq/url.py +55 -0
- admitiq-0.3.1/admitiq.egg-info/PKG-INFO +73 -0
- admitiq-0.3.1/admitiq.egg-info/SOURCES.txt +18 -0
- admitiq-0.3.1/admitiq.egg-info/dependency_links.txt +1 -0
- admitiq-0.3.1/admitiq.egg-info/requires.txt +15 -0
- admitiq-0.3.1/admitiq.egg-info/top_level.txt +1 -0
- admitiq-0.3.1/pyproject.toml +30 -0
- admitiq-0.3.1/setup.cfg +4 -0
- admitiq-0.3.1/tests/test_core.py +64 -0
- admitiq-0.3.1/tests/test_ec.py +38 -0
- admitiq-0.3.1/tests/test_url.py +34 -0
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)
|
admitiq-0.3.1/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"
|
admitiq-0.3.1/setup.cfg
ADDED
|
@@ -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")
|