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.
- masks_client-0.2.0/.gitignore +6 -0
- masks_client-0.2.0/CHANGELOG.md +42 -0
- masks_client-0.2.0/LICENSE +21 -0
- masks_client-0.2.0/PKG-INFO +316 -0
- masks_client-0.2.0/README.md +287 -0
- masks_client-0.2.0/TODO.md +22 -0
- masks_client-0.2.0/pyproject.toml +70 -0
- masks_client-0.2.0/src/masks/__init__.py +116 -0
- masks_client-0.2.0/src/masks/asgi.py +104 -0
- masks_client-0.2.0/src/masks/auth.py +89 -0
- masks_client-0.2.0/src/masks/client.py +1077 -0
- masks_client-0.2.0/src/masks/django/__init__.py +171 -0
- masks_client-0.2.0/src/masks/django/middleware.py +47 -0
- masks_client-0.2.0/src/masks/django/urls.py +66 -0
- masks_client-0.2.0/src/masks/dpop.py +56 -0
- masks_client-0.2.0/src/masks/errors.py +80 -0
- masks_client-0.2.0/src/masks/fastapi.py +248 -0
- masks_client-0.2.0/src/masks/flask.py +226 -0
- masks_client-0.2.0/src/masks/flows.py +282 -0
- masks_client-0.2.0/src/masks/handshake.py +157 -0
- masks_client-0.2.0/src/masks/keys.py +99 -0
- masks_client-0.2.0/src/masks/keyset.py +113 -0
- masks_client-0.2.0/src/masks/models.py +328 -0
- masks_client-0.2.0/src/masks/oidc.py +131 -0
- masks_client-0.2.0/src/masks/py.typed +0 -0
- masks_client-0.2.0/src/masks/replay.py +37 -0
- masks_client-0.2.0/src/masks/sources.py +54 -0
- masks_client-0.2.0/src/masks/verifier.py +308 -0
- masks_client-0.2.0/src/masks/web/__init__.py +36 -0
- masks_client-0.2.0/src/masks/web/config.py +98 -0
- masks_client-0.2.0/src/masks/web/core.py +1421 -0
- masks_client-0.2.0/src/masks/web/credentials.py +128 -0
- masks_client-0.2.0/src/masks/web/person.py +239 -0
- masks_client-0.2.0/src/masks/web/sessions.py +391 -0
- masks_client-0.2.0/tests/__init__.py +0 -0
- masks_client-0.2.0/tests/conftest.py +42 -0
- masks_client-0.2.0/tests/django_app.py +52 -0
- masks_client-0.2.0/tests/fake.py +769 -0
- masks_client-0.2.0/tests/scenarios.py +387 -0
- masks_client-0.2.0/tests/test_client.py +592 -0
- masks_client-0.2.0/tests/test_django.py +95 -0
- masks_client-0.2.0/tests/test_fastapi.py +752 -0
- masks_client-0.2.0/tests/test_flask.py +133 -0
- masks_client-0.2.0/tests/test_resource.py +574 -0
- masks_client-0.2.0/tests/test_signin.py +357 -0
- masks_client-0.2.0/uv.lock +1224 -0
|
@@ -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.
|