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.
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+