libb-util 0.0.44__tar.gz → 0.0.45__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.
- {libb_util-0.0.44 → libb_util-0.0.45}/PKG-INFO +1 -1
- {libb_util-0.0.44 → libb_util-0.0.45}/pyproject.toml +2 -2
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/Cargo.lock +20 -20
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/__init__.py +1 -1
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/tokenauth.py +105 -41
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/webapp.py +17 -13
- {libb_util-0.0.44 → libb_util-0.0.45}/LICENSE +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/README.md +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/Cargo.toml +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/LICENSE +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/dictsort.rs +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/iter.rs +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/lib.rs +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/numparse.rs +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/python.rs +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/text.rs +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/_docs.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/_rust.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/attrdict.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/chart.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/classes.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/config.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/crypto.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/dicts.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/dir.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/exception.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/format.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/func.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/future.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/geo.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/heap.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/iolib.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/iter.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/ldapauth.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/mime.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/mime.types +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/module.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/orderedset.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/pandasutils.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/path.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/proc.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/rand.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/signals.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/stats.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/stream.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/sync.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/text.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/thread.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/typedefs.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/util.py +0 -0
- {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/win.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "libb-util"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.45"
|
|
4
4
|
readme = "README.md"
|
|
5
5
|
description = "libb utility modules for python"
|
|
6
6
|
authors = [{name = "bissli", email = "bissli@xyz.org"}]
|
|
@@ -45,7 +45,7 @@ libb-tokenauth = "libb.tokenauth:run_cli"
|
|
|
45
45
|
|
|
46
46
|
[tool.poetry]
|
|
47
47
|
name = "libb-util"
|
|
48
|
-
version = "0.0.
|
|
48
|
+
version = "0.0.45"
|
|
49
49
|
readme = "README.md"
|
|
50
50
|
description = "libb utility modules for python"
|
|
51
51
|
authors = ["bissli"]
|
|
@@ -19,9 +19,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
|
19
19
|
|
|
20
20
|
[[package]]
|
|
21
21
|
name = "cc"
|
|
22
|
-
version = "1.
|
|
22
|
+
version = "1.4.0"
|
|
23
23
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
24
|
-
checksum = "
|
|
24
|
+
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
|
25
25
|
dependencies = [
|
|
26
26
|
"find-msvc-tools",
|
|
27
27
|
"shlex",
|
|
@@ -65,15 +65,15 @@ dependencies = [
|
|
|
65
65
|
|
|
66
66
|
[[package]]
|
|
67
67
|
name = "libc"
|
|
68
|
-
version = "0.2.
|
|
68
|
+
version = "0.2.189"
|
|
69
69
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
70
|
-
checksum = "
|
|
70
|
+
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
71
71
|
|
|
72
72
|
[[package]]
|
|
73
73
|
name = "memchr"
|
|
74
|
-
version = "2.8.
|
|
74
|
+
version = "2.8.3"
|
|
75
75
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
76
|
-
checksum = "
|
|
76
|
+
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
77
77
|
|
|
78
78
|
[[package]]
|
|
79
79
|
name = "memoffset"
|
|
@@ -92,15 +92,15 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
92
92
|
|
|
93
93
|
[[package]]
|
|
94
94
|
name = "portable-atomic"
|
|
95
|
-
version = "1.
|
|
95
|
+
version = "1.14.0"
|
|
96
96
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
97
|
-
checksum = "
|
|
97
|
+
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
|
98
98
|
|
|
99
99
|
[[package]]
|
|
100
100
|
name = "proc-macro2"
|
|
101
|
-
version = "1.0.
|
|
101
|
+
version = "1.0.107"
|
|
102
102
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
103
|
-
checksum = "
|
|
103
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
104
104
|
dependencies = [
|
|
105
105
|
"unicode-ident",
|
|
106
106
|
]
|
|
@@ -180,18 +180,18 @@ dependencies = [
|
|
|
180
180
|
|
|
181
181
|
[[package]]
|
|
182
182
|
name = "quote"
|
|
183
|
-
version = "1.0.
|
|
183
|
+
version = "1.0.47"
|
|
184
184
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
185
|
-
checksum = "
|
|
185
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
186
186
|
dependencies = [
|
|
187
187
|
"proc-macro2",
|
|
188
188
|
]
|
|
189
189
|
|
|
190
190
|
[[package]]
|
|
191
191
|
name = "regex"
|
|
192
|
-
version = "1.
|
|
192
|
+
version = "1.13.1"
|
|
193
193
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
194
|
-
checksum = "
|
|
194
|
+
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
|
195
195
|
dependencies = [
|
|
196
196
|
"aho-corasick",
|
|
197
197
|
"memchr",
|
|
@@ -201,9 +201,9 @@ dependencies = [
|
|
|
201
201
|
|
|
202
202
|
[[package]]
|
|
203
203
|
name = "regex-automata"
|
|
204
|
-
version = "0.4.
|
|
204
|
+
version = "0.4.16"
|
|
205
205
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
206
|
-
checksum = "
|
|
206
|
+
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
|
207
207
|
dependencies = [
|
|
208
208
|
"aho-corasick",
|
|
209
209
|
"memchr",
|
|
@@ -218,9 +218,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|
|
218
218
|
|
|
219
219
|
[[package]]
|
|
220
220
|
name = "rustversion"
|
|
221
|
-
version = "1.0.
|
|
221
|
+
version = "1.0.23"
|
|
222
222
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
223
|
-
checksum = "
|
|
223
|
+
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
224
224
|
|
|
225
225
|
[[package]]
|
|
226
226
|
name = "shlex"
|
|
@@ -230,9 +230,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
|
230
230
|
|
|
231
231
|
[[package]]
|
|
232
232
|
name = "syn"
|
|
233
|
-
version = "2.0.
|
|
233
|
+
version = "2.0.119"
|
|
234
234
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
235
|
-
checksum = "
|
|
235
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
236
236
|
dependencies = [
|
|
237
237
|
"proc-macro2",
|
|
238
238
|
"quote",
|
|
@@ -15,7 +15,7 @@ Layers, low to high:
|
|
|
15
15
|
- :func:`verify_token` -- authorize a presented key: static break-glass
|
|
16
16
|
token (constant time) then the registry, failing closed.
|
|
17
17
|
- :class:`ApiTokenMiddleware` -- a raw-ASGI gate that runs a
|
|
18
|
-
``(presented) ->
|
|
18
|
+
``(presented) -> str | None`` verifier (typically
|
|
19
19
|
``functools.partial(verify_token, ...)``) for chosen path prefixes.
|
|
20
20
|
- :func:`mint_key` / :func:`revoke_key` / :func:`list_clients` and the
|
|
21
21
|
``libb-tokenauth`` CLI (:func:`run_cli`) -- provisioning.
|
|
@@ -23,6 +23,18 @@ Layers, low to high:
|
|
|
23
23
|
Both :func:`verify_token` and :class:`ApiTokenMiddleware` independently
|
|
24
24
|
fail closed; used together that is deliberate defense-in-depth.
|
|
25
25
|
|
|
26
|
+
The authorization layers return the *identity* they authorized -- a
|
|
27
|
+
client_id string, or None when denied -- rather than a bare bool, and
|
|
28
|
+
:class:`ApiTokenMiddleware` publishes it at ``scope['state']['client_id']``
|
|
29
|
+
so a downstream handler can log which human made the call. Returning an
|
|
30
|
+
identity keeps the truthiness contract of the old bool, so ``if
|
|
31
|
+
verify_token(...)`` still reads correctly.
|
|
32
|
+
|
|
33
|
+
A credential is read from the ``X-API-Key`` or ``Authorization: Bearer``
|
|
34
|
+
header only. A ``?key=`` query parameter is deliberately not accepted:
|
|
35
|
+
query strings are recorded verbatim by proxy and web-server access logs,
|
|
36
|
+
so that form turns every request into a credential disclosure.
|
|
37
|
+
|
|
26
38
|
Expected table shape::
|
|
27
39
|
|
|
28
40
|
client_id (S) -- partition key
|
|
@@ -39,12 +51,13 @@ import logging
|
|
|
39
51
|
import secrets
|
|
40
52
|
from collections.abc import Callable, Iterable
|
|
41
53
|
from typing import Any, Literal, NamedTuple
|
|
42
|
-
from urllib.parse import parse_qs
|
|
43
54
|
|
|
44
55
|
logger = logging.getLogger(__name__)
|
|
45
56
|
|
|
46
57
|
__all__ = [
|
|
47
58
|
'KEY_SHA256_INDEX',
|
|
59
|
+
'STATIC_TOKEN_CLIENT_ID',
|
|
60
|
+
'UNKNOWN_CLIENT_ID',
|
|
48
61
|
'ClientExistsError',
|
|
49
62
|
'ClientNotFoundError',
|
|
50
63
|
'ClientRecord',
|
|
@@ -59,6 +72,28 @@ __all__ = [
|
|
|
59
72
|
|
|
60
73
|
KEY_SHA256_INDEX = 'key_sha256-index'
|
|
61
74
|
|
|
75
|
+
STATIC_TOKEN_CLIENT_ID = 'static-token'
|
|
76
|
+
|
|
77
|
+
UNKNOWN_CLIENT_ID = 'unknown'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _as_client_id(result: Any) -> str | None:
|
|
81
|
+
"""Normalize an authorizer result to a client_id or None.
|
|
82
|
+
|
|
83
|
+
Keeps the identity published downstream typed as ``str | None`` even
|
|
84
|
+
when a caller supplies a pre-existing ``-> bool`` callable, so a raw
|
|
85
|
+
``True`` can never reach a log line posing as a client_id.
|
|
86
|
+
|
|
87
|
+
:param result: Whatever an injected ``registry_check`` or ``verify``
|
|
88
|
+
returned.
|
|
89
|
+
:returns: The string unchanged when non-empty; ``UNKNOWN_CLIENT_ID``
|
|
90
|
+
for a truthy non-string (a legacy bool-returning callable); None
|
|
91
|
+
for anything falsy.
|
|
92
|
+
"""
|
|
93
|
+
if isinstance(result, str):
|
|
94
|
+
return result or None
|
|
95
|
+
return UNKNOWN_CLIENT_ID if result else None
|
|
96
|
+
|
|
62
97
|
|
|
63
98
|
class ClientRecord(NamedTuple):
|
|
64
99
|
"""A registry client row: id, status, and creation time."""
|
|
@@ -109,18 +144,23 @@ def key_active_in_registry(
|
|
|
109
144
|
table: str,
|
|
110
145
|
region: str | None = None,
|
|
111
146
|
dynamodb_client: Any = None,
|
|
112
|
-
) ->
|
|
113
|
-
"""Return
|
|
147
|
+
) -> str | None:
|
|
148
|
+
"""Return the client_id a hashed key maps to, if that client is active.
|
|
114
149
|
|
|
115
|
-
Queries the ``key_sha256-index`` GSI for a single match and reports
|
|
116
|
-
|
|
117
|
-
callers decide both.
|
|
150
|
+
Queries the ``key_sha256-index`` GSI for a single match and reports the
|
|
151
|
+
matched client's identity. Does not catch errors and does not cache --
|
|
152
|
+
callers decide both. The GSI query already returns the whole row, so
|
|
153
|
+
surfacing the identity costs nothing over the previous boolean and is
|
|
154
|
+
what lets a caller attribute a request to a named client. An active row
|
|
155
|
+
whose ``client_id`` is missing or empty denies, rather than authorizing
|
|
156
|
+
an unattributable caller.
|
|
118
157
|
|
|
119
158
|
:param key_sha256: SHA-256 hex digest of the presented key.
|
|
120
159
|
:param table: DynamoDB registry table name.
|
|
121
160
|
:param region: AWS region for a default boto3 client (optional).
|
|
122
161
|
:param dynamodb_client: Injected boto3 DynamoDB client (optional).
|
|
123
|
-
:returns:
|
|
162
|
+
:returns: The matched ``client_id`` when the item exists and is
|
|
163
|
+
active, else None.
|
|
124
164
|
"""
|
|
125
165
|
client = _dynamodb_client(dynamodb_client, region)
|
|
126
166
|
response = client.query(
|
|
@@ -132,8 +172,10 @@ def key_active_in_registry(
|
|
|
132
172
|
)
|
|
133
173
|
items = response.get('Items', [])
|
|
134
174
|
if not items:
|
|
135
|
-
return
|
|
136
|
-
|
|
175
|
+
return None
|
|
176
|
+
if not items[0].get('active', {}).get('BOOL', False):
|
|
177
|
+
return None
|
|
178
|
+
return items[0].get('client_id', {}).get('S', '') or None
|
|
137
179
|
|
|
138
180
|
|
|
139
181
|
def verify_token(
|
|
@@ -143,9 +185,9 @@ def verify_token(
|
|
|
143
185
|
static_token: str | None = None,
|
|
144
186
|
region: str | None = None,
|
|
145
187
|
dynamodb_client: Any = None,
|
|
146
|
-
registry_check: Callable[[str],
|
|
147
|
-
) ->
|
|
148
|
-
"""Authorize a presented key, failing closed.
|
|
188
|
+
registry_check: Callable[[str], str | None] | None = None,
|
|
189
|
+
) -> str | None:
|
|
190
|
+
"""Authorize a presented key and return the identity, failing closed.
|
|
149
191
|
|
|
150
192
|
The static break-glass token is checked first (constant time). The
|
|
151
193
|
registry is then consulted via ``registry_check`` if given, else via a
|
|
@@ -158,6 +200,9 @@ def verify_token(
|
|
|
158
200
|
To bind this to :class:`ApiTokenMiddleware`, wrap it with
|
|
159
201
|
``functools.partial(verify_token, static_token=..., registry_check=...)``.
|
|
160
202
|
|
|
203
|
+
The returned identity is never the presented key: a credential must
|
|
204
|
+
not reach the log line that records who called.
|
|
205
|
+
|
|
161
206
|
:param presented: The raw key presented by the client.
|
|
162
207
|
:param table: DynamoDB registry table name (optional). Used to build
|
|
163
208
|
the default registry lookup when ``registry_check`` is not given.
|
|
@@ -165,28 +210,31 @@ def verify_token(
|
|
|
165
210
|
:param region: AWS region for a default boto3 client (optional).
|
|
166
211
|
:param dynamodb_client: Injected boto3 DynamoDB client (optional).
|
|
167
212
|
:param registry_check: Lookup called with the SHA-256 *digest* of the
|
|
168
|
-
key (not the raw key) -- ``(key_sha256) ->
|
|
169
|
-
default :func:`key_active_in_registry` call. This is
|
|
170
|
-
cached lookup: wrap :func:`key_active_in_registry`
|
|
171
|
-
and pass it here. When given, ``table``/``region``/
|
|
213
|
+
key (not the raw key) -- ``(key_sha256) -> str | None`` --
|
|
214
|
+
replacing the default :func:`key_active_in_registry` call. This is
|
|
215
|
+
the seam for a cached lookup: wrap :func:`key_active_in_registry`
|
|
216
|
+
in a TTL cache and pass it here. When given, ``table``/``region``/
|
|
172
217
|
``dynamodb_client`` are not used.
|
|
173
|
-
:returns:
|
|
218
|
+
:returns: The authorized client identity, else None. A static-token
|
|
219
|
+
match yields ``STATIC_TOKEN_CLIENT_ID`` so break-glass use is
|
|
220
|
+
distinguishable in a log from a registered client; a legacy
|
|
221
|
+
``-> bool`` ``registry_check`` yields ``UNKNOWN_CLIENT_ID``.
|
|
174
222
|
"""
|
|
175
223
|
if not presented:
|
|
176
|
-
return
|
|
224
|
+
return None
|
|
177
225
|
if static_token and secrets.compare_digest(presented, static_token):
|
|
178
|
-
return
|
|
226
|
+
return STATIC_TOKEN_CLIENT_ID
|
|
179
227
|
if registry_check is None and not table:
|
|
180
|
-
return
|
|
228
|
+
return None
|
|
181
229
|
try:
|
|
182
230
|
if registry_check is not None:
|
|
183
|
-
return registry_check(hash_key(presented))
|
|
231
|
+
return _as_client_id(registry_check(hash_key(presented)))
|
|
184
232
|
return key_active_in_registry(
|
|
185
233
|
hash_key(presented), table=table, region=region,
|
|
186
234
|
dynamodb_client=dynamodb_client)
|
|
187
235
|
except Exception as exc:
|
|
188
236
|
logger.warning('token registry lookup failed; denying (fail closed): %s', exc)
|
|
189
|
-
return
|
|
237
|
+
return None
|
|
190
238
|
|
|
191
239
|
|
|
192
240
|
def mint_key(
|
|
@@ -297,19 +345,19 @@ def list_clients(
|
|
|
297
345
|
class ApiTokenMiddleware:
|
|
298
346
|
"""Raw ASGI middleware gating chosen path prefixes on a token.
|
|
299
347
|
|
|
300
|
-
HTTP requests under ``protected_prefixes`` must present a key
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
its config::
|
|
348
|
+
HTTP requests under ``protected_prefixes`` must present a key -- in the
|
|
349
|
+
``X-API-Key`` or ``Authorization: Bearer`` header -- that ``verify``
|
|
350
|
+
authorizes; everything else passes through. ``verify`` is any
|
|
351
|
+
``(presented) -> str | None`` -- typically :func:`verify_token` bound
|
|
352
|
+
to its config::
|
|
305
353
|
|
|
306
354
|
verify = functools.partial(
|
|
307
355
|
verify_token, static_token=TOKEN, registry_check=cached_lookup)
|
|
308
356
|
app.add_middleware(
|
|
309
357
|
ApiTokenMiddleware, protected_prefixes=('/api/',), verify=verify)
|
|
310
358
|
|
|
311
|
-
The verifier runs in a worker thread and fails closed -- returning
|
|
312
|
-
|
|
359
|
+
The verifier runs in a worker thread and fails closed -- returning a
|
|
360
|
+
falsy value or raising yields a 401.
|
|
313
361
|
|
|
314
362
|
Raw ASGI, not ``BaseHTTPMiddleware``, so it never buffers a streaming
|
|
315
363
|
body and depends on no web framework; only ``anyio`` is imported
|
|
@@ -317,9 +365,17 @@ class ApiTokenMiddleware:
|
|
|
317
365
|
gated -- ``websocket`` connections pass through, so do not place a
|
|
318
366
|
WebSocket endpoint under a protected prefix expecting it to be gated.
|
|
319
367
|
|
|
368
|
+
On success the authorized identity is published at
|
|
369
|
+
``scope['state']['client_id']`` before the wrapped app runs, so a
|
|
370
|
+
downstream handler can attribute the request without re-reading the
|
|
371
|
+
credential. A legacy ``-> bool`` verifier yields ``UNKNOWN_CLIENT_ID``
|
|
372
|
+
there rather than a bare ``True``. A ``?key=`` query parameter is not
|
|
373
|
+
an accepted credential source; see the module docstring.
|
|
374
|
+
|
|
320
375
|
:param app: The wrapped ASGI application.
|
|
321
376
|
:param protected_prefixes: Path prefixes that require a token.
|
|
322
|
-
:param verify: ``(presented) ->
|
|
377
|
+
:param verify: ``(presented) -> str | None`` authorizer returning the
|
|
378
|
+
identity.
|
|
323
379
|
"""
|
|
324
380
|
|
|
325
381
|
def __init__(
|
|
@@ -327,7 +383,7 @@ class ApiTokenMiddleware:
|
|
|
327
383
|
app: Any,
|
|
328
384
|
*,
|
|
329
385
|
protected_prefixes: Iterable[str],
|
|
330
|
-
verify: Callable[[str],
|
|
386
|
+
verify: Callable[[str], str | None],
|
|
331
387
|
) -> None:
|
|
332
388
|
"""Wrap an ASGI app with the configured credential check."""
|
|
333
389
|
self.app = app
|
|
@@ -343,16 +399,18 @@ class ApiTokenMiddleware:
|
|
|
343
399
|
return
|
|
344
400
|
|
|
345
401
|
presented = self._present_key(scope)
|
|
346
|
-
|
|
402
|
+
client_id = None
|
|
347
403
|
if presented:
|
|
348
404
|
try:
|
|
349
|
-
|
|
405
|
+
client_id = _as_client_id(
|
|
406
|
+
await anyio.to_thread.run_sync(self._verify, presented))
|
|
350
407
|
except Exception as exc:
|
|
351
408
|
logger.warning('authorization raised; denying (fail closed): %s', exc)
|
|
352
|
-
if not
|
|
409
|
+
if not client_id:
|
|
353
410
|
await _send_unauthorized(send)
|
|
354
411
|
return
|
|
355
412
|
|
|
413
|
+
scope.setdefault('state', {})['client_id'] = client_id
|
|
356
414
|
await self.app(scope, receive, send)
|
|
357
415
|
|
|
358
416
|
def _guards(self, scope: dict) -> bool:
|
|
@@ -363,7 +421,16 @@ class ApiTokenMiddleware:
|
|
|
363
421
|
|
|
364
422
|
@staticmethod
|
|
365
423
|
def _present_key(scope: dict) -> str | None:
|
|
366
|
-
"""Pull the key from the X-API-Key
|
|
424
|
+
"""Pull the key from the X-API-Key or Bearer header, header-only.
|
|
425
|
+
|
|
426
|
+
The query string is never consulted. A credential in a URL is
|
|
427
|
+
written verbatim into proxy, load-balancer and web-server access
|
|
428
|
+
logs, so accepting ``?key=`` makes every request a disclosure.
|
|
429
|
+
|
|
430
|
+
:param scope: The ASGI HTTP connection scope.
|
|
431
|
+
:returns: The presented credential, or None when neither header
|
|
432
|
+
carries one.
|
|
433
|
+
"""
|
|
367
434
|
headers = {
|
|
368
435
|
key.decode('latin-1').lower(): value.decode('latin-1')
|
|
369
436
|
for key, value in scope.get('headers', [])
|
|
@@ -372,11 +439,8 @@ class ApiTokenMiddleware:
|
|
|
372
439
|
return headers['x-api-key']
|
|
373
440
|
authorization = headers.get('authorization', '')
|
|
374
441
|
if authorization.lower().startswith('bearer '):
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return bearer
|
|
378
|
-
query = parse_qs(scope.get('query_string', b'').decode('latin-1'))
|
|
379
|
-
return (query.get('key') or [None])[0]
|
|
442
|
+
return authorization[7:] or None
|
|
443
|
+
return None
|
|
380
444
|
|
|
381
445
|
|
|
382
446
|
async def _send_unauthorized(send: Any) -> None:
|
|
@@ -213,14 +213,14 @@ def login_required(*, allowed_groups, session_accessor=None,
|
|
|
213
213
|
return decorator
|
|
214
214
|
|
|
215
215
|
|
|
216
|
-
def _extract_presented_key(
|
|
216
|
+
def _extract_presented_key() -> str:
|
|
217
217
|
"""Pull a presented API key from the current Flask request.
|
|
218
218
|
|
|
219
|
-
Order: ``Authorization: Bearer <k>``, then ``X-Api-Key
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
Order: ``Authorization: Bearer <k>``, then ``X-Api-Key``. The query
|
|
220
|
+
string is never consulted -- a credential in a URL is written verbatim
|
|
221
|
+
into proxy, load-balancer and web-server access logs, so accepting
|
|
222
|
+
``?key=`` turns every request into a credential disclosure.
|
|
222
223
|
|
|
223
|
-
:param allow_query_key: Accept the key from the ?key query arg.
|
|
224
224
|
:returns: The presented key, or '' if none.
|
|
225
225
|
"""
|
|
226
226
|
header = flask.request.headers.get('Authorization', '')
|
|
@@ -229,13 +229,11 @@ def _extract_presented_key(allow_query_key: bool = False) -> str:
|
|
|
229
229
|
api_key = flask.request.headers.get('X-Api-Key')
|
|
230
230
|
if api_key:
|
|
231
231
|
return api_key.strip()
|
|
232
|
-
if allow_query_key:
|
|
233
|
-
return (flask.request.args.get('key') or '').strip()
|
|
234
232
|
return ''
|
|
235
233
|
|
|
236
234
|
|
|
237
235
|
def token_required(*, table=None, static_token=None, region=None,
|
|
238
|
-
dynamodb_client=None
|
|
236
|
+
dynamodb_client=None):
|
|
239
237
|
"""Decorator: gate a Flask view on a registry token, failing closed.
|
|
240
238
|
|
|
241
239
|
Extracts the key from the request (see :func:`_extract_presented_key`)
|
|
@@ -243,11 +241,15 @@ def token_required(*, table=None, static_token=None, region=None,
|
|
|
243
241
|
unauthorized request gets a 401. Suited to machine endpoints
|
|
244
242
|
(MCP / API) behind the standard Flask tier.
|
|
245
243
|
|
|
244
|
+
On success the authorized identity is published at ``flask.g.client_id``
|
|
245
|
+
before the view runs, so a view or an after-request logger can record
|
|
246
|
+
which client called without re-reading the credential. This mirrors
|
|
247
|
+
``scope['state']['client_id']`` on the ASGI side.
|
|
248
|
+
|
|
246
249
|
:param table: DynamoDB registry table name (optional).
|
|
247
250
|
:param static_token: Constant-time break-glass token (optional).
|
|
248
251
|
:param region: AWS region for a default boto3 client (optional).
|
|
249
252
|
:param dynamodb_client: Injected boto3 DynamoDB client (optional).
|
|
250
|
-
:param allow_query_key: Accept the key from the ?key query arg.
|
|
251
253
|
:returns: A view decorator.
|
|
252
254
|
"""
|
|
253
255
|
from libb import tokenauth
|
|
@@ -255,11 +257,13 @@ def token_required(*, table=None, static_token=None, region=None,
|
|
|
255
257
|
def decorator(view):
|
|
256
258
|
@wraps(view)
|
|
257
259
|
def token_required_fn(*args, **kwargs):
|
|
258
|
-
presented = _extract_presented_key(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
presented = _extract_presented_key()
|
|
261
|
+
client_id = tokenauth.verify_token(
|
|
262
|
+
presented, table=table, static_token=static_token,
|
|
263
|
+
region=region, dynamodb_client=dynamodb_client)
|
|
264
|
+
if not client_id:
|
|
262
265
|
return flask.abort(401)
|
|
266
|
+
flask.g.client_id = client_id
|
|
263
267
|
return view(*args, **kwargs)
|
|
264
268
|
|
|
265
269
|
return token_required_fn
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|