sealedwebtoken 1.0.0__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.
- sealedwebtoken-1.0.0/.gitignore +230 -0
- sealedwebtoken-1.0.0/PKG-INFO +110 -0
- sealedwebtoken-1.0.0/README.md +91 -0
- sealedwebtoken-1.0.0/pyproject.toml +34 -0
- sealedwebtoken-1.0.0/swt/__init__.py +345 -0
- sealedwebtoken-1.0.0/test.py +142 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
|
|
3
|
+
logs
|
|
4
|
+
_.log
|
|
5
|
+
npm-debug.log_
|
|
6
|
+
yarn-debug.log*
|
|
7
|
+
yarn-error.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
.pnpm-debug.log*
|
|
10
|
+
|
|
11
|
+
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
12
|
+
|
|
13
|
+
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|
14
|
+
|
|
15
|
+
# Runtime data
|
|
16
|
+
|
|
17
|
+
pids
|
|
18
|
+
_.pid
|
|
19
|
+
_.seed
|
|
20
|
+
\*.pid.lock
|
|
21
|
+
|
|
22
|
+
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
23
|
+
|
|
24
|
+
lib-cov
|
|
25
|
+
|
|
26
|
+
# Coverage directory used by tools like istanbul
|
|
27
|
+
|
|
28
|
+
coverage
|
|
29
|
+
\*.lcov
|
|
30
|
+
|
|
31
|
+
# nyc test coverage
|
|
32
|
+
|
|
33
|
+
.nyc_output
|
|
34
|
+
|
|
35
|
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
36
|
+
|
|
37
|
+
.grunt
|
|
38
|
+
|
|
39
|
+
# Bower dependency directory (https://bower.io/)
|
|
40
|
+
|
|
41
|
+
bower_components
|
|
42
|
+
|
|
43
|
+
# node-waf configuration
|
|
44
|
+
|
|
45
|
+
.lock-wscript
|
|
46
|
+
|
|
47
|
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
48
|
+
|
|
49
|
+
build/Release
|
|
50
|
+
|
|
51
|
+
# Dependency directories
|
|
52
|
+
|
|
53
|
+
node_modules/
|
|
54
|
+
jspm_packages/
|
|
55
|
+
|
|
56
|
+
# Snowpack dependency directory (https://snowpack.dev/)
|
|
57
|
+
|
|
58
|
+
web_modules/
|
|
59
|
+
|
|
60
|
+
# TypeScript cache
|
|
61
|
+
|
|
62
|
+
\*.tsbuildinfo
|
|
63
|
+
|
|
64
|
+
# Optional npm cache directory
|
|
65
|
+
|
|
66
|
+
.npm
|
|
67
|
+
|
|
68
|
+
# Optional eslint cache
|
|
69
|
+
|
|
70
|
+
.eslintcache
|
|
71
|
+
|
|
72
|
+
# Optional stylelint cache
|
|
73
|
+
|
|
74
|
+
.stylelintcache
|
|
75
|
+
|
|
76
|
+
# Microbundle cache
|
|
77
|
+
|
|
78
|
+
.rpt2_cache/
|
|
79
|
+
.rts2_cache_cjs/
|
|
80
|
+
.rts2_cache_es/
|
|
81
|
+
.rts2_cache_umd/
|
|
82
|
+
|
|
83
|
+
# Optional REPL history
|
|
84
|
+
|
|
85
|
+
.node_repl_history
|
|
86
|
+
|
|
87
|
+
# Output of 'npm pack'
|
|
88
|
+
|
|
89
|
+
\*.tgz
|
|
90
|
+
|
|
91
|
+
# Yarn Integrity file
|
|
92
|
+
|
|
93
|
+
.yarn-integrity
|
|
94
|
+
|
|
95
|
+
# parcel-bundler cache (https://parceljs.org/)
|
|
96
|
+
|
|
97
|
+
.cache
|
|
98
|
+
.parcel-cache
|
|
99
|
+
|
|
100
|
+
# Next.js build output
|
|
101
|
+
|
|
102
|
+
.next
|
|
103
|
+
out
|
|
104
|
+
|
|
105
|
+
# Nuxt.js build / generate output
|
|
106
|
+
|
|
107
|
+
.nuxt
|
|
108
|
+
dist
|
|
109
|
+
|
|
110
|
+
# Gatsby files
|
|
111
|
+
|
|
112
|
+
.cache/
|
|
113
|
+
|
|
114
|
+
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
115
|
+
|
|
116
|
+
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
117
|
+
|
|
118
|
+
# public
|
|
119
|
+
|
|
120
|
+
# vuepress build output
|
|
121
|
+
|
|
122
|
+
.vuepress/dist
|
|
123
|
+
|
|
124
|
+
# vuepress v2.x temp and cache directory
|
|
125
|
+
|
|
126
|
+
.temp
|
|
127
|
+
.cache
|
|
128
|
+
|
|
129
|
+
# Docusaurus cache and generated files
|
|
130
|
+
|
|
131
|
+
.docusaurus
|
|
132
|
+
|
|
133
|
+
# Serverless directories
|
|
134
|
+
|
|
135
|
+
.serverless/
|
|
136
|
+
|
|
137
|
+
# FuseBox cache
|
|
138
|
+
|
|
139
|
+
.fusebox/
|
|
140
|
+
|
|
141
|
+
# DynamoDB Local files
|
|
142
|
+
|
|
143
|
+
.dynamodb/
|
|
144
|
+
|
|
145
|
+
# TernJS port file
|
|
146
|
+
|
|
147
|
+
.tern-port
|
|
148
|
+
|
|
149
|
+
# Stores VSCode versions used for testing VSCode extensions
|
|
150
|
+
|
|
151
|
+
.vscode-test
|
|
152
|
+
|
|
153
|
+
# yarn v2
|
|
154
|
+
|
|
155
|
+
.yarn/cache
|
|
156
|
+
.yarn/unplugged
|
|
157
|
+
.yarn/build-state.yml
|
|
158
|
+
.yarn/install-state.gz
|
|
159
|
+
.pnp.\*
|
|
160
|
+
|
|
161
|
+
# wrangler project
|
|
162
|
+
|
|
163
|
+
.dev.vars*
|
|
164
|
+
!.dev.vars.example
|
|
165
|
+
.env*
|
|
166
|
+
!.env.example
|
|
167
|
+
.wrangler/
|
|
168
|
+
|
|
169
|
+
# Python SDK / Standards
|
|
170
|
+
# Byte-compiled / optimized / DLL files
|
|
171
|
+
__pycache__/
|
|
172
|
+
*.py[cod]
|
|
173
|
+
*$py.class
|
|
174
|
+
|
|
175
|
+
# C extensions
|
|
176
|
+
*.so
|
|
177
|
+
|
|
178
|
+
# Distribution / packaging
|
|
179
|
+
build/
|
|
180
|
+
develop-eggs/
|
|
181
|
+
dist/
|
|
182
|
+
downloads/
|
|
183
|
+
eggs/
|
|
184
|
+
.eggs/
|
|
185
|
+
lib/
|
|
186
|
+
lib64/
|
|
187
|
+
parts/
|
|
188
|
+
sdist/
|
|
189
|
+
var/
|
|
190
|
+
wheels/
|
|
191
|
+
share/python-wheels/
|
|
192
|
+
*.egg-info/
|
|
193
|
+
.installed.cfg
|
|
194
|
+
*.egg
|
|
195
|
+
MANIFEST
|
|
196
|
+
|
|
197
|
+
# PyInstaller
|
|
198
|
+
# Usually these files are written by a python script from a template
|
|
199
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
200
|
+
*.manifest
|
|
201
|
+
*.spec
|
|
202
|
+
|
|
203
|
+
# Installer logs
|
|
204
|
+
pip-log.txt
|
|
205
|
+
pip-delete-this-directory.txt
|
|
206
|
+
|
|
207
|
+
# Unit test / coverage reports
|
|
208
|
+
htmlcov/
|
|
209
|
+
.tox/
|
|
210
|
+
.nox/
|
|
211
|
+
.coverage
|
|
212
|
+
.coverage.*
|
|
213
|
+
.cache
|
|
214
|
+
nosetests.xml
|
|
215
|
+
coverage.xml
|
|
216
|
+
*.cover
|
|
217
|
+
*.py,cover
|
|
218
|
+
.hypothesis/
|
|
219
|
+
.pytest_cache/
|
|
220
|
+
cover/
|
|
221
|
+
|
|
222
|
+
# Environments
|
|
223
|
+
.env
|
|
224
|
+
.venv
|
|
225
|
+
env/
|
|
226
|
+
venv/
|
|
227
|
+
ENV/
|
|
228
|
+
env.bak/
|
|
229
|
+
venv.bak/
|
|
230
|
+
test_env/
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sealedwebtoken
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for Odysii Sealed Web Tokens (SWT)
|
|
5
|
+
Project-URL: Homepage, https://swt.odysii.in
|
|
6
|
+
Project-URL: Repository, https://github.com/snskar125/swt
|
|
7
|
+
Author: snskar125
|
|
8
|
+
License: ISC
|
|
9
|
+
Keywords: authentication,jwt,sealed-web-tokens,swt,tokens
|
|
10
|
+
Classifier: License :: OSI Approved :: ISC License (ISCL)
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Provides-Extra: async
|
|
17
|
+
Requires-Dist: aiohttp>=3.9; extra == 'async'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# sealedwebtoken
|
|
21
|
+
|
|
22
|
+
The official Python SDK for Odysii Sealed Web Tokens (SWT).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Sync client only (zero dependencies)
|
|
28
|
+
pip install sealedwebtoken
|
|
29
|
+
|
|
30
|
+
# With async support (installs aiohttp)
|
|
31
|
+
pip install sealedwebtoken[async]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Sync
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import time
|
|
40
|
+
import swt
|
|
41
|
+
|
|
42
|
+
secret = "your-super-secret-key"
|
|
43
|
+
payload = {"user_id": 123, "role": "admin"}
|
|
44
|
+
|
|
45
|
+
# 1. Sign a token (expiresAt is required, max 31 days)
|
|
46
|
+
expires_at = int(time.time()) + 3600 # 1 hour from now
|
|
47
|
+
resp = swt.sign(payload, secret=secret, expires_at=expires_at)
|
|
48
|
+
print("Token:", resp.token)
|
|
49
|
+
print("Expires at:", resp.expires_at)
|
|
50
|
+
|
|
51
|
+
# 2. Verify a token
|
|
52
|
+
v = swt.verify(resp.token, secret=secret)
|
|
53
|
+
if v.valid:
|
|
54
|
+
print("Payload:", v.payload)
|
|
55
|
+
|
|
56
|
+
# 3. Revoke a token
|
|
57
|
+
swt.revoke(resp.token, secret=secret)
|
|
58
|
+
print("Token revoked")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Async
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio, time
|
|
65
|
+
import swt
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
client = swt.AsyncSWTClient()
|
|
69
|
+
expires_at = int(time.time()) + 3600
|
|
70
|
+
|
|
71
|
+
resp = await client.sign({"user_id": 42}, secret="mysecret123", expires_at=expires_at)
|
|
72
|
+
print("Token:", resp.token)
|
|
73
|
+
|
|
74
|
+
v = await client.verify(resp.token, secret="mysecret123")
|
|
75
|
+
if v.valid:
|
|
76
|
+
print("Payload:", v.payload)
|
|
77
|
+
|
|
78
|
+
await client.revoke(resp.token, secret="mysecret123")
|
|
79
|
+
print("Token revoked")
|
|
80
|
+
|
|
81
|
+
asyncio.run(main())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Using the class-based client
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import swt
|
|
88
|
+
|
|
89
|
+
# Create a reusable client
|
|
90
|
+
client = swt.SWTClient()
|
|
91
|
+
resp = client.sign({"user_id": 1}, secret="mysecret123", expires_at=...)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## API Reference
|
|
95
|
+
|
|
96
|
+
### `swt.sign(payload, *, secret, expires_at) → SignResponse`
|
|
97
|
+
|
|
98
|
+
- `payload` — `dict` to embed in the token (max 512 bytes serialised)
|
|
99
|
+
- `secret` — string, min 8 characters
|
|
100
|
+
- `expires_at` — Unix timestamp in seconds, **required**, max 31 days from now
|
|
101
|
+
|
|
102
|
+
### `swt.verify(token, *, secret=None) → VerifyResponse`
|
|
103
|
+
|
|
104
|
+
- `token` — the SWT token string
|
|
105
|
+
- `secret` — optional; if provided, validates the secret matches
|
|
106
|
+
|
|
107
|
+
### `swt.revoke(token, *, secret) → RevokeResponse`
|
|
108
|
+
|
|
109
|
+
- `token` — the SWT token string to revoke
|
|
110
|
+
- `secret` — the secret used when signing
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# sealedwebtoken
|
|
2
|
+
|
|
3
|
+
The official Python SDK for Odysii Sealed Web Tokens (SWT).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Sync client only (zero dependencies)
|
|
9
|
+
pip install sealedwebtoken
|
|
10
|
+
|
|
11
|
+
# With async support (installs aiohttp)
|
|
12
|
+
pip install sealedwebtoken[async]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Sync
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import time
|
|
21
|
+
import swt
|
|
22
|
+
|
|
23
|
+
secret = "your-super-secret-key"
|
|
24
|
+
payload = {"user_id": 123, "role": "admin"}
|
|
25
|
+
|
|
26
|
+
# 1. Sign a token (expiresAt is required, max 31 days)
|
|
27
|
+
expires_at = int(time.time()) + 3600 # 1 hour from now
|
|
28
|
+
resp = swt.sign(payload, secret=secret, expires_at=expires_at)
|
|
29
|
+
print("Token:", resp.token)
|
|
30
|
+
print("Expires at:", resp.expires_at)
|
|
31
|
+
|
|
32
|
+
# 2. Verify a token
|
|
33
|
+
v = swt.verify(resp.token, secret=secret)
|
|
34
|
+
if v.valid:
|
|
35
|
+
print("Payload:", v.payload)
|
|
36
|
+
|
|
37
|
+
# 3. Revoke a token
|
|
38
|
+
swt.revoke(resp.token, secret=secret)
|
|
39
|
+
print("Token revoked")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Async
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio, time
|
|
46
|
+
import swt
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
client = swt.AsyncSWTClient()
|
|
50
|
+
expires_at = int(time.time()) + 3600
|
|
51
|
+
|
|
52
|
+
resp = await client.sign({"user_id": 42}, secret="mysecret123", expires_at=expires_at)
|
|
53
|
+
print("Token:", resp.token)
|
|
54
|
+
|
|
55
|
+
v = await client.verify(resp.token, secret="mysecret123")
|
|
56
|
+
if v.valid:
|
|
57
|
+
print("Payload:", v.payload)
|
|
58
|
+
|
|
59
|
+
await client.revoke(resp.token, secret="mysecret123")
|
|
60
|
+
print("Token revoked")
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Using the class-based client
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import swt
|
|
69
|
+
|
|
70
|
+
# Create a reusable client
|
|
71
|
+
client = swt.SWTClient()
|
|
72
|
+
resp = client.sign({"user_id": 1}, secret="mysecret123", expires_at=...)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## API Reference
|
|
76
|
+
|
|
77
|
+
### `swt.sign(payload, *, secret, expires_at) → SignResponse`
|
|
78
|
+
|
|
79
|
+
- `payload` — `dict` to embed in the token (max 512 bytes serialised)
|
|
80
|
+
- `secret` — string, min 8 characters
|
|
81
|
+
- `expires_at` — Unix timestamp in seconds, **required**, max 31 days from now
|
|
82
|
+
|
|
83
|
+
### `swt.verify(token, *, secret=None) → VerifyResponse`
|
|
84
|
+
|
|
85
|
+
- `token` — the SWT token string
|
|
86
|
+
- `secret` — optional; if provided, validates the secret matches
|
|
87
|
+
|
|
88
|
+
### `swt.revoke(token, *, secret) → RevokeResponse`
|
|
89
|
+
|
|
90
|
+
- `token` — the SWT token string to revoke
|
|
91
|
+
- `secret` — the secret used when signing
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sealedwebtoken"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for Odysii Sealed Web Tokens (SWT)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "ISC" }
|
|
11
|
+
authors = [{ name = "snskar125" }]
|
|
12
|
+
keywords = ["swt", "sealed-web-tokens", "jwt", "authentication", "tokens"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: ISC License (ISCL)",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.9"
|
|
21
|
+
|
|
22
|
+
# Zero required dependencies — sync client uses stdlib urllib only
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
# Install aiohttp to use AsyncSWTClient
|
|
27
|
+
async = ["aiohttp>=3.9"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://swt.odysii.in"
|
|
31
|
+
Repository = "https://github.com/snskar125/swt"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["swt"]
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
swt — Official Python SDK for Odysii Sealed Web Tokens (SWT).
|
|
3
|
+
|
|
4
|
+
Provides both a synchronous client (SWTClient) and an async client
|
|
5
|
+
(AsyncSWTClient) that mirror the Node.js SDK's sign/verify/revoke API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
# RESPONSE TYPES
|
|
16
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SignResponse:
|
|
20
|
+
"""Returned by sign() on success."""
|
|
21
|
+
|
|
22
|
+
token: str
|
|
23
|
+
"""The issued SWT token string."""
|
|
24
|
+
|
|
25
|
+
expires_at: int
|
|
26
|
+
"""Unix timestamp (seconds) when the token expires."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class VerifyResponse:
|
|
31
|
+
"""Returned by verify() on success."""
|
|
32
|
+
|
|
33
|
+
valid: bool
|
|
34
|
+
"""True if the token is valid, not expired, and not revoked."""
|
|
35
|
+
|
|
36
|
+
payload: dict[str, Any] | None = None
|
|
37
|
+
"""The caller-supplied payload embedded in the token. None if invalid."""
|
|
38
|
+
|
|
39
|
+
expires_at: int | None = None
|
|
40
|
+
"""Unix timestamp when the token expires. None if invalid."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RevokeResponse:
|
|
45
|
+
"""Returned by revoke() on success."""
|
|
46
|
+
|
|
47
|
+
success: bool
|
|
48
|
+
"""True if the token was revoked (or was already revoked)."""
|
|
49
|
+
|
|
50
|
+
message: str | None = None
|
|
51
|
+
"""Optional human-readable status message."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
# EXCEPTIONS
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
class SWTError(Exception):
|
|
59
|
+
"""Raised when the SWT API returns an error response."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.status_code = status_code
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
# CONSTANTS
|
|
68
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
_API_URL = "https://api.swt.odysii.in"
|
|
71
|
+
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
# SYNC CLIENT
|
|
74
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
class SWTClient:
|
|
77
|
+
"""
|
|
78
|
+
Synchronous SWT client using urllib (zero dependencies).
|
|
79
|
+
|
|
80
|
+
Example::
|
|
81
|
+
|
|
82
|
+
import swt
|
|
83
|
+
|
|
84
|
+
client = swt.SWTClient()
|
|
85
|
+
|
|
86
|
+
# Sign
|
|
87
|
+
import time
|
|
88
|
+
expires_at = int(time.time()) + 3600 # 1 hour from now
|
|
89
|
+
resp = client.sign({"user_id": 42}, secret="mysecret123", expires_at=expires_at)
|
|
90
|
+
print(resp.token)
|
|
91
|
+
|
|
92
|
+
# Verify
|
|
93
|
+
v = client.verify(resp.token, secret="mysecret123")
|
|
94
|
+
if v.valid:
|
|
95
|
+
print(v.payload)
|
|
96
|
+
|
|
97
|
+
# Revoke
|
|
98
|
+
client.revoke(resp.token, secret="mysecret123")
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, base_url: str = _API_URL) -> None:
|
|
102
|
+
self._base_url = base_url.rstrip("/")
|
|
103
|
+
|
|
104
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
"""Make a synchronous POST request using urllib (no third-party deps)."""
|
|
106
|
+
import urllib.request
|
|
107
|
+
import urllib.error
|
|
108
|
+
|
|
109
|
+
data = json.dumps(body).encode("utf-8")
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
f"{self._base_url}{path}",
|
|
112
|
+
data=data,
|
|
113
|
+
headers={
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
"User-Agent": "odysii-swt-python/1.0.0",
|
|
116
|
+
},
|
|
117
|
+
method="POST",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with urllib.request.urlopen(req) as resp:
|
|
122
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
123
|
+
except urllib.error.HTTPError as exc:
|
|
124
|
+
# Read the error body for the API error message
|
|
125
|
+
try:
|
|
126
|
+
error_body = json.loads(exc.read().decode("utf-8"))
|
|
127
|
+
msg = error_body.get("error", f"HTTP {exc.code}")
|
|
128
|
+
except Exception:
|
|
129
|
+
msg = f"HTTP {exc.code}"
|
|
130
|
+
raise SWTError(msg, status_code=exc.code) from exc
|
|
131
|
+
|
|
132
|
+
def sign(
|
|
133
|
+
self,
|
|
134
|
+
payload: dict[str, Any],
|
|
135
|
+
*,
|
|
136
|
+
secret: str,
|
|
137
|
+
expires_at: int,
|
|
138
|
+
) -> SignResponse:
|
|
139
|
+
"""
|
|
140
|
+
Issue a new SWT token.
|
|
141
|
+
|
|
142
|
+
:param payload: Arbitrary dict to embed in the token (max 512 bytes serialised).
|
|
143
|
+
:param secret: Secret used for signing (min 8 characters).
|
|
144
|
+
:param expires_at: Required Unix timestamp (seconds) for expiry. Max 31 days from now.
|
|
145
|
+
:raises SWTError: If the API returns an error.
|
|
146
|
+
"""
|
|
147
|
+
data = self._post("/v1/sign", {
|
|
148
|
+
"secret": secret,
|
|
149
|
+
"payload": payload,
|
|
150
|
+
"expiresAt": expires_at,
|
|
151
|
+
})
|
|
152
|
+
return SignResponse(token=data["token"], expires_at=data["expiresAt"])
|
|
153
|
+
|
|
154
|
+
def verify(
|
|
155
|
+
self,
|
|
156
|
+
token: str,
|
|
157
|
+
*,
|
|
158
|
+
secret: str | None = None,
|
|
159
|
+
) -> VerifyResponse:
|
|
160
|
+
"""
|
|
161
|
+
Verify a SWT token.
|
|
162
|
+
|
|
163
|
+
:param token: The token string to verify.
|
|
164
|
+
:param secret: Optional — if provided, also validates the secret matches.
|
|
165
|
+
:raises SWTError: If the API returns a non-auth error.
|
|
166
|
+
"""
|
|
167
|
+
body: dict[str, Any] = {"token": token}
|
|
168
|
+
if secret is not None:
|
|
169
|
+
body["secret"] = secret
|
|
170
|
+
|
|
171
|
+
data = self._post("/v1/verify", body)
|
|
172
|
+
return VerifyResponse(
|
|
173
|
+
valid=data.get("valid", False),
|
|
174
|
+
payload=data.get("payload"),
|
|
175
|
+
expires_at=data.get("expiresAt"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def revoke(self, token: str, *, secret: str) -> RevokeResponse:
|
|
179
|
+
"""
|
|
180
|
+
Revoke a SWT token.
|
|
181
|
+
|
|
182
|
+
:param token: The token string to revoke.
|
|
183
|
+
:param secret: The secret that was used to sign the token.
|
|
184
|
+
:raises SWTError: If the API returns an error.
|
|
185
|
+
"""
|
|
186
|
+
data = self._post("/v1/revoke", {"token": token, "secret": secret})
|
|
187
|
+
return RevokeResponse(success=data.get("success", False), message=data.get("message"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
# ASYNC CLIENT
|
|
192
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
class AsyncSWTClient:
|
|
195
|
+
"""
|
|
196
|
+
Asynchronous SWT client using aiohttp.
|
|
197
|
+
|
|
198
|
+
Requires ``aiohttp`` to be installed::
|
|
199
|
+
|
|
200
|
+
pip install swt[async]
|
|
201
|
+
|
|
202
|
+
Example::
|
|
203
|
+
|
|
204
|
+
import asyncio, time
|
|
205
|
+
import swt
|
|
206
|
+
|
|
207
|
+
async def main():
|
|
208
|
+
client = swt.AsyncSWTClient()
|
|
209
|
+
expires_at = int(time.time()) + 3600
|
|
210
|
+
|
|
211
|
+
resp = await client.sign({"user_id": 42}, secret="mysecret123", expires_at=expires_at)
|
|
212
|
+
print(resp.token)
|
|
213
|
+
|
|
214
|
+
v = await client.verify(resp.token, secret="mysecret123")
|
|
215
|
+
if v.valid:
|
|
216
|
+
print(v.payload)
|
|
217
|
+
|
|
218
|
+
await client.revoke(resp.token, secret="mysecret123")
|
|
219
|
+
|
|
220
|
+
asyncio.run(main())
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self, base_url: str = _API_URL) -> None:
|
|
224
|
+
self._base_url = base_url.rstrip("/")
|
|
225
|
+
|
|
226
|
+
async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
"""Make an async POST request using aiohttp."""
|
|
228
|
+
try:
|
|
229
|
+
import aiohttp
|
|
230
|
+
except ImportError as exc:
|
|
231
|
+
raise ImportError(
|
|
232
|
+
"AsyncSWTClient requires aiohttp. Install it with: pip install swt[async]"
|
|
233
|
+
) from exc
|
|
234
|
+
|
|
235
|
+
async with aiohttp.ClientSession() as session:
|
|
236
|
+
async with session.post(
|
|
237
|
+
f"{self._base_url}{path}",
|
|
238
|
+
json=body,
|
|
239
|
+
headers={
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
"User-Agent": "odysii-swt-python/1.0.0",
|
|
242
|
+
},
|
|
243
|
+
) as resp:
|
|
244
|
+
data = await resp.json()
|
|
245
|
+
if not resp.ok:
|
|
246
|
+
msg = data.get("error", f"HTTP {resp.status}") if isinstance(data, dict) else f"HTTP {resp.status}"
|
|
247
|
+
raise SWTError(msg, status_code=resp.status)
|
|
248
|
+
return data
|
|
249
|
+
|
|
250
|
+
async def sign(
|
|
251
|
+
self,
|
|
252
|
+
payload: dict[str, Any],
|
|
253
|
+
*,
|
|
254
|
+
secret: str,
|
|
255
|
+
expires_at: int,
|
|
256
|
+
) -> SignResponse:
|
|
257
|
+
"""
|
|
258
|
+
Issue a new SWT token (async).
|
|
259
|
+
|
|
260
|
+
:param payload: Arbitrary dict to embed in the token (max 512 bytes serialised).
|
|
261
|
+
:param secret: Secret used for signing (min 8 characters).
|
|
262
|
+
:param expires_at: Required Unix timestamp (seconds) for expiry. Max 31 days from now.
|
|
263
|
+
:raises SWTError: If the API returns an error.
|
|
264
|
+
"""
|
|
265
|
+
data = await self._post("/v1/sign", {
|
|
266
|
+
"secret": secret,
|
|
267
|
+
"payload": payload,
|
|
268
|
+
"expiresAt": expires_at,
|
|
269
|
+
})
|
|
270
|
+
return SignResponse(token=data["token"], expires_at=data["expiresAt"])
|
|
271
|
+
|
|
272
|
+
async def verify(
|
|
273
|
+
self,
|
|
274
|
+
token: str,
|
|
275
|
+
*,
|
|
276
|
+
secret: str | None = None,
|
|
277
|
+
) -> VerifyResponse:
|
|
278
|
+
"""
|
|
279
|
+
Verify a SWT token (async).
|
|
280
|
+
|
|
281
|
+
:param token: The token string to verify.
|
|
282
|
+
:param secret: Optional — if provided, also validates the secret matches.
|
|
283
|
+
:raises SWTError: If the API returns a non-auth error.
|
|
284
|
+
"""
|
|
285
|
+
body: dict[str, Any] = {"token": token}
|
|
286
|
+
if secret is not None:
|
|
287
|
+
body["secret"] = secret
|
|
288
|
+
|
|
289
|
+
data = await self._post("/v1/verify", body)
|
|
290
|
+
return VerifyResponse(
|
|
291
|
+
valid=data.get("valid", False),
|
|
292
|
+
payload=data.get("payload"),
|
|
293
|
+
expires_at=data.get("expiresAt"),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def revoke(self, token: str, *, secret: str) -> RevokeResponse:
|
|
297
|
+
"""
|
|
298
|
+
Revoke a SWT token (async).
|
|
299
|
+
|
|
300
|
+
:param token: The token string to revoke.
|
|
301
|
+
:param secret: The secret that was used to sign the token.
|
|
302
|
+
:raises SWTError: If the API returns an error.
|
|
303
|
+
"""
|
|
304
|
+
data = await self._post("/v1/revoke", {"token": token, "secret": secret})
|
|
305
|
+
return RevokeResponse(success=data.get("success", False), message=data.get("message"))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
# MODULE-LEVEL CONVENIENCE (mirrors Node.js SDK's functional API)
|
|
310
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
_default_client = SWTClient()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def sign(
|
|
316
|
+
payload: dict[str, Any],
|
|
317
|
+
*,
|
|
318
|
+
secret: str,
|
|
319
|
+
expires_at: int,
|
|
320
|
+
) -> SignResponse:
|
|
321
|
+
"""Module-level sign() — uses a shared default SWTClient."""
|
|
322
|
+
return _default_client.sign(payload, secret=secret, expires_at=expires_at)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def verify(token: str, *, secret: str | None = None) -> VerifyResponse:
|
|
326
|
+
"""Module-level verify() — uses a shared default SWTClient."""
|
|
327
|
+
return _default_client.verify(token, secret=secret)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def revoke(token: str, *, secret: str) -> RevokeResponse:
|
|
331
|
+
"""Module-level revoke() — uses a shared default SWTClient."""
|
|
332
|
+
return _default_client.revoke(token, secret=secret)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = [
|
|
336
|
+
"SWTClient",
|
|
337
|
+
"AsyncSWTClient",
|
|
338
|
+
"SignResponse",
|
|
339
|
+
"VerifyResponse",
|
|
340
|
+
"RevokeResponse",
|
|
341
|
+
"SWTError",
|
|
342
|
+
"sign",
|
|
343
|
+
"verify",
|
|
344
|
+
"revoke",
|
|
345
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test suite for the Python SWT SDK.
|
|
3
|
+
Runs against the live API — requires network access.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
# (Removed sys.path so we test the actual installed pip package)
|
|
10
|
+
|
|
11
|
+
import swt
|
|
12
|
+
|
|
13
|
+
# ─── Config ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
SECRET = "test-secret-swt-2026"
|
|
16
|
+
PAYLOAD = {"userId": "py-test-001", "role": "tester"}
|
|
17
|
+
EXPIRES_AT = int(time.time()) + 3600 # 1 hour from now
|
|
18
|
+
|
|
19
|
+
PASS = "\033[92m✓\033[0m"
|
|
20
|
+
FAIL = "\033[91m✗\033[0m"
|
|
21
|
+
|
|
22
|
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
|
23
|
+
status = PASS if condition else FAIL
|
|
24
|
+
print(f" {status} {label}" + (f" — {detail}" if detail else ""))
|
|
25
|
+
if not condition:
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
# ─── Tests ────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
print("\n🔐 SWT Python SDK — Live API Test\n")
|
|
31
|
+
|
|
32
|
+
# 1. SIGN
|
|
33
|
+
print("1. sign()")
|
|
34
|
+
try:
|
|
35
|
+
resp = swt.sign(PAYLOAD, secret=SECRET, expires_at=EXPIRES_AT)
|
|
36
|
+
check("returns a token string", isinstance(resp.token, str) and resp.token.startswith("swt_"))
|
|
37
|
+
check("token has correct format (swt_<jti>.<blob>)", resp.token.count(".") == 1)
|
|
38
|
+
check("returns expiresAt as int", isinstance(resp.expires_at, int))
|
|
39
|
+
check("expiresAt matches what we sent", resp.expires_at == EXPIRES_AT)
|
|
40
|
+
TOKEN = resp.token
|
|
41
|
+
print(f" token: {TOKEN[:60]}...")
|
|
42
|
+
except swt.SWTError as e:
|
|
43
|
+
check("sign() succeeded", False, str(e))
|
|
44
|
+
|
|
45
|
+
print()
|
|
46
|
+
|
|
47
|
+
# 2. VERIFY — valid token
|
|
48
|
+
print("2. verify() — valid token")
|
|
49
|
+
try:
|
|
50
|
+
v = swt.verify(TOKEN, secret=SECRET)
|
|
51
|
+
check("valid is True", v.valid is True)
|
|
52
|
+
check("payload matches", v.payload == PAYLOAD)
|
|
53
|
+
check("expiresAt is returned", v.expires_at == EXPIRES_AT)
|
|
54
|
+
except swt.SWTError as e:
|
|
55
|
+
check("verify() succeeded", False, str(e))
|
|
56
|
+
|
|
57
|
+
print()
|
|
58
|
+
|
|
59
|
+
# 3. VERIFY — wrong secret
|
|
60
|
+
print("3. verify() — wrong secret")
|
|
61
|
+
try:
|
|
62
|
+
v = swt.verify(TOKEN, secret="wrong-secret-123")
|
|
63
|
+
check("valid is False for wrong secret", v.valid is False)
|
|
64
|
+
except swt.SWTError:
|
|
65
|
+
# API may return 401 which throws SWTError — also acceptable
|
|
66
|
+
check("rejected with wrong secret", True, "raised SWTError")
|
|
67
|
+
|
|
68
|
+
print()
|
|
69
|
+
|
|
70
|
+
# 4. VERIFY — no secret (token-only check)
|
|
71
|
+
print("4. verify() — no secret (token only)")
|
|
72
|
+
try:
|
|
73
|
+
v = swt.verify(TOKEN)
|
|
74
|
+
check("valid is True without secret", v.valid is True)
|
|
75
|
+
check("payload is present", v.payload is not None)
|
|
76
|
+
except swt.SWTError as e:
|
|
77
|
+
check("verify() without secret succeeded", False, str(e))
|
|
78
|
+
|
|
79
|
+
print()
|
|
80
|
+
|
|
81
|
+
# 5. VERIFY — malformed token
|
|
82
|
+
print("5. verify() — malformed token")
|
|
83
|
+
try:
|
|
84
|
+
v = swt.verify("not-a-real-token", secret=SECRET)
|
|
85
|
+
check("valid is False for malformed token", v.valid is False)
|
|
86
|
+
except swt.SWTError:
|
|
87
|
+
check("rejected malformed token", True, "raised SWTError")
|
|
88
|
+
|
|
89
|
+
print()
|
|
90
|
+
|
|
91
|
+
# 6. REVOKE
|
|
92
|
+
print("6. revoke()")
|
|
93
|
+
try:
|
|
94
|
+
r = swt.revoke(TOKEN, secret=SECRET)
|
|
95
|
+
check("success is True", r.success is True)
|
|
96
|
+
except swt.SWTError as e:
|
|
97
|
+
check("revoke() succeeded", False, str(e))
|
|
98
|
+
|
|
99
|
+
print()
|
|
100
|
+
|
|
101
|
+
# 7. VERIFY — after revocation
|
|
102
|
+
print("7. verify() — after revocation")
|
|
103
|
+
try:
|
|
104
|
+
v = swt.verify(TOKEN, secret=SECRET)
|
|
105
|
+
check("valid is False after revoke", v.valid is False)
|
|
106
|
+
except swt.SWTError:
|
|
107
|
+
check("rejected revoked token", True, "raised SWTError")
|
|
108
|
+
|
|
109
|
+
print()
|
|
110
|
+
|
|
111
|
+
# 8. REVOKE — wrong secret
|
|
112
|
+
print("8. revoke() — wrong secret")
|
|
113
|
+
resp2 = swt.sign({"test": "revoke-auth"}, secret=SECRET, expires_at=int(time.time()) + 3600)
|
|
114
|
+
try:
|
|
115
|
+
swt.revoke(resp2.token, secret="wrong-secret-123")
|
|
116
|
+
check("should have rejected", False, "expected SWTError but didn't get one")
|
|
117
|
+
except swt.SWTError as e:
|
|
118
|
+
check("rejected revoke with wrong secret", True, f"SWTError: {e}")
|
|
119
|
+
|
|
120
|
+
print()
|
|
121
|
+
|
|
122
|
+
# 9. SIGN — expired expiresAt
|
|
123
|
+
print("9. sign() — expiresAt in the past")
|
|
124
|
+
try:
|
|
125
|
+
swt.sign(PAYLOAD, secret=SECRET, expires_at=int(time.time()) - 100)
|
|
126
|
+
check("should have rejected past expiresAt", False, "expected SWTError")
|
|
127
|
+
except swt.SWTError as e:
|
|
128
|
+
check("rejected past expiresAt", True, f"SWTError: {e}")
|
|
129
|
+
|
|
130
|
+
print()
|
|
131
|
+
|
|
132
|
+
# 10. SIGN — expiresAt beyond 31 days
|
|
133
|
+
print("10. sign() — expiresAt > 31 days")
|
|
134
|
+
try:
|
|
135
|
+
far_future = int(time.time()) + (32 * 24 * 60 * 60)
|
|
136
|
+
swt.sign(PAYLOAD, secret=SECRET, expires_at=far_future)
|
|
137
|
+
check("should have rejected >31 day TTL", False, "expected SWTError")
|
|
138
|
+
except swt.SWTError as e:
|
|
139
|
+
check("rejected >31 day TTL", True, f"SWTError: {e}")
|
|
140
|
+
|
|
141
|
+
print()
|
|
142
|
+
print("✅ All tests passed\n")
|