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.
Files changed (51) hide show
  1. {libb_util-0.0.44 → libb_util-0.0.45}/PKG-INFO +1 -1
  2. {libb_util-0.0.44 → libb_util-0.0.45}/pyproject.toml +2 -2
  3. {libb_util-0.0.44 → libb_util-0.0.45}/rust/Cargo.lock +20 -20
  4. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/__init__.py +1 -1
  5. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/tokenauth.py +105 -41
  6. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/webapp.py +17 -13
  7. {libb_util-0.0.44 → libb_util-0.0.45}/LICENSE +0 -0
  8. {libb_util-0.0.44 → libb_util-0.0.45}/README.md +0 -0
  9. {libb_util-0.0.44 → libb_util-0.0.45}/rust/Cargo.toml +0 -0
  10. {libb_util-0.0.44 → libb_util-0.0.45}/rust/LICENSE +0 -0
  11. {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/dictsort.rs +0 -0
  12. {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/iter.rs +0 -0
  13. {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/lib.rs +0 -0
  14. {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/numparse.rs +0 -0
  15. {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/python.rs +0 -0
  16. {libb_util-0.0.44 → libb_util-0.0.45}/rust/src/text.rs +0 -0
  17. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/_docs.py +0 -0
  18. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/_rust.py +0 -0
  19. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/attrdict.py +0 -0
  20. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/chart.py +0 -0
  21. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/classes.py +0 -0
  22. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/config.py +0 -0
  23. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/crypto.py +0 -0
  24. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/dicts.py +0 -0
  25. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/dir.py +0 -0
  26. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/exception.py +0 -0
  27. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/format.py +0 -0
  28. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/func.py +0 -0
  29. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/future.py +0 -0
  30. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/geo.py +0 -0
  31. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/heap.py +0 -0
  32. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/iolib.py +0 -0
  33. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/iter.py +0 -0
  34. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/ldapauth.py +0 -0
  35. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/mime.py +0 -0
  36. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/mime.types +0 -0
  37. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/module.py +0 -0
  38. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/orderedset.py +0 -0
  39. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/pandasutils.py +0 -0
  40. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/path.py +0 -0
  41. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/proc.py +0 -0
  42. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/rand.py +0 -0
  43. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/signals.py +0 -0
  44. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/stats.py +0 -0
  45. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/stream.py +0 -0
  46. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/sync.py +0 -0
  47. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/text.py +0 -0
  48. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/thread.py +0 -0
  49. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/typedefs.py +0 -0
  50. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/util.py +0 -0
  51. {libb_util-0.0.44 → libb_util-0.0.45}/src/libb/win.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libb-util
3
- Version: 0.0.44
3
+ Version: 0.0.45
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.10
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "libb-util"
3
- version = "0.0.44"
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.44"
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.2.64"
22
+ version = "1.4.0"
23
23
  source = "registry+https://github.com/rust-lang/crates.io-index"
24
- checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
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.186"
68
+ version = "0.2.189"
69
69
  source = "registry+https://github.com/rust-lang/crates.io-index"
70
- checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
70
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
71
71
 
72
72
  [[package]]
73
73
  name = "memchr"
74
- version = "2.8.2"
74
+ version = "2.8.3"
75
75
  source = "registry+https://github.com/rust-lang/crates.io-index"
76
- checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
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.13.1"
95
+ version = "1.14.0"
96
96
  source = "registry+https://github.com/rust-lang/crates.io-index"
97
- checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
97
+ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
98
98
 
99
99
  [[package]]
100
100
  name = "proc-macro2"
101
- version = "1.0.106"
101
+ version = "1.0.107"
102
102
  source = "registry+https://github.com/rust-lang/crates.io-index"
103
- checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
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.45"
183
+ version = "1.0.47"
184
184
  source = "registry+https://github.com/rust-lang/crates.io-index"
185
- checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
185
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
186
186
  dependencies = [
187
187
  "proc-macro2",
188
188
  ]
189
189
 
190
190
  [[package]]
191
191
  name = "regex"
192
- version = "1.12.4"
192
+ version = "1.13.1"
193
193
  source = "registry+https://github.com/rust-lang/crates.io-index"
194
- checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
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.14"
204
+ version = "0.4.16"
205
205
  source = "registry+https://github.com/rust-lang/crates.io-index"
206
- checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
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.22"
221
+ version = "1.0.23"
222
222
  source = "registry+https://github.com/rust-lang/crates.io-index"
223
- checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
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.117"
233
+ version = "2.0.119"
234
234
  source = "registry+https://github.com/rust-lang/crates.io-index"
235
- checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
235
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
236
236
  dependencies = [
237
237
  "proc-macro2",
238
238
  "quote",
@@ -1,4 +1,4 @@
1
- __version__ = '0.0.44'
1
+ __version__ = '0.0.45'
2
2
 
3
3
  # public
4
4
  version = __version__
@@ -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) -> bool`` verifier (typically
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
- ) -> bool:
113
- """Return True iff a hashed key maps to an active client.
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
- its ``active`` flag. Does not catch errors and does not cache --
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: True if a matching client item is active.
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 False
136
- return items[0].get('active', {}).get('BOOL', False)
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], bool] | None = None,
147
- ) -> bool:
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) -> bool`` -- replacing the
169
- default :func:`key_active_in_registry` call. This is the seam for a
170
- cached lookup: wrap :func:`key_active_in_registry` in a TTL cache
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: True if the presented key is authorized.
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 False
224
+ return None
177
225
  if static_token and secrets.compare_digest(presented, static_token):
178
- return True
226
+ return STATIC_TOKEN_CLIENT_ID
179
227
  if registry_check is None and not table:
180
- return False
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 False
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
- (``X-API-Key`` header, ``Authorization: Bearer``, or ``?key=``) that
302
- ``verify`` authorizes; everything else passes through. ``verify`` is
303
- any ``(presented) -> bool`` -- typically :func:`verify_token` bound to
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
- False or raising yields a 401.
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) -> bool`` authorizer.
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], bool],
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
- authorized = False
402
+ client_id = None
347
403
  if presented:
348
404
  try:
349
- authorized = await anyio.to_thread.run_sync(self._verify, presented)
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 authorized:
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, Bearer, or ?key= sources."""
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
- bearer = authorization[7:]
376
- if bearer:
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(allow_query_key: bool = False) -> str:
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``, then the
220
- ``?key=`` query arg when ``allow_query_key`` is set (off by default;
221
- query strings leak into logs).
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, allow_query_key=False):
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(allow_query_key)
259
- if not tokenauth.verify_token(
260
- presented, table=table, static_token=static_token,
261
- region=region, dynamodb_client=dynamodb_client):
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