masks-client 0.2.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.
Files changed (46) hide show
  1. masks_client-0.2.0/.gitignore +6 -0
  2. masks_client-0.2.0/CHANGELOG.md +42 -0
  3. masks_client-0.2.0/LICENSE +21 -0
  4. masks_client-0.2.0/PKG-INFO +316 -0
  5. masks_client-0.2.0/README.md +287 -0
  6. masks_client-0.2.0/TODO.md +22 -0
  7. masks_client-0.2.0/pyproject.toml +70 -0
  8. masks_client-0.2.0/src/masks/__init__.py +116 -0
  9. masks_client-0.2.0/src/masks/asgi.py +104 -0
  10. masks_client-0.2.0/src/masks/auth.py +89 -0
  11. masks_client-0.2.0/src/masks/client.py +1077 -0
  12. masks_client-0.2.0/src/masks/django/__init__.py +171 -0
  13. masks_client-0.2.0/src/masks/django/middleware.py +47 -0
  14. masks_client-0.2.0/src/masks/django/urls.py +66 -0
  15. masks_client-0.2.0/src/masks/dpop.py +56 -0
  16. masks_client-0.2.0/src/masks/errors.py +80 -0
  17. masks_client-0.2.0/src/masks/fastapi.py +248 -0
  18. masks_client-0.2.0/src/masks/flask.py +226 -0
  19. masks_client-0.2.0/src/masks/flows.py +282 -0
  20. masks_client-0.2.0/src/masks/handshake.py +157 -0
  21. masks_client-0.2.0/src/masks/keys.py +99 -0
  22. masks_client-0.2.0/src/masks/keyset.py +113 -0
  23. masks_client-0.2.0/src/masks/models.py +328 -0
  24. masks_client-0.2.0/src/masks/oidc.py +131 -0
  25. masks_client-0.2.0/src/masks/py.typed +0 -0
  26. masks_client-0.2.0/src/masks/replay.py +37 -0
  27. masks_client-0.2.0/src/masks/sources.py +54 -0
  28. masks_client-0.2.0/src/masks/verifier.py +308 -0
  29. masks_client-0.2.0/src/masks/web/__init__.py +36 -0
  30. masks_client-0.2.0/src/masks/web/config.py +98 -0
  31. masks_client-0.2.0/src/masks/web/core.py +1421 -0
  32. masks_client-0.2.0/src/masks/web/credentials.py +128 -0
  33. masks_client-0.2.0/src/masks/web/person.py +239 -0
  34. masks_client-0.2.0/src/masks/web/sessions.py +391 -0
  35. masks_client-0.2.0/tests/__init__.py +0 -0
  36. masks_client-0.2.0/tests/conftest.py +42 -0
  37. masks_client-0.2.0/tests/django_app.py +52 -0
  38. masks_client-0.2.0/tests/fake.py +769 -0
  39. masks_client-0.2.0/tests/scenarios.py +387 -0
  40. masks_client-0.2.0/tests/test_client.py +592 -0
  41. masks_client-0.2.0/tests/test_django.py +95 -0
  42. masks_client-0.2.0/tests/test_fastapi.py +752 -0
  43. masks_client-0.2.0/tests/test_flask.py +133 -0
  44. masks_client-0.2.0/tests/test_resource.py +574 -0
  45. masks_client-0.2.0/tests/test_signin.py +357 -0
  46. masks_client-0.2.0/uv.lock +1224 -0
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/masksrb/masks/compare/python-v0.1.0...python-v0.2.0) (2026-10-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **python:** a FastAPI app keeps its sessions in an async store, so waiting on Redis or a database holds up no other request ([f285a95](https://github.com/masksrb/masks/commit/f285a9521f9a40552c5f63d0318653b333c39d87))
9
+ * **python:** a FastAPI app signs people in through masks, guards routes by privilege, and accepts tokens ([6265b69](https://github.com/masksrb/masks/commit/6265b696fd4c53b33879afeac20704e5ff28539d))
10
+ * **python:** a Python app signs people in with the code flow, shakes hands with masks, and holds delegations ([f649f92](https://github.com/masksrb/masks/commit/f649f92e62baadaf4a16bbb1893c418a20e8e4b4))
11
+ * **python:** a sign-in checks that masks still knows the app, and sends it back through the handshake when not ([d1a16cd](https://github.com/masksrb/masks/commit/d1a16cd442ef55e3b724d181c72b0f71f0389ba1))
12
+ * **python:** a verifier remembers DPoP proofs in a replay store that several processes can share ([209c4ca](https://github.com/masksrb/masks/commit/209c4ca460de76ae8d01f1b64a6b45683c8643d4))
13
+ * **python:** an app enrolls and unenrolls its own machines, introspects tokens, and exchanges them ([9bc4070](https://github.com/masksrb/masks/commit/9bc4070ec296f1d9e345da89a471192a99b7f8c8))
14
+ * **python:** Django and Flask apps sign people in through masks with the same routes and privileges ([a662f29](https://github.com/masksrb/masks/commit/a662f29a7134ba01d42f811f9bd6d902f0732873))
15
+ * **python:** requests that share an expired sign-in refresh it once, and back-channel logout ends cookie sessions ([7b5d731](https://github.com/masksrb/masks/commit/7b5d731a75af344fad66c0c4fda6385a751b6936))
16
+
17
+
18
+ ### Fixes
19
+
20
+ * **python:** a session holds to the issuer that signed it in, a back-channel request over 64 KB is refused unread, and an answer that changes the session stays out of shared caches ([443c695](https://github.com/masksrb/masks/commit/443c695046e018db13aa795dd034dc86393393fc))
21
+ * **python:** a signed-in person's picture is the app's avatar route when masks holds a photo, so it no longer answers 401 in the browser ([ac6283b](https://github.com/masksrb/masks/commit/ac6283b5b5569e1c740aa4e5bfd0a938179a5f8e))
22
+ * **python:** a verifier that cannot reach its keys or replay store answers 503 instead of rejecting the token ([0396e55](https://github.com/masksrb/masks/commit/0396e55fd80793e225ec1893f51fbdaf60c53da0))
23
+ * **python:** Flask and Django read the session only when a request needs it, cap a back-channel request at 64 KB, and refuse an async store at startup ([09ccf0e](https://github.com/masksrb/masks/commit/09ccf0e38bc3124df6ce3ccd51c4dcab31906a85))
24
+ * **python:** signing out everywhere returns to a registered address, and no other site can sign anyone out ([e12d0ec](https://github.com/masksrb/masks/commit/e12d0ec3ba8042eca34a97dbe2e65598b701d73f))
25
+ * **python:** the Reconnect button on the connection page reaches masks, and no other site can frame a masks page ([4e204e0](https://github.com/masksrb/masks/commit/4e204e0f972e8109b15fc885d04e8e6e926a91d3))
26
+
27
+
28
+ ### Documentation
29
+
30
+ * each language has a group under Reference, with an overview, a page per framework, and its API reference ([64b7837](https://github.com/masksrb/masks/commit/64b7837e0484d11ddd5b4d926fc4aa800c06ed81))
31
+ * each language's guide sits in the sidebar, and Other languages is gone ([86e863f](https://github.com/masksrb/masks/commit/86e863f52d92c7aaf9649d284e70787e17ac1b6e))
32
+ * each SDK keeps a TODO of the cleanups, security follow-ups, and testing left from the framework integrations ([4c4353b](https://github.com/masksrb/masks/commit/4c4353b63713700034c78f3d3c0b3b45ac7b50b3))
33
+ * **python:** the Python SDK installs from its release tag, since masks-client is not on PyPI yet ([f59ee0f](https://github.com/masksrb/masks/commit/f59ee0f5a93001a0e315684ede4b37e25c79e177))
34
+ * **python:** the TODO records the live FastAPI run and what it left out ([2840e96](https://github.com/masksrb/masks/commit/2840e967840f3c744b729d31bfc896733d15844c))
35
+ * **python:** the TODO records the live Flask run ([765c5b1](https://github.com/masksrb/masks/commit/765c5b15e99c734ddc7bb93b00dd8e16e332d3b3))
36
+
37
+ ## 0.1.0 (2026-10-11)
38
+
39
+
40
+ ### Features
41
+
42
+ * **python:** a Python service or machine signs in to masks as itself with a key, holds its tokens to a second key with DPoP, enrolls with an initial access token, and an API checks those tokens ([ecbc546](https://github.com/masksrb/masks/commit/ecbc5460507ab7a2e1a99e4d17325266ced7d14d))
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the masks authors
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,316 @@
1
+ Metadata-Version: 2.5
2
+ Name: masks-client
3
+ Version: 0.2.0
4
+ Summary: Sign a Python service or machine in to masks, hold its tokens to a key with DPoP, and check masks access tokens in an API.
5
+ Project-URL: Homepage, https://masks.pages.dev
6
+ Project-URL: Source, https://github.com/masksrb/masks/tree/main/python
7
+ Project-URL: Changelog, https://github.com/masksrb/masks/blob/main/python/CHANGELOG.md
8
+ Author-email: Jon Geiger <jon@geiger.to>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: authentication,dpop,masks,oauth,openid-connect
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: anyio>=4.5
19
+ Requires-Dist: cryptography>=44
20
+ Requires-Dist: httpx<1,>=0.28.1
21
+ Requires-Dist: pyjwt>=2.10
22
+ Provides-Extra: django
23
+ Requires-Dist: django>=5.2; extra == 'django'
24
+ Provides-Extra: fastapi
25
+ Requires-Dist: fastapi>=0.115; extra == 'fastapi'
26
+ Provides-Extra: flask
27
+ Requires-Dist: flask>=3.1; extra == 'flask'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # masks for Python
31
+
32
+ `masks-client` connects a Python web app, service, or machine to masks. It signs
33
+ people in, gets tokens as a client, and checks them as an API. Every call has a synchronous form and an
34
+ `async` one.
35
+
36
+ ```sh
37
+ pip install "masks-client @ git+https://github.com/masksrb/masks@python-v0.1.0#subdirectory=python"
38
+ ```
39
+
40
+ The package installs from its release tag and is not on PyPI yet. It imports as
41
+ `masks` and needs Python 3.10 or later.
42
+
43
+ | Covers | |
44
+ | --- | --- |
45
+ | Discovery | The issuer's document, checked against the address it was fetched from. |
46
+ | Registration | Redeeming an initial access token, then reading, updating, and deleting the client. |
47
+ | Enrollment | An app asking for initial access tokens for its own machines, and unenrolling them. |
48
+ | Client authentication | `client_secret_basic`, `client_secret_post`, `private_key_jwt`, and `none`. |
49
+ | Grants | Client credentials, refresh tokens, the device flow, and token exchange. |
50
+ | DPoP | Tokens held to a key, and proofs on every request an `httpx` client sends. |
51
+ | Revocation | Access and refresh tokens. |
52
+ | Introspection | Asking the issuer whether a token is still active. |
53
+ | Checking tokens | `at+jwt` access tokens, including DPoP-bound ones, in any framework or as ASGI middleware. |
54
+
55
+ `masks.fastapi`, `masks.django`, and `masks.flask` sign people in to a web app.
56
+ See [Python apps](https://masks.pages.dev/reference/python/) for the walkthrough and
57
+ [masks-client](https://masks.pages.dev/reference/python/client/) for every setting and
58
+ call.
59
+
60
+ ## A machine signs in as itself
61
+
62
+ A machine keeps two P-256 keys. One signs its client assertions and the other
63
+ holds its tokens, so a token copied off the machine is useless without the key.
64
+
65
+ ```python
66
+ import json
67
+ import masks
68
+
69
+ key = masks.Key.generate()
70
+ dpop = masks.Key.generate()
71
+
72
+ saved = json.dumps({"key": key.to_jwk(), "dpop": dpop.to_jwk()})
73
+ key = masks.Key.from_jwk(json.loads(saved)["key"])
74
+ ```
75
+
76
+ `to_jwk()` writes the private key, so keep the file readable only by the
77
+ machine's service user. `key.jwks()` is the public half to register.
78
+
79
+ ```python
80
+ issuer = masks.discover("https://auth.example.com")
81
+
82
+ client = masks.Client(issuer, client_id, key=key, dpop=dpop)
83
+ token = client.client_credentials(scope=["files:read"], resource=["https://files.example.com"])
84
+ ```
85
+
86
+ The client authenticates with `client_secret_basic` when `secret` is set and
87
+ with `private_key_jwt` when `key` is. Pass `method` to choose another. When
88
+ `dpop` is set, a token that comes back unbound is refused.
89
+
90
+ ## Enrolling with an initial access token
91
+
92
+ A manager approves the client in masks, and masks gives out an initial access
93
+ token for it. The machine redeems the token once and keeps what comes back.
94
+
95
+ ```python
96
+ registration = masks.Client.register(issuer, masks.ClientMetadata(), initial_access_token)
97
+ client = masks.Client.from_registration(issuer, registration, key=key, dpop=dpop)
98
+ ```
99
+
100
+ `read_registration`, `update_registration`, and `delete_registration` manage
101
+ the client with its registration access token. They send that token only to the
102
+ issuer's own origin.
103
+
104
+ ## Enrolling machines as an app
105
+
106
+ An approved app that has claimed a namespace enrolls machines of its own, such
107
+ as an agent it installs on each computer. It asks the issuer's
108
+ `enrollment_endpoint` for an initial access token and hands the token to the
109
+ machine, which registers with it as above.
110
+
111
+ ```python
112
+ app = masks.Client(issuer, app_id, secret=app_secret)
113
+
114
+ enrollment = app.enroll(scope=["xixo:agent"], client_name="nas", expires_in=900)
115
+ enrollment.initial_access_token
116
+ enrollment.expires_at
117
+ ```
118
+
119
+ The app authenticates the way it does at the token endpoint. Every scope must
120
+ fall under one of the app's namespaces. `expires_in` is between 60 and 3600
121
+ seconds and defaults to 15 minutes. The token is spent the first time a machine
122
+ registers with it.
123
+
124
+ ```python
125
+ app.unenroll(machine_client_id)
126
+ ```
127
+
128
+ `unenroll` archives the machine and revokes every token it holds. Unenrolling
129
+ it again succeeds as well. A client the app did not enroll raises `masks.Error`
130
+ with `status` 404.
131
+
132
+ ## Calling an API
133
+
134
+ `client.auth` returns an `httpx.Auth` that fetches and reuses tokens and signs
135
+ each request. It sends tokens only to the origins it is given, and `httpx`
136
+ drops the token when a redirect leaves that origin.
137
+
138
+ ```python
139
+ import httpx
140
+
141
+ source = client.credentials_source(scope=["files:read"], resource=["https://files.example.com"])
142
+
143
+ with httpx.Client(auth=client.auth(source, "https://files.example.com")) as api:
144
+ response = api.get("https://files.example.com/files")
145
+ ```
146
+
147
+ A held token goes out as `Authorization: DPoP` with a fresh proof, and a server
148
+ that asks for a DPoP nonce is answered once. `refresh_source` renews a token
149
+ with its refresh token and hands each rotated token to `keep` to be saved.
150
+
151
+ ## Exchanging a token
152
+
153
+ `exchange` trades a token for a new one under RFC 8693. A service that receives
154
+ a caller's access token exchanges it for a token to another API, acting on the
155
+ caller's behalf.
156
+
157
+ ```python
158
+ token = client.exchange(
159
+ subject_token,
160
+ scope=["files:read"],
161
+ resource=["https://files.example.com"],
162
+ requested_lifetime=300,
163
+ )
164
+ ```
165
+
166
+ The subject is an access token unless `subject_token_type` says
167
+ `masks.ID_TOKEN_TYPE`. `actor_token` names another party in the token's `act`
168
+ claim. The new token lasts no longer than the subject token. When the client
169
+ has a `dpop` key the new token is held to it, and a subject token held to a key
170
+ is exchanged only with a proof from that same key.
171
+
172
+ `requested_token_type=masks.UPSTREAM_ACCESS_TOKEN_TYPE` with a connection's id
173
+ in `audience` releases the access token masks holds for that connection through
174
+ a delegation. That token is a Bearer token from the provider, so it comes back
175
+ unbound with `issued_token_type` naming the upstream type.
176
+
177
+ ## Async
178
+
179
+ `masks.AsyncClient` has the same methods as `masks.Client`, awaited, and
180
+ `masks.adiscover` discovers an issuer.
181
+
182
+ ```python
183
+ issuer = await masks.adiscover("https://auth.example.com")
184
+
185
+ async with masks.AsyncClient(issuer, client_id, key=key, dpop=dpop) as client:
186
+ source = client.credentials_source(resource=["https://files.example.com"])
187
+
188
+ async with httpx.AsyncClient(auth=client.auth(source, "https://files.example.com")) as api:
189
+ response = await api.get("https://files.example.com/files")
190
+ ```
191
+
192
+ ## The device flow
193
+
194
+ A machine with no browser shows a code that a person approves on another
195
+ device.
196
+
197
+ ```python
198
+ device = client.device_authorization(scope=["openid", "offline_access"])
199
+ print(f"Open {device.verification_uri} and enter {device.user_code}")
200
+
201
+ token = client.await_device(device)
202
+ ```
203
+
204
+ `await_device` polls at the interval masks names, slows down when asked, and
205
+ stops when the code expires. The synchronous form takes a `timeout` in seconds,
206
+ and the async form stops when its task is cancelled.
207
+
208
+ ## Checking access tokens
209
+
210
+ `masks.Verifier` checks a token from any framework. Pass the method, the URL
211
+ the client used, and the request's headers.
212
+
213
+ ```python
214
+ verifier = masks.Verifier(issuer, "https://files.example.com", public_url="https://files.example.com")
215
+
216
+ claims = verifier.authenticate(request.method, request.url, request.headers)
217
+ claims.has_scope("files:read")
218
+ ```
219
+
220
+ The verifier checks the signature against the issuer's published keys, the
221
+ `at+jwt` type, the issuer, the audience, and the expiry. A DPoP-bound token
222
+ needs a proof signed by its key, made for this method and URL, made in the last
223
+ minute, and not seen before. A failed check raises `masks.InvalidToken`.
224
+
225
+ When the verifier cannot reach the issuer's keys, or its replay store fails, it
226
+ raises `masks.Unavailable`. The token may still be good, so answer `503` and
227
+ let the client try again with the same token.
228
+
229
+ Set `public_url` when a proxy sits in front of the API, since a proof names the
230
+ address the client used.
231
+
232
+ ### Replayed proofs
233
+
234
+ A verifier keeps the proofs it has seen in the process's memory. An API that
235
+ runs in several processes passes `replay` an object whose `first(key,
236
+ expires_in)` answers `True` only the first time it sees a key across every
237
+ process, and `False` after that, for `expires_in` seconds.
238
+
239
+ ```python
240
+ class RedisReplay:
241
+ def __init__(self, redis):
242
+ self.redis = redis
243
+
244
+ def first(self, key, expires_in):
245
+ return bool(self.redis.set(f"dpop:{key}", 1, nx=True, ex=expires_in))
246
+
247
+
248
+ verifier = masks.Verifier(issuer, "https://files.example.com", replay=RedisReplay(redis))
249
+ ```
250
+
251
+ `AsyncVerifier` also takes a store whose `first` is a coroutine, and
252
+ `Verifier` raises `TypeError` when given one. An answer of `False` raises
253
+ `masks.InvalidToken`. When the store raises or answers anything other than
254
+ `True` or `False`, the verifier raises `masks.Unavailable` from the store's
255
+ error. `masks.MemoryReplayStore` is the default, and one instance can be shared
256
+ between verifiers in the same process.
257
+
258
+ ### Introspection
259
+
260
+ A verifier that checks the JWT sees a revoked token as valid until it expires.
261
+ `introspect` asks the issuer.
262
+
263
+ ```python
264
+ found = client.introspect(token, "access_token")
265
+ found.active and found.has_scope("files:read")
266
+ ```
267
+
268
+ The issuer answers `active` only to the client the token was issued to, or to
269
+ an approved client whose resources include one of the token's audiences. A
270
+ token that is not active has no scopes. An answer that names another issuer
271
+ raises `masks.Error`.
272
+
273
+ ### ASGI and FastAPI
274
+
275
+ `masks.asgi.Middleware` checks every request with an `AsyncVerifier` and
276
+ answers `401` with `WWW-Authenticate` challenges when the check fails. It
277
+ answers `503` with no challenge when the verifier raises `masks.Unavailable`.
278
+ The claims are at `request.state.masks_claims`.
279
+
280
+ ```python
281
+ from fastapi import Depends, FastAPI, HTTPException, Request
282
+ from masks.asgi import Middleware
283
+
284
+ verifier = masks.AsyncVerifier(issuer, "https://files.example.com", public_url="https://files.example.com")
285
+
286
+ app = FastAPI()
287
+ app.add_middleware(Middleware, verifier=verifier)
288
+
289
+
290
+ def reading(request: Request) -> masks.Claims:
291
+ claims = request.state.masks_claims
292
+
293
+ if not claims.has_scope("files:read"):
294
+ raise HTTPException(403)
295
+
296
+ return claims
297
+
298
+
299
+ @app.get("/files")
300
+ async def files(claims: masks.Claims = Depends(reading)):
301
+ return {"for": claims.subject}
302
+ ```
303
+
304
+ ### Django and Flask
305
+
306
+ Call `Verifier.authenticate` in a view decorator or a `before_request` hook.
307
+ In Django, pass `request.method`, `request.build_absolute_uri()`, and
308
+ `request.headers`. In Flask, pass `request.method`, `request.url`, and
309
+ `request.headers`. Answer `401` when it raises `masks.InvalidToken`, and `503`
310
+ when it raises `masks.Unavailable`.
311
+
312
+ ## Plain HTTP
313
+
314
+ Every URL is `https`, except on this machine: `localhost`, a `.localhost` name,
315
+ or a loopback address. `allow_plain_http=True` on `discover` lifts that for a
316
+ private network.
@@ -0,0 +1,287 @@
1
+ # masks for Python
2
+
3
+ `masks-client` connects a Python web app, service, or machine to masks. It signs
4
+ people in, gets tokens as a client, and checks them as an API. Every call has a synchronous form and an
5
+ `async` one.
6
+
7
+ ```sh
8
+ pip install "masks-client @ git+https://github.com/masksrb/masks@python-v0.1.0#subdirectory=python"
9
+ ```
10
+
11
+ The package installs from its release tag and is not on PyPI yet. It imports as
12
+ `masks` and needs Python 3.10 or later.
13
+
14
+ | Covers | |
15
+ | --- | --- |
16
+ | Discovery | The issuer's document, checked against the address it was fetched from. |
17
+ | Registration | Redeeming an initial access token, then reading, updating, and deleting the client. |
18
+ | Enrollment | An app asking for initial access tokens for its own machines, and unenrolling them. |
19
+ | Client authentication | `client_secret_basic`, `client_secret_post`, `private_key_jwt`, and `none`. |
20
+ | Grants | Client credentials, refresh tokens, the device flow, and token exchange. |
21
+ | DPoP | Tokens held to a key, and proofs on every request an `httpx` client sends. |
22
+ | Revocation | Access and refresh tokens. |
23
+ | Introspection | Asking the issuer whether a token is still active. |
24
+ | Checking tokens | `at+jwt` access tokens, including DPoP-bound ones, in any framework or as ASGI middleware. |
25
+
26
+ `masks.fastapi`, `masks.django`, and `masks.flask` sign people in to a web app.
27
+ See [Python apps](https://masks.pages.dev/reference/python/) for the walkthrough and
28
+ [masks-client](https://masks.pages.dev/reference/python/client/) for every setting and
29
+ call.
30
+
31
+ ## A machine signs in as itself
32
+
33
+ A machine keeps two P-256 keys. One signs its client assertions and the other
34
+ holds its tokens, so a token copied off the machine is useless without the key.
35
+
36
+ ```python
37
+ import json
38
+ import masks
39
+
40
+ key = masks.Key.generate()
41
+ dpop = masks.Key.generate()
42
+
43
+ saved = json.dumps({"key": key.to_jwk(), "dpop": dpop.to_jwk()})
44
+ key = masks.Key.from_jwk(json.loads(saved)["key"])
45
+ ```
46
+
47
+ `to_jwk()` writes the private key, so keep the file readable only by the
48
+ machine's service user. `key.jwks()` is the public half to register.
49
+
50
+ ```python
51
+ issuer = masks.discover("https://auth.example.com")
52
+
53
+ client = masks.Client(issuer, client_id, key=key, dpop=dpop)
54
+ token = client.client_credentials(scope=["files:read"], resource=["https://files.example.com"])
55
+ ```
56
+
57
+ The client authenticates with `client_secret_basic` when `secret` is set and
58
+ with `private_key_jwt` when `key` is. Pass `method` to choose another. When
59
+ `dpop` is set, a token that comes back unbound is refused.
60
+
61
+ ## Enrolling with an initial access token
62
+
63
+ A manager approves the client in masks, and masks gives out an initial access
64
+ token for it. The machine redeems the token once and keeps what comes back.
65
+
66
+ ```python
67
+ registration = masks.Client.register(issuer, masks.ClientMetadata(), initial_access_token)
68
+ client = masks.Client.from_registration(issuer, registration, key=key, dpop=dpop)
69
+ ```
70
+
71
+ `read_registration`, `update_registration`, and `delete_registration` manage
72
+ the client with its registration access token. They send that token only to the
73
+ issuer's own origin.
74
+
75
+ ## Enrolling machines as an app
76
+
77
+ An approved app that has claimed a namespace enrolls machines of its own, such
78
+ as an agent it installs on each computer. It asks the issuer's
79
+ `enrollment_endpoint` for an initial access token and hands the token to the
80
+ machine, which registers with it as above.
81
+
82
+ ```python
83
+ app = masks.Client(issuer, app_id, secret=app_secret)
84
+
85
+ enrollment = app.enroll(scope=["xixo:agent"], client_name="nas", expires_in=900)
86
+ enrollment.initial_access_token
87
+ enrollment.expires_at
88
+ ```
89
+
90
+ The app authenticates the way it does at the token endpoint. Every scope must
91
+ fall under one of the app's namespaces. `expires_in` is between 60 and 3600
92
+ seconds and defaults to 15 minutes. The token is spent the first time a machine
93
+ registers with it.
94
+
95
+ ```python
96
+ app.unenroll(machine_client_id)
97
+ ```
98
+
99
+ `unenroll` archives the machine and revokes every token it holds. Unenrolling
100
+ it again succeeds as well. A client the app did not enroll raises `masks.Error`
101
+ with `status` 404.
102
+
103
+ ## Calling an API
104
+
105
+ `client.auth` returns an `httpx.Auth` that fetches and reuses tokens and signs
106
+ each request. It sends tokens only to the origins it is given, and `httpx`
107
+ drops the token when a redirect leaves that origin.
108
+
109
+ ```python
110
+ import httpx
111
+
112
+ source = client.credentials_source(scope=["files:read"], resource=["https://files.example.com"])
113
+
114
+ with httpx.Client(auth=client.auth(source, "https://files.example.com")) as api:
115
+ response = api.get("https://files.example.com/files")
116
+ ```
117
+
118
+ A held token goes out as `Authorization: DPoP` with a fresh proof, and a server
119
+ that asks for a DPoP nonce is answered once. `refresh_source` renews a token
120
+ with its refresh token and hands each rotated token to `keep` to be saved.
121
+
122
+ ## Exchanging a token
123
+
124
+ `exchange` trades a token for a new one under RFC 8693. A service that receives
125
+ a caller's access token exchanges it for a token to another API, acting on the
126
+ caller's behalf.
127
+
128
+ ```python
129
+ token = client.exchange(
130
+ subject_token,
131
+ scope=["files:read"],
132
+ resource=["https://files.example.com"],
133
+ requested_lifetime=300,
134
+ )
135
+ ```
136
+
137
+ The subject is an access token unless `subject_token_type` says
138
+ `masks.ID_TOKEN_TYPE`. `actor_token` names another party in the token's `act`
139
+ claim. The new token lasts no longer than the subject token. When the client
140
+ has a `dpop` key the new token is held to it, and a subject token held to a key
141
+ is exchanged only with a proof from that same key.
142
+
143
+ `requested_token_type=masks.UPSTREAM_ACCESS_TOKEN_TYPE` with a connection's id
144
+ in `audience` releases the access token masks holds for that connection through
145
+ a delegation. That token is a Bearer token from the provider, so it comes back
146
+ unbound with `issued_token_type` naming the upstream type.
147
+
148
+ ## Async
149
+
150
+ `masks.AsyncClient` has the same methods as `masks.Client`, awaited, and
151
+ `masks.adiscover` discovers an issuer.
152
+
153
+ ```python
154
+ issuer = await masks.adiscover("https://auth.example.com")
155
+
156
+ async with masks.AsyncClient(issuer, client_id, key=key, dpop=dpop) as client:
157
+ source = client.credentials_source(resource=["https://files.example.com"])
158
+
159
+ async with httpx.AsyncClient(auth=client.auth(source, "https://files.example.com")) as api:
160
+ response = await api.get("https://files.example.com/files")
161
+ ```
162
+
163
+ ## The device flow
164
+
165
+ A machine with no browser shows a code that a person approves on another
166
+ device.
167
+
168
+ ```python
169
+ device = client.device_authorization(scope=["openid", "offline_access"])
170
+ print(f"Open {device.verification_uri} and enter {device.user_code}")
171
+
172
+ token = client.await_device(device)
173
+ ```
174
+
175
+ `await_device` polls at the interval masks names, slows down when asked, and
176
+ stops when the code expires. The synchronous form takes a `timeout` in seconds,
177
+ and the async form stops when its task is cancelled.
178
+
179
+ ## Checking access tokens
180
+
181
+ `masks.Verifier` checks a token from any framework. Pass the method, the URL
182
+ the client used, and the request's headers.
183
+
184
+ ```python
185
+ verifier = masks.Verifier(issuer, "https://files.example.com", public_url="https://files.example.com")
186
+
187
+ claims = verifier.authenticate(request.method, request.url, request.headers)
188
+ claims.has_scope("files:read")
189
+ ```
190
+
191
+ The verifier checks the signature against the issuer's published keys, the
192
+ `at+jwt` type, the issuer, the audience, and the expiry. A DPoP-bound token
193
+ needs a proof signed by its key, made for this method and URL, made in the last
194
+ minute, and not seen before. A failed check raises `masks.InvalidToken`.
195
+
196
+ When the verifier cannot reach the issuer's keys, or its replay store fails, it
197
+ raises `masks.Unavailable`. The token may still be good, so answer `503` and
198
+ let the client try again with the same token.
199
+
200
+ Set `public_url` when a proxy sits in front of the API, since a proof names the
201
+ address the client used.
202
+
203
+ ### Replayed proofs
204
+
205
+ A verifier keeps the proofs it has seen in the process's memory. An API that
206
+ runs in several processes passes `replay` an object whose `first(key,
207
+ expires_in)` answers `True` only the first time it sees a key across every
208
+ process, and `False` after that, for `expires_in` seconds.
209
+
210
+ ```python
211
+ class RedisReplay:
212
+ def __init__(self, redis):
213
+ self.redis = redis
214
+
215
+ def first(self, key, expires_in):
216
+ return bool(self.redis.set(f"dpop:{key}", 1, nx=True, ex=expires_in))
217
+
218
+
219
+ verifier = masks.Verifier(issuer, "https://files.example.com", replay=RedisReplay(redis))
220
+ ```
221
+
222
+ `AsyncVerifier` also takes a store whose `first` is a coroutine, and
223
+ `Verifier` raises `TypeError` when given one. An answer of `False` raises
224
+ `masks.InvalidToken`. When the store raises or answers anything other than
225
+ `True` or `False`, the verifier raises `masks.Unavailable` from the store's
226
+ error. `masks.MemoryReplayStore` is the default, and one instance can be shared
227
+ between verifiers in the same process.
228
+
229
+ ### Introspection
230
+
231
+ A verifier that checks the JWT sees a revoked token as valid until it expires.
232
+ `introspect` asks the issuer.
233
+
234
+ ```python
235
+ found = client.introspect(token, "access_token")
236
+ found.active and found.has_scope("files:read")
237
+ ```
238
+
239
+ The issuer answers `active` only to the client the token was issued to, or to
240
+ an approved client whose resources include one of the token's audiences. A
241
+ token that is not active has no scopes. An answer that names another issuer
242
+ raises `masks.Error`.
243
+
244
+ ### ASGI and FastAPI
245
+
246
+ `masks.asgi.Middleware` checks every request with an `AsyncVerifier` and
247
+ answers `401` with `WWW-Authenticate` challenges when the check fails. It
248
+ answers `503` with no challenge when the verifier raises `masks.Unavailable`.
249
+ The claims are at `request.state.masks_claims`.
250
+
251
+ ```python
252
+ from fastapi import Depends, FastAPI, HTTPException, Request
253
+ from masks.asgi import Middleware
254
+
255
+ verifier = masks.AsyncVerifier(issuer, "https://files.example.com", public_url="https://files.example.com")
256
+
257
+ app = FastAPI()
258
+ app.add_middleware(Middleware, verifier=verifier)
259
+
260
+
261
+ def reading(request: Request) -> masks.Claims:
262
+ claims = request.state.masks_claims
263
+
264
+ if not claims.has_scope("files:read"):
265
+ raise HTTPException(403)
266
+
267
+ return claims
268
+
269
+
270
+ @app.get("/files")
271
+ async def files(claims: masks.Claims = Depends(reading)):
272
+ return {"for": claims.subject}
273
+ ```
274
+
275
+ ### Django and Flask
276
+
277
+ Call `Verifier.authenticate` in a view decorator or a `before_request` hook.
278
+ In Django, pass `request.method`, `request.build_absolute_uri()`, and
279
+ `request.headers`. In Flask, pass `request.method`, `request.url`, and
280
+ `request.headers`. Answer `401` when it raises `masks.InvalidToken`, and `503`
281
+ when it raises `masks.Unavailable`.
282
+
283
+ ## Plain HTTP
284
+
285
+ Every URL is `https`, except on this machine: `localhost`, a `.localhost` name,
286
+ or a loopback address. `allow_plain_http=True` on `discover` lifts that for a
287
+ private network.