n0passtemps 1.1.3__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.
- n0passtemps-1.1.3/LICENSE +21 -0
- n0passtemps-1.1.3/PKG-INFO +304 -0
- n0passtemps-1.1.3/README.md +275 -0
- n0passtemps-1.1.3/pyproject.toml +51 -0
- n0passtemps-1.1.3/setup.cfg +4 -0
- n0passtemps-1.1.3/src/n0passtemps/__init__.py +71 -0
- n0passtemps-1.1.3/src/n0passtemps/assertion.py +704 -0
- n0passtemps-1.1.3/src/n0passtemps/client.py +778 -0
- n0passtemps-1.1.3/src/n0passtemps/errors.py +231 -0
- n0passtemps-1.1.3/src/n0passtemps/py.typed +0 -0
- n0passtemps-1.1.3/src/n0passtemps.egg-info/PKG-INFO +304 -0
- n0passtemps-1.1.3/src/n0passtemps.egg-info/SOURCES.txt +15 -0
- n0passtemps-1.1.3/src/n0passtemps.egg-info/dependency_links.txt +1 -0
- n0passtemps-1.1.3/src/n0passtemps.egg-info/requires.txt +3 -0
- n0passtemps-1.1.3/src/n0passtemps.egg-info/top_level.txt +1 -0
- n0passtemps-1.1.3/tests/test_assertion.py +855 -0
- n0passtemps-1.1.3/tests/test_client.py +656 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016-2026 Socold
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: n0passtemps
|
|
3
|
+
Version: 1.1.3
|
|
4
|
+
Summary: Client SDK for the n0passtemps authentication server: WebAuthn, TOTP, recovery codes and offline verification of the signed assertion.
|
|
5
|
+
Author: Socold
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Socold/n0passtemps
|
|
8
|
+
Project-URL: Source, https://github.com/Socold/n0passtemps/tree/main/sdk/python
|
|
9
|
+
Keywords: authentication,webauthn,passkeys,totp,jws,ed25519
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Provides-Extra: verify
|
|
27
|
+
Requires-Dist: cryptography>=42; extra == "verify"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# n0passtemps for Python
|
|
31
|
+
|
|
32
|
+
Client SDK for the [n0passtemps](https://github.com/Socold/n0passtemps)
|
|
33
|
+
authentication server: WebAuthn ceremonies, TOTP, recovery codes, and offline
|
|
34
|
+
verification of the signed assertion that a successful authentication returns.
|
|
35
|
+
|
|
36
|
+
- Python 3.9 and later, fully typed (`py.typed`).
|
|
37
|
+
- The client has no runtime dependency; it is built on `urllib`.
|
|
38
|
+
- Verifying assertions needs an Ed25519 primitive, which the standard library
|
|
39
|
+
lacks. It comes from the optional `cryptography` dependency. The SDK ships no
|
|
40
|
+
signature code of its own.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install n0passtemps # client only
|
|
46
|
+
pip install "n0passtemps[verify]" # client and assertion verification
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## End to end
|
|
50
|
+
|
|
51
|
+
The browser half of a WebAuthn ceremony (`navigator.credentials.create` and
|
|
52
|
+
`.get`) is yours. The SDK covers the server half: it hands you the `options`
|
|
53
|
+
for the browser and takes the `credential` the browser produced, both as plain
|
|
54
|
+
dicts, untouched.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import os
|
|
58
|
+
|
|
59
|
+
from n0passtemps import Client, AuthenticationFailed, Throttled
|
|
60
|
+
from n0passtemps import Verifier, RemoteJWKS
|
|
61
|
+
|
|
62
|
+
BASE_URL = "https://auth.example.org"
|
|
63
|
+
API_KEY_ID = os.environ["N0PASSTEMPS_API_KEY_ID"] # the id of the key, from the admin API
|
|
64
|
+
|
|
65
|
+
client = Client(BASE_URL, api_key=os.environ["N0PASSTEMPS_API_KEY"])
|
|
66
|
+
|
|
67
|
+
# 1. Make sure the subject exists. Idempotent: call it on every login.
|
|
68
|
+
subject = client.resolve_subject("user-8f3a1c", display_name="Alice")
|
|
69
|
+
|
|
70
|
+
# 2. Registration, when the subject has no authenticator yet.
|
|
71
|
+
if subject.credential_count == 0:
|
|
72
|
+
begun = client.begin_registration("user-8f3a1c", label="Laptop")
|
|
73
|
+
# send begun["options"] to the browser, get its PublicKeyCredential back
|
|
74
|
+
client.complete_registration("user-8f3a1c", begun["challenge_id"], credential)
|
|
75
|
+
batch = client.issue_recovery_codes("user-8f3a1c")
|
|
76
|
+
show_once(batch.codes) # they cannot be displayed again
|
|
77
|
+
|
|
78
|
+
# 3. Authentication.
|
|
79
|
+
begun = client.begin_assertion("user-8f3a1c")
|
|
80
|
+
try:
|
|
81
|
+
result = client.complete_assertion("user-8f3a1c", begun["challenge_id"], credential)
|
|
82
|
+
except AuthenticationFailed:
|
|
83
|
+
... # not authenticated; the service never says why
|
|
84
|
+
except Throttled as error:
|
|
85
|
+
... # wait error.retry_after seconds
|
|
86
|
+
|
|
87
|
+
# 4. Verify the proof before opening a session (see the next section).
|
|
88
|
+
verifier = Verifier(
|
|
89
|
+
issuer="n0passtemps",
|
|
90
|
+
audience=API_KEY_ID,
|
|
91
|
+
keys=RemoteJWKS(BASE_URL + "/v1/.well-known/jwks.json"),
|
|
92
|
+
)
|
|
93
|
+
claims = verifier.verify(result.assertion)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Build the `Verifier` once, at start-up, and share it: that is what makes its
|
|
97
|
+
key cache useful.
|
|
98
|
+
|
|
99
|
+
TOTP and recovery codes follow the same shape:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
enrolment = client.enrol_totp("user-8f3a1c") # .secret, .provisioning_uri: shown once
|
|
103
|
+
client.confirm_totp("user-8f3a1c", "123456") # the secret is inert until this succeeds
|
|
104
|
+
result = client.verify_totp("user-8f3a1c", "654321")
|
|
105
|
+
result = client.consume_recovery_code("user-8f3a1c", "ABCD-EFGH-JKLM")
|
|
106
|
+
result.recovery_codes_remaining
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Method | Route | Returns |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `resolve_subject(ref, display_name=None)` | `POST /v1/subjects` | `Subject` |
|
|
112
|
+
| `get_subject(ref)` | `GET /v1/subjects/{ref}` | `Subject` |
|
|
113
|
+
| `begin_registration(ref, label=None)` | `POST /v1/webauthn/{ref}/register` | `dict` |
|
|
114
|
+
| `complete_registration(ref, challenge_id, credential)` | `POST /v1/webauthn/{ref}/register/complete` | `dict` |
|
|
115
|
+
| `begin_assertion(ref)` | `POST /v1/webauthn/{ref}/assert` | `dict` |
|
|
116
|
+
| `complete_assertion(ref, challenge_id, credential)` | `POST /v1/webauthn/{ref}/assert/complete` | `AssertionResult` |
|
|
117
|
+
| `enrol_totp(ref)` | `POST /v1/totp/{ref}/enrol` | `TOTPEnrolment` |
|
|
118
|
+
| `confirm_totp(ref, code)` | `POST /v1/totp/{ref}/enrol/confirm` | `dict` |
|
|
119
|
+
| `verify_totp(ref, code)` | `POST /v1/totp/{ref}/verify` | `AssertionResult` |
|
|
120
|
+
| `issue_recovery_codes(ref)` | `POST /v1/recovery/{ref}/issue` | `RecoveryBatch` |
|
|
121
|
+
| `consume_recovery_code(ref, code)` | `POST /v1/recovery/{ref}/consume` | `AssertionResult` |
|
|
122
|
+
| `health()` | `GET /v1/health` | `dict` |
|
|
123
|
+
| `health_detail()` | `GET /v1/health/detail` | `dict` |
|
|
124
|
+
|
|
125
|
+
The result classes are frozen dataclasses. Each keeps the decoded response in
|
|
126
|
+
`.raw`, so a member added by a newer server is reachable without an SDK
|
|
127
|
+
release. `AssertionResult` carries `.assertion`, `.expires_at`, `.factors`,
|
|
128
|
+
`.signals`, `.subject_id` and, after a recovery code,
|
|
129
|
+
`.recovery_codes_remaining`. `health()` returns the liveness report even when
|
|
130
|
+
the service answers 503 with one; read its `status`.
|
|
131
|
+
|
|
132
|
+
The subject reference is your own identifier for the user. It is
|
|
133
|
+
percent-encoded into a single path segment, so any character is safe, but
|
|
134
|
+
prefer an opaque identifier to an email address: it travels in the request
|
|
135
|
+
path, where a reverse proxy may log it.
|
|
136
|
+
|
|
137
|
+
### What the client does to protect the API key
|
|
138
|
+
|
|
139
|
+
The key is a bearer credential, so the client is strict about where it goes:
|
|
140
|
+
|
|
141
|
+
- a base URL that is not `https` is refused, unless the host is loopback or you
|
|
142
|
+
pass `allow_insecure_transport=True`;
|
|
143
|
+
- a redirect is followed only for a `GET` that stays on the same scheme, host
|
|
144
|
+
and port. `urllib` would otherwise copy the `Authorization` header to
|
|
145
|
+
whichever host a `Location` header names;
|
|
146
|
+
- `repr(client)` shows the base URL only, and no exception message contains
|
|
147
|
+
the key;
|
|
148
|
+
- TLS verification cannot be switched off. For an internal certificate
|
|
149
|
+
authority, pass `ca_file="/path/to/ca.pem"`.
|
|
150
|
+
|
|
151
|
+
Responses are capped at 1 MiB, the default timeout is 10 seconds, and nothing
|
|
152
|
+
is retried: several routes consume single-use material (a challenge, a TOTP
|
|
153
|
+
step, a recovery code), so whether a repeat is safe is your decision.
|
|
154
|
+
|
|
155
|
+
### Say whose browser it is
|
|
156
|
+
|
|
157
|
+
Every call reaches the service from your backend, so its per-address rate limit
|
|
158
|
+
and network risk signal have nothing to work from unless you declare the end
|
|
159
|
+
user's address. `client.for_end_user(ip)` returns a client that sends it as
|
|
160
|
+
`X-End-User-IP` on every call; create one per incoming request:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
auth = client.for_end_user(request.remote_addr)
|
|
164
|
+
result = auth.verify_totp(subject_ref, code)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Without it the service applies no per-address limit to the call; the limits per
|
|
168
|
+
subject and per key still hold.
|
|
169
|
+
|
|
170
|
+
## Verifying the assertion
|
|
171
|
+
|
|
172
|
+
`result.assertion` is a compact JWS signed with Ed25519. Verifying it means
|
|
173
|
+
your application does not have to trust the network path between itself and
|
|
174
|
+
the service: a bare `200 OK` can be faked by whoever sits on that path, a
|
|
175
|
+
signature cannot.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from n0passtemps import Verifier, RemoteJWKS, StaticKeys, InvalidAssertion
|
|
179
|
+
|
|
180
|
+
verifier = Verifier(
|
|
181
|
+
issuer="n0passtemps", # the iss your deployment is configured with
|
|
182
|
+
audience=API_KEY_ID, # the id of your API key, not the key itself
|
|
183
|
+
keys=RemoteJWKS("https://auth.example.org/v1/.well-known/jwks.json"),
|
|
184
|
+
clock_skew=30,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
claims = verifier.verify(result.assertion)
|
|
189
|
+
except InvalidAssertion:
|
|
190
|
+
... # refuse; there is deliberately no further detail
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`verify` performs, in this order:
|
|
194
|
+
|
|
195
|
+
1. exactly three segments, each strict unpadded base64url (padding,
|
|
196
|
+
characters outside the alphabet and non-canonical trailing bits are all
|
|
197
|
+
refused);
|
|
198
|
+
2. the header `alg` is exactly `EdDSA`. The algorithm is never chosen from the
|
|
199
|
+
token, so `none`, `HS256` keyed with the public key, and everything else
|
|
200
|
+
are refused before any key is looked up;
|
|
201
|
+
3. any `crit` header is refused, and `typ`, when present, must be `JWT`;
|
|
202
|
+
4. the key is selected by `kid`, and only that key is tried;
|
|
203
|
+
5. the signature is verified **before** any claim is read;
|
|
204
|
+
6. then `iss`, `aud`, `sub`, `amr`, `jti`, `exp` (required) and `nbf`, the last
|
|
205
|
+
two with `clock_skew`.
|
|
206
|
+
|
|
207
|
+
Every failure raises the same `InvalidAssertion`, with no indication of which
|
|
208
|
+
check failed. Telling a caller that a forged token had the right audience, or
|
|
209
|
+
that a `kid` exists, would help nobody but an attacker. The one separate
|
|
210
|
+
outcome is `TransportError`, when `RemoteJWKS` cannot fetch the keys: that is
|
|
211
|
+
an outage on your side, not a property of the token.
|
|
212
|
+
|
|
213
|
+
### Two checks that remain yours
|
|
214
|
+
|
|
215
|
+
- **Enforce single use of `jti`.** An assertion stays valid until `exp`, about
|
|
216
|
+
a minute. Within that window nothing in the verifier stops it being
|
|
217
|
+
presented twice. Record each accepted `claims.jti` until `claims.expires_at`
|
|
218
|
+
has passed, and refuse a repeat.
|
|
219
|
+
- **Check `amr` against your own policy.** `claims.amr` lists the factors that
|
|
220
|
+
authenticated the subject: `webauthn`, `webauthn-uv` (the authenticator also
|
|
221
|
+
verified the user with a PIN or a biometric), `totp`, `recovery-code`. The
|
|
222
|
+
verifier proves the list is authentic; whether `recovery-code` alone may open
|
|
223
|
+
a session, or whether a sensitive operation needs `webauthn-uv`, is for your
|
|
224
|
+
application to decide.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
if not replay_cache.add(claims.jti, until=claims.expires_at):
|
|
228
|
+
raise PermissionError("assertion already used")
|
|
229
|
+
if "webauthn-uv" not in claims.amr:
|
|
230
|
+
raise PermissionError("user verification required")
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Key sources
|
|
234
|
+
|
|
235
|
+
`RemoteJWKS(url, cache_seconds=300, min_refetch_interval=10)` fetches the
|
|
236
|
+
published key set and caches it. A token naming an unknown `kid` triggers one
|
|
237
|
+
refetch, which is how a rotated key is picked up straight away. Because that
|
|
238
|
+
path is driven by unverified input, all fetches are limited to one per
|
|
239
|
+
`min_refetch_interval`: a flood of tokens with random `kid` values cannot turn
|
|
240
|
+
your verifier into a request amplifier aimed at the service. Once the cache has
|
|
241
|
+
lapsed, a failed fetch raises `TransportError` instead of trusting keys of
|
|
242
|
+
unknown age. The URL must be `https` unless the host is loopback, since whoever
|
|
243
|
+
can rewrite that response chooses your verification keys.
|
|
244
|
+
|
|
245
|
+
`StaticKeys(jwks)` takes a JWK Set document you hold in configuration, and
|
|
246
|
+
`StaticKeys.from_public_keys([raw32bytes])` takes raw keys. No network is
|
|
247
|
+
involved, at the price of a configuration change when the service rotates its
|
|
248
|
+
key.
|
|
249
|
+
|
|
250
|
+
`jwk_thumbprint(jwk)` returns the RFC 7638 thumbprint of an Ed25519 JWK, the
|
|
251
|
+
value the service uses as `kid`. A published key whose `kid` does not match its
|
|
252
|
+
own thumbprint is ignored.
|
|
253
|
+
|
|
254
|
+
Without the `cryptography` package, constructing a `Verifier` raises
|
|
255
|
+
`VerificationUnavailable` with the install command, at start-up and not at the
|
|
256
|
+
first login.
|
|
257
|
+
|
|
258
|
+
## Errors
|
|
259
|
+
|
|
260
|
+
Everything derives from `N0PasstempsError`. An `APIError` carries the RFC 9457
|
|
261
|
+
problem document: `status`, `type`, `title`, `detail`, `request_id`,
|
|
262
|
+
`retry_after`, and `raw`. The subclass is chosen from the problem `type`, not
|
|
263
|
+
from the status, because 401 means two different things.
|
|
264
|
+
|
|
265
|
+
| Exception | Problem `type` (after `urn:n0passtemps:error:`) | Status | Meaning |
|
|
266
|
+
|---|---|---|---|
|
|
267
|
+
| `Unauthorized` | `unauthorized` | 401 | Your API key is missing, unknown or revoked. |
|
|
268
|
+
| `AuthenticationFailed` | `ceremony-failed` | 401 | The end user did not authenticate. No reason is given, by design. |
|
|
269
|
+
| `Forbidden` | `forbidden` | 403 | The key lacks the scope, or the authenticator model is not permitted. |
|
|
270
|
+
| `NotFound` | `not-found` | 404 | No such subject (`get_subject` only). |
|
|
271
|
+
| `Conflict` | `conflict` | 409 | State conflict, named in `detail`: authenticator already registered, credential limit, no authenticator. |
|
|
272
|
+
| `Throttled` | `throttled` | 429 | Too many attempts. Wait `retry_after` seconds. |
|
|
273
|
+
| `Unavailable` | `unavailable` | 503 | A dependency of the service is down. |
|
|
274
|
+
| `APIError` | `bad-request`, `payload-too-large`, `unsupported-media-type`, `internal` | 400, 413, 415, 500 | Everything else; `detail` explains a 400. |
|
|
275
|
+
| `TransportError` | | | No HTTP response: connection, TLS, timeout, or a refused redirect. |
|
|
276
|
+
| `ProtocolError` | | | A response that is not what the API describes, or a body over 1 MiB. |
|
|
277
|
+
| `ConfigurationError` | | | Unusable constructor arguments. Also a `ValueError`. |
|
|
278
|
+
| `InvalidAssertion` | | | The assertion failed verification. |
|
|
279
|
+
| `VerificationUnavailable` | | | `cryptography` is not installed. |
|
|
280
|
+
|
|
281
|
+
An unknown subject on an authentication route is reported as
|
|
282
|
+
`AuthenticationFailed`, not `NotFound`, so those routes cannot be used to test
|
|
283
|
+
whether somebody has an account. When a response carries no recognised `type`
|
|
284
|
+
(an error page from a proxy, say), the class falls back on the status; a bare
|
|
285
|
+
401 then maps to `Unauthorized`, never to `AuthenticationFailed`.
|
|
286
|
+
|
|
287
|
+
Quote `error.request_id` when asking the operator about a failure: it locates
|
|
288
|
+
the log and audit entries where the real reason was written.
|
|
289
|
+
|
|
290
|
+
## Development
|
|
291
|
+
|
|
292
|
+
```sh
|
|
293
|
+
cd sdk/python
|
|
294
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
295
|
+
python3 -m mypy --strict src
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The tests use a local `http.server` on loopback and never touch the network.
|
|
299
|
+
The verifier tests are skipped, with a message, when `cryptography` is not
|
|
300
|
+
installed.
|
|
301
|
+
|
|
302
|
+
## Licence
|
|
303
|
+
|
|
304
|
+
MIT, like the server.
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# n0passtemps for Python
|
|
2
|
+
|
|
3
|
+
Client SDK for the [n0passtemps](https://github.com/Socold/n0passtemps)
|
|
4
|
+
authentication server: WebAuthn ceremonies, TOTP, recovery codes, and offline
|
|
5
|
+
verification of the signed assertion that a successful authentication returns.
|
|
6
|
+
|
|
7
|
+
- Python 3.9 and later, fully typed (`py.typed`).
|
|
8
|
+
- The client has no runtime dependency; it is built on `urllib`.
|
|
9
|
+
- Verifying assertions needs an Ed25519 primitive, which the standard library
|
|
10
|
+
lacks. It comes from the optional `cryptography` dependency. The SDK ships no
|
|
11
|
+
signature code of its own.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install n0passtemps # client only
|
|
17
|
+
pip install "n0passtemps[verify]" # client and assertion verification
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## End to end
|
|
21
|
+
|
|
22
|
+
The browser half of a WebAuthn ceremony (`navigator.credentials.create` and
|
|
23
|
+
`.get`) is yours. The SDK covers the server half: it hands you the `options`
|
|
24
|
+
for the browser and takes the `credential` the browser produced, both as plain
|
|
25
|
+
dicts, untouched.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from n0passtemps import Client, AuthenticationFailed, Throttled
|
|
31
|
+
from n0passtemps import Verifier, RemoteJWKS
|
|
32
|
+
|
|
33
|
+
BASE_URL = "https://auth.example.org"
|
|
34
|
+
API_KEY_ID = os.environ["N0PASSTEMPS_API_KEY_ID"] # the id of the key, from the admin API
|
|
35
|
+
|
|
36
|
+
client = Client(BASE_URL, api_key=os.environ["N0PASSTEMPS_API_KEY"])
|
|
37
|
+
|
|
38
|
+
# 1. Make sure the subject exists. Idempotent: call it on every login.
|
|
39
|
+
subject = client.resolve_subject("user-8f3a1c", display_name="Alice")
|
|
40
|
+
|
|
41
|
+
# 2. Registration, when the subject has no authenticator yet.
|
|
42
|
+
if subject.credential_count == 0:
|
|
43
|
+
begun = client.begin_registration("user-8f3a1c", label="Laptop")
|
|
44
|
+
# send begun["options"] to the browser, get its PublicKeyCredential back
|
|
45
|
+
client.complete_registration("user-8f3a1c", begun["challenge_id"], credential)
|
|
46
|
+
batch = client.issue_recovery_codes("user-8f3a1c")
|
|
47
|
+
show_once(batch.codes) # they cannot be displayed again
|
|
48
|
+
|
|
49
|
+
# 3. Authentication.
|
|
50
|
+
begun = client.begin_assertion("user-8f3a1c")
|
|
51
|
+
try:
|
|
52
|
+
result = client.complete_assertion("user-8f3a1c", begun["challenge_id"], credential)
|
|
53
|
+
except AuthenticationFailed:
|
|
54
|
+
... # not authenticated; the service never says why
|
|
55
|
+
except Throttled as error:
|
|
56
|
+
... # wait error.retry_after seconds
|
|
57
|
+
|
|
58
|
+
# 4. Verify the proof before opening a session (see the next section).
|
|
59
|
+
verifier = Verifier(
|
|
60
|
+
issuer="n0passtemps",
|
|
61
|
+
audience=API_KEY_ID,
|
|
62
|
+
keys=RemoteJWKS(BASE_URL + "/v1/.well-known/jwks.json"),
|
|
63
|
+
)
|
|
64
|
+
claims = verifier.verify(result.assertion)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Build the `Verifier` once, at start-up, and share it: that is what makes its
|
|
68
|
+
key cache useful.
|
|
69
|
+
|
|
70
|
+
TOTP and recovery codes follow the same shape:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
enrolment = client.enrol_totp("user-8f3a1c") # .secret, .provisioning_uri: shown once
|
|
74
|
+
client.confirm_totp("user-8f3a1c", "123456") # the secret is inert until this succeeds
|
|
75
|
+
result = client.verify_totp("user-8f3a1c", "654321")
|
|
76
|
+
result = client.consume_recovery_code("user-8f3a1c", "ABCD-EFGH-JKLM")
|
|
77
|
+
result.recovery_codes_remaining
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Method | Route | Returns |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `resolve_subject(ref, display_name=None)` | `POST /v1/subjects` | `Subject` |
|
|
83
|
+
| `get_subject(ref)` | `GET /v1/subjects/{ref}` | `Subject` |
|
|
84
|
+
| `begin_registration(ref, label=None)` | `POST /v1/webauthn/{ref}/register` | `dict` |
|
|
85
|
+
| `complete_registration(ref, challenge_id, credential)` | `POST /v1/webauthn/{ref}/register/complete` | `dict` |
|
|
86
|
+
| `begin_assertion(ref)` | `POST /v1/webauthn/{ref}/assert` | `dict` |
|
|
87
|
+
| `complete_assertion(ref, challenge_id, credential)` | `POST /v1/webauthn/{ref}/assert/complete` | `AssertionResult` |
|
|
88
|
+
| `enrol_totp(ref)` | `POST /v1/totp/{ref}/enrol` | `TOTPEnrolment` |
|
|
89
|
+
| `confirm_totp(ref, code)` | `POST /v1/totp/{ref}/enrol/confirm` | `dict` |
|
|
90
|
+
| `verify_totp(ref, code)` | `POST /v1/totp/{ref}/verify` | `AssertionResult` |
|
|
91
|
+
| `issue_recovery_codes(ref)` | `POST /v1/recovery/{ref}/issue` | `RecoveryBatch` |
|
|
92
|
+
| `consume_recovery_code(ref, code)` | `POST /v1/recovery/{ref}/consume` | `AssertionResult` |
|
|
93
|
+
| `health()` | `GET /v1/health` | `dict` |
|
|
94
|
+
| `health_detail()` | `GET /v1/health/detail` | `dict` |
|
|
95
|
+
|
|
96
|
+
The result classes are frozen dataclasses. Each keeps the decoded response in
|
|
97
|
+
`.raw`, so a member added by a newer server is reachable without an SDK
|
|
98
|
+
release. `AssertionResult` carries `.assertion`, `.expires_at`, `.factors`,
|
|
99
|
+
`.signals`, `.subject_id` and, after a recovery code,
|
|
100
|
+
`.recovery_codes_remaining`. `health()` returns the liveness report even when
|
|
101
|
+
the service answers 503 with one; read its `status`.
|
|
102
|
+
|
|
103
|
+
The subject reference is your own identifier for the user. It is
|
|
104
|
+
percent-encoded into a single path segment, so any character is safe, but
|
|
105
|
+
prefer an opaque identifier to an email address: it travels in the request
|
|
106
|
+
path, where a reverse proxy may log it.
|
|
107
|
+
|
|
108
|
+
### What the client does to protect the API key
|
|
109
|
+
|
|
110
|
+
The key is a bearer credential, so the client is strict about where it goes:
|
|
111
|
+
|
|
112
|
+
- a base URL that is not `https` is refused, unless the host is loopback or you
|
|
113
|
+
pass `allow_insecure_transport=True`;
|
|
114
|
+
- a redirect is followed only for a `GET` that stays on the same scheme, host
|
|
115
|
+
and port. `urllib` would otherwise copy the `Authorization` header to
|
|
116
|
+
whichever host a `Location` header names;
|
|
117
|
+
- `repr(client)` shows the base URL only, and no exception message contains
|
|
118
|
+
the key;
|
|
119
|
+
- TLS verification cannot be switched off. For an internal certificate
|
|
120
|
+
authority, pass `ca_file="/path/to/ca.pem"`.
|
|
121
|
+
|
|
122
|
+
Responses are capped at 1 MiB, the default timeout is 10 seconds, and nothing
|
|
123
|
+
is retried: several routes consume single-use material (a challenge, a TOTP
|
|
124
|
+
step, a recovery code), so whether a repeat is safe is your decision.
|
|
125
|
+
|
|
126
|
+
### Say whose browser it is
|
|
127
|
+
|
|
128
|
+
Every call reaches the service from your backend, so its per-address rate limit
|
|
129
|
+
and network risk signal have nothing to work from unless you declare the end
|
|
130
|
+
user's address. `client.for_end_user(ip)` returns a client that sends it as
|
|
131
|
+
`X-End-User-IP` on every call; create one per incoming request:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
auth = client.for_end_user(request.remote_addr)
|
|
135
|
+
result = auth.verify_totp(subject_ref, code)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Without it the service applies no per-address limit to the call; the limits per
|
|
139
|
+
subject and per key still hold.
|
|
140
|
+
|
|
141
|
+
## Verifying the assertion
|
|
142
|
+
|
|
143
|
+
`result.assertion` is a compact JWS signed with Ed25519. Verifying it means
|
|
144
|
+
your application does not have to trust the network path between itself and
|
|
145
|
+
the service: a bare `200 OK` can be faked by whoever sits on that path, a
|
|
146
|
+
signature cannot.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from n0passtemps import Verifier, RemoteJWKS, StaticKeys, InvalidAssertion
|
|
150
|
+
|
|
151
|
+
verifier = Verifier(
|
|
152
|
+
issuer="n0passtemps", # the iss your deployment is configured with
|
|
153
|
+
audience=API_KEY_ID, # the id of your API key, not the key itself
|
|
154
|
+
keys=RemoteJWKS("https://auth.example.org/v1/.well-known/jwks.json"),
|
|
155
|
+
clock_skew=30,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
claims = verifier.verify(result.assertion)
|
|
160
|
+
except InvalidAssertion:
|
|
161
|
+
... # refuse; there is deliberately no further detail
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`verify` performs, in this order:
|
|
165
|
+
|
|
166
|
+
1. exactly three segments, each strict unpadded base64url (padding,
|
|
167
|
+
characters outside the alphabet and non-canonical trailing bits are all
|
|
168
|
+
refused);
|
|
169
|
+
2. the header `alg` is exactly `EdDSA`. The algorithm is never chosen from the
|
|
170
|
+
token, so `none`, `HS256` keyed with the public key, and everything else
|
|
171
|
+
are refused before any key is looked up;
|
|
172
|
+
3. any `crit` header is refused, and `typ`, when present, must be `JWT`;
|
|
173
|
+
4. the key is selected by `kid`, and only that key is tried;
|
|
174
|
+
5. the signature is verified **before** any claim is read;
|
|
175
|
+
6. then `iss`, `aud`, `sub`, `amr`, `jti`, `exp` (required) and `nbf`, the last
|
|
176
|
+
two with `clock_skew`.
|
|
177
|
+
|
|
178
|
+
Every failure raises the same `InvalidAssertion`, with no indication of which
|
|
179
|
+
check failed. Telling a caller that a forged token had the right audience, or
|
|
180
|
+
that a `kid` exists, would help nobody but an attacker. The one separate
|
|
181
|
+
outcome is `TransportError`, when `RemoteJWKS` cannot fetch the keys: that is
|
|
182
|
+
an outage on your side, not a property of the token.
|
|
183
|
+
|
|
184
|
+
### Two checks that remain yours
|
|
185
|
+
|
|
186
|
+
- **Enforce single use of `jti`.** An assertion stays valid until `exp`, about
|
|
187
|
+
a minute. Within that window nothing in the verifier stops it being
|
|
188
|
+
presented twice. Record each accepted `claims.jti` until `claims.expires_at`
|
|
189
|
+
has passed, and refuse a repeat.
|
|
190
|
+
- **Check `amr` against your own policy.** `claims.amr` lists the factors that
|
|
191
|
+
authenticated the subject: `webauthn`, `webauthn-uv` (the authenticator also
|
|
192
|
+
verified the user with a PIN or a biometric), `totp`, `recovery-code`. The
|
|
193
|
+
verifier proves the list is authentic; whether `recovery-code` alone may open
|
|
194
|
+
a session, or whether a sensitive operation needs `webauthn-uv`, is for your
|
|
195
|
+
application to decide.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
if not replay_cache.add(claims.jti, until=claims.expires_at):
|
|
199
|
+
raise PermissionError("assertion already used")
|
|
200
|
+
if "webauthn-uv" not in claims.amr:
|
|
201
|
+
raise PermissionError("user verification required")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Key sources
|
|
205
|
+
|
|
206
|
+
`RemoteJWKS(url, cache_seconds=300, min_refetch_interval=10)` fetches the
|
|
207
|
+
published key set and caches it. A token naming an unknown `kid` triggers one
|
|
208
|
+
refetch, which is how a rotated key is picked up straight away. Because that
|
|
209
|
+
path is driven by unverified input, all fetches are limited to one per
|
|
210
|
+
`min_refetch_interval`: a flood of tokens with random `kid` values cannot turn
|
|
211
|
+
your verifier into a request amplifier aimed at the service. Once the cache has
|
|
212
|
+
lapsed, a failed fetch raises `TransportError` instead of trusting keys of
|
|
213
|
+
unknown age. The URL must be `https` unless the host is loopback, since whoever
|
|
214
|
+
can rewrite that response chooses your verification keys.
|
|
215
|
+
|
|
216
|
+
`StaticKeys(jwks)` takes a JWK Set document you hold in configuration, and
|
|
217
|
+
`StaticKeys.from_public_keys([raw32bytes])` takes raw keys. No network is
|
|
218
|
+
involved, at the price of a configuration change when the service rotates its
|
|
219
|
+
key.
|
|
220
|
+
|
|
221
|
+
`jwk_thumbprint(jwk)` returns the RFC 7638 thumbprint of an Ed25519 JWK, the
|
|
222
|
+
value the service uses as `kid`. A published key whose `kid` does not match its
|
|
223
|
+
own thumbprint is ignored.
|
|
224
|
+
|
|
225
|
+
Without the `cryptography` package, constructing a `Verifier` raises
|
|
226
|
+
`VerificationUnavailable` with the install command, at start-up and not at the
|
|
227
|
+
first login.
|
|
228
|
+
|
|
229
|
+
## Errors
|
|
230
|
+
|
|
231
|
+
Everything derives from `N0PasstempsError`. An `APIError` carries the RFC 9457
|
|
232
|
+
problem document: `status`, `type`, `title`, `detail`, `request_id`,
|
|
233
|
+
`retry_after`, and `raw`. The subclass is chosen from the problem `type`, not
|
|
234
|
+
from the status, because 401 means two different things.
|
|
235
|
+
|
|
236
|
+
| Exception | Problem `type` (after `urn:n0passtemps:error:`) | Status | Meaning |
|
|
237
|
+
|---|---|---|---|
|
|
238
|
+
| `Unauthorized` | `unauthorized` | 401 | Your API key is missing, unknown or revoked. |
|
|
239
|
+
| `AuthenticationFailed` | `ceremony-failed` | 401 | The end user did not authenticate. No reason is given, by design. |
|
|
240
|
+
| `Forbidden` | `forbidden` | 403 | The key lacks the scope, or the authenticator model is not permitted. |
|
|
241
|
+
| `NotFound` | `not-found` | 404 | No such subject (`get_subject` only). |
|
|
242
|
+
| `Conflict` | `conflict` | 409 | State conflict, named in `detail`: authenticator already registered, credential limit, no authenticator. |
|
|
243
|
+
| `Throttled` | `throttled` | 429 | Too many attempts. Wait `retry_after` seconds. |
|
|
244
|
+
| `Unavailable` | `unavailable` | 503 | A dependency of the service is down. |
|
|
245
|
+
| `APIError` | `bad-request`, `payload-too-large`, `unsupported-media-type`, `internal` | 400, 413, 415, 500 | Everything else; `detail` explains a 400. |
|
|
246
|
+
| `TransportError` | | | No HTTP response: connection, TLS, timeout, or a refused redirect. |
|
|
247
|
+
| `ProtocolError` | | | A response that is not what the API describes, or a body over 1 MiB. |
|
|
248
|
+
| `ConfigurationError` | | | Unusable constructor arguments. Also a `ValueError`. |
|
|
249
|
+
| `InvalidAssertion` | | | The assertion failed verification. |
|
|
250
|
+
| `VerificationUnavailable` | | | `cryptography` is not installed. |
|
|
251
|
+
|
|
252
|
+
An unknown subject on an authentication route is reported as
|
|
253
|
+
`AuthenticationFailed`, not `NotFound`, so those routes cannot be used to test
|
|
254
|
+
whether somebody has an account. When a response carries no recognised `type`
|
|
255
|
+
(an error page from a proxy, say), the class falls back on the status; a bare
|
|
256
|
+
401 then maps to `Unauthorized`, never to `AuthenticationFailed`.
|
|
257
|
+
|
|
258
|
+
Quote `error.request_id` when asking the operator about a failure: it locates
|
|
259
|
+
the log and audit entries where the real reason was written.
|
|
260
|
+
|
|
261
|
+
## Development
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
cd sdk/python
|
|
265
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
266
|
+
python3 -m mypy --strict src
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The tests use a local `http.server` on loopback and never touch the network.
|
|
270
|
+
The verifier tests are skipped, with a message, when `cryptography` is not
|
|
271
|
+
installed.
|
|
272
|
+
|
|
273
|
+
## Licence
|
|
274
|
+
|
|
275
|
+
MIT, like the server.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "n0passtemps"
|
|
7
|
+
version = "1.1.3"
|
|
8
|
+
description = "Client SDK for the n0passtemps authentication server: WebAuthn, TOTP, recovery codes and offline verification of the signed assertion."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Socold" }]
|
|
13
|
+
keywords = ["authentication", "webauthn", "passkeys", "totp", "jws", "ed25519"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
# No runtime dependency: the client is built on the standard library.
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
# Assertion verification needs an Ed25519 primitive, which the standard
|
|
34
|
+
# library does not have.
|
|
35
|
+
verify = ["cryptography>=42"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/Socold/n0passtemps"
|
|
39
|
+
Source = "https://github.com/Socold/n0passtemps/tree/main/sdk/python"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
package-dir = { "" = "src" }
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.package-data]
|
|
48
|
+
n0passtemps = ["py.typed"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
strict = true
|