tiny-ca-gateway 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,546 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import uuid as _uuid_module
7
+
8
+ import aiofiles
9
+ from aiohttp import web
10
+ from cryptography import x509
11
+ from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
12
+ from pydantic import BaseModel, ValidationError
13
+ from tiny_ca.managers.async_lifecycle_manager import AsyncCertLifecycleManager
14
+ from tiny_ca.models.certificate import CAConfig
15
+
16
+ from tiny_ca_gateway.aiohttp.lifespan.manager import AiohttpCAManager
17
+ from tiny_ca_gateway.core import (
18
+ REASON_MAP,
19
+ CosignRequest,
20
+ CRLVerifyRequest,
21
+ ExportP12Request,
22
+ IntermediateCARequest,
23
+ IssueCertRequest,
24
+ RenewRequest,
25
+ RevokeRequest,
26
+ Routes,
27
+ VerifyRequest,
28
+ build_client_config,
29
+ get_artifact_path,
30
+ record_to_item,
31
+ )
32
+ from tiny_ca_gateway.core.helpers import load_pem_cert, load_pem_crl
33
+ from tiny_ca_gateway.models import API_SETTINGS
34
+
35
+ routes = web.RouteTableDef()
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # aiohttp-specific wrappers
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _require_factory() -> AsyncCertLifecycleManager:
44
+ mgr = AiohttpCAManager().manager
45
+ if mgr.factory is None:
46
+ raise web.HTTPServiceUnavailable(
47
+ content_type="application/json",
48
+ text='{"detail": "CA not initialised — POST /ca/root first."}',
49
+ )
50
+ return mgr
51
+
52
+
53
+ def _verify_token(request: web.Request) -> None:
54
+ expected = getattr(API_SETTINGS, "api_token", None)
55
+ if not expected:
56
+ return
57
+ auth = request.headers.get("Authorization", "")
58
+ if not auth.startswith("Bearer ") or auth[7:] != expected:
59
+ raise web.HTTPUnauthorized(
60
+ content_type="application/json",
61
+ text='{"detail": "Invalid or missing bearer token."}',
62
+ )
63
+
64
+
65
+ def _load_pem_cert(pem: str) -> x509.Certificate:
66
+ try:
67
+ return load_pem_cert(pem)
68
+ except ValueError as exc:
69
+ raise web.HTTPUnprocessableEntity(
70
+ content_type="application/json", text=json.dumps({"detail": str(exc)})
71
+ ) from exc
72
+
73
+
74
+ def _load_pem_crl(pem: str) -> x509.CertificateRevocationList:
75
+ try:
76
+ return load_pem_crl(pem)
77
+ except ValueError as exc:
78
+ raise web.HTTPUnprocessableEntity(
79
+ content_type="application/json", text=json.dumps({"detail": str(exc)})
80
+ ) from exc
81
+
82
+
83
+ async def _parse(request: web.Request, model_cls: type[BaseModel]) -> Any:
84
+ try:
85
+ return model_cls.model_validate(await request.json())
86
+ except ValidationError as exc:
87
+ raise web.HTTPUnprocessableEntity(
88
+ content_type="application/json", text=exc.json()
89
+ ) from exc
90
+ except Exception as exc:
91
+ raise web.HTTPBadRequest(
92
+ content_type="application/json",
93
+ text=json.dumps({"detail": f"Invalid JSON: {exc}"}),
94
+ ) from exc
95
+
96
+
97
+ def _resp(data: dict[str, Any] | list[Any], status: int = 200) -> web.Response:
98
+ if hasattr(data, "model_dump"):
99
+ payload = data.model_dump(mode="json")
100
+ elif isinstance(data, list):
101
+ payload = [
102
+ i.model_dump(mode="json") if hasattr(i, "model_dump") else i for i in data
103
+ ]
104
+ else:
105
+ payload = data
106
+ return web.Response(
107
+ text=json.dumps(payload, default=str),
108
+ content_type="application/json",
109
+ status=status,
110
+ )
111
+
112
+
113
+ def _err(detail: str, status: int) -> web.Response:
114
+ return web.Response(
115
+ text=json.dumps({"detail": detail}),
116
+ content_type="application/json",
117
+ status=status,
118
+ )
119
+
120
+
121
+ # ===========================================================================
122
+ # Public
123
+ # ===========================================================================
124
+
125
+
126
+ @routes.get(Routes.GET_PUBLIC_CERT)
127
+ async def get_public_cert(request: web.Request) -> web.Response:
128
+ cert_path = Path(API_SETTINGS.path_to_ca_cer)
129
+ if not cert_path.exists():
130
+ return _err("CA not bootstrapped — POST /ca/root first.", 404)
131
+ return web.Response(
132
+ body=cert_path.read_bytes(),
133
+ content_type="application/x-pem-file",
134
+ headers={"Content-Disposition": 'attachment; filename="ca.pem"'},
135
+ )
136
+
137
+
138
+ @routes.get(Routes.BASE_CRL)
139
+ async def crl_route(request: web.Request) -> web.Response:
140
+ pem_fmt = request.rel_url.query.get("pem", "false").lower() == "true"
141
+ crl_path = Path(API_SETTINGS.path_to_crl)
142
+ if not crl_path.exists():
143
+ try:
144
+ await _require_factory().generate_crl()
145
+ except Exception as exc:
146
+ return _err(str(exc), 500)
147
+ if not crl_path.exists():
148
+ return _err("CRL not found after generation.", 500)
149
+ async with aiofiles.open(crl_path, "rb") as fh:
150
+ content = await fh.read()
151
+ if pem_fmt:
152
+ if not content.startswith(b"-----"):
153
+ content = x509.load_der_x509_crl(content).public_bytes(Encoding.PEM)
154
+ return web.Response(
155
+ body=content,
156
+ content_type="application/x-pem-file",
157
+ headers={"Content-Disposition": 'attachment; filename="ca.crl.pem"'},
158
+ )
159
+ return web.Response(
160
+ body=content,
161
+ content_type="application/pkix-crl",
162
+ headers={
163
+ "Content-Disposition": 'attachment; filename="ca.crl"',
164
+ "Cache-Control": "max-age=3600",
165
+ },
166
+ )
167
+
168
+
169
+ # ===========================================================================
170
+ # List
171
+ # ===========================================================================
172
+
173
+
174
+ @routes.get("")
175
+ @routes.get("/")
176
+ async def list_certificates(request: web.Request) -> web.Response:
177
+ _verify_token(request)
178
+ q = request.rel_url.query
179
+ records = await _require_factory().list_certificates(
180
+ status=q.get("status"),
181
+ key_type=q.get("key_type"),
182
+ limit=min(int(q.get("limit", 100)), 1000),
183
+ offset=max(int(q.get("offset", 0)), 0),
184
+ )
185
+ return _resp([record_to_item(r) for r in records])
186
+
187
+
188
+ @routes.get(Routes.GET_LIST_EXPIRING_CERTS)
189
+ async def get_expiring(request: web.Request) -> web.Response:
190
+ _verify_token(request)
191
+ within_days = max(1, min(int(request.rel_url.query.get("within_days", 30)), 365))
192
+ records = await _require_factory().get_expiring_soon(within_days=within_days)
193
+ return _resp(
194
+ {
195
+ "within_days": within_days,
196
+ "count": len(records),
197
+ "certificates": [
198
+ record_to_item(r).model_dump(mode="json") for r in records
199
+ ],
200
+ }
201
+ )
202
+
203
+
204
+ # ===========================================================================
205
+ # CA bootstrap
206
+ # ===========================================================================
207
+
208
+
209
+ @routes.post(Routes.CA_ROOT)
210
+ async def create_root_ca(request: web.Request) -> web.Response:
211
+ _verify_token(request)
212
+ lm = AiohttpCAManager()
213
+ payload = await _parse(request, CAConfig)
214
+ try:
215
+ await lm.rebuild_root_ca_pair(ca_config=payload)
216
+ except Exception as exc:
217
+ return _err(str(exc), 409)
218
+ try:
219
+ await lm.rebuild_manager()
220
+ except Exception as exc:
221
+ lm.logger.error("Factory reload failed: %s", exc)
222
+ cert_obj = x509.load_pem_x509_certificate(
223
+ Path(API_SETTINGS.path_to_ca_cer).read_bytes()
224
+ )
225
+ return _resp(
226
+ {
227
+ "ca_id": "",
228
+ "expires_at": cert_obj.not_valid_after_utc.strftime(
229
+ API_SETTINGS.datetime_fmt
230
+ ),
231
+ }
232
+ )
233
+
234
+
235
+ @routes.post(Routes.CA_INTERMEDIATE)
236
+ async def issue_intermediate_ca(request: web.Request) -> web.Response:
237
+ _verify_token(request)
238
+ mgr = _require_factory()
239
+ payload = await _parse(request, IntermediateCARequest)
240
+ _uuid = str(_uuid_module.uuid4())
241
+ try:
242
+ cert, _ = await mgr.issue_intermediate_ca(
243
+ common_name=payload.common_name,
244
+ key_size=payload.key_size,
245
+ days_valid=payload.days_valid,
246
+ path_length=payload.path_length,
247
+ organization=payload.organization,
248
+ country=payload.country,
249
+ uuid_str=_uuid,
250
+ )
251
+ except Exception as exc:
252
+ return _err(str(exc), 422)
253
+ return _resp(
254
+ {
255
+ "uuid": _uuid,
256
+ "serial_number": cert.serial_number,
257
+ "common_name": payload.common_name,
258
+ "not_valid_before": cert.not_valid_before_utc.isoformat(),
259
+ "not_valid_after": cert.not_valid_after_utc.isoformat(),
260
+ }
261
+ )
262
+
263
+
264
+ @routes.post(Routes.ISSUE)
265
+ async def issue_certificate(request: web.Request) -> web.Response:
266
+ _verify_token(request)
267
+ mgr = _require_factory()
268
+ payload = await _parse(request, IssueCertRequest)
269
+ _uuid = str(_uuid_module.uuid4())
270
+ try:
271
+ cert, _, _ = await mgr.issue_certificate(
272
+ config=build_client_config(payload),
273
+ uuid_str=_uuid,
274
+ is_overwrite=payload.is_overwrite,
275
+ )
276
+ except Exception as exc:
277
+ return _err(str(exc), 409)
278
+ return _resp(
279
+ {
280
+ "uuid": _uuid,
281
+ "serial_number": cert.serial_number,
282
+ "common_name": payload.common_name,
283
+ "not_valid_before": cert.not_valid_before_utc.isoformat(),
284
+ "not_valid_after": cert.not_valid_after_utc.isoformat(),
285
+ },
286
+ status=201,
287
+ )
288
+
289
+
290
+ @routes.post(Routes.MAINTENANCE_EXPIRE)
291
+ async def mark_expired(request: web.Request) -> web.Response:
292
+ _verify_token(request)
293
+ return _resp({"updated": await _require_factory().refresh_expired_statuses()})
294
+
295
+
296
+ # ===========================================================================
297
+ # CRL
298
+ # ===========================================================================
299
+
300
+
301
+ @routes.post(Routes.CRL_REFRESH)
302
+ async def refresh_crl(request: web.Request) -> web.Response:
303
+ _verify_token(request)
304
+ try:
305
+ crl = await _require_factory().generate_crl()
306
+ except Exception as exc:
307
+ return _err(str(exc), 500)
308
+ return _resp({"next_update": crl.next_update_utc.isoformat()})
309
+
310
+
311
+ @routes.post(Routes.CRL_VERIFY)
312
+ async def verify_crl(request: web.Request) -> web.Response:
313
+ _verify_token(request)
314
+ payload = await _parse(request, CRLVerifyRequest)
315
+ crl = _load_pem_crl(payload.pem)
316
+ try:
317
+ await _require_factory().verify_crl(crl=crl)
318
+ return _resp({"valid": True, "next_update": crl.next_update_utc.isoformat()})
319
+ except Exception as exc:
320
+ return _resp({"valid": False, "detail": str(exc)})
321
+
322
+
323
+ # ===========================================================================
324
+ # Verification / cosign / export
325
+ # ===========================================================================
326
+
327
+
328
+ @routes.post(Routes.VERIFY)
329
+ async def verify_certificate(request: web.Request) -> web.Response:
330
+ _verify_token(request)
331
+ cert = _load_pem_cert((await _parse(request, VerifyRequest)).pem)
332
+ try:
333
+ await _require_factory().verify_certificate(cert=cert)
334
+ return _resp({"valid": True})
335
+ except Exception as exc:
336
+ return _resp({"valid": False, "detail": str(exc)})
337
+
338
+
339
+ @routes.post(Routes.COSIGN)
340
+ async def cosign_certificate(request: web.Request) -> web.Response:
341
+ _verify_token(request)
342
+ payload = await _parse(request, CosignRequest)
343
+ cert = _load_pem_cert(payload.pem)
344
+ try:
345
+ cosigned = await _require_factory().cosign_certificate(
346
+ cert=cert,
347
+ days_valid=payload.days_valid,
348
+ valid_from=payload.valid_from,
349
+ )
350
+ except Exception as exc:
351
+ return _err(str(exc), 422)
352
+ return _resp(
353
+ {
354
+ "serial_number": cosigned.serial_number,
355
+ "not_valid_before": cosigned.not_valid_before_utc.isoformat(),
356
+ "not_valid_after": cosigned.not_valid_after_utc.isoformat(),
357
+ "pem": cosigned.public_bytes(Encoding.PEM).decode(),
358
+ }
359
+ )
360
+
361
+
362
+ @routes.post(Routes.EXPORT)
363
+ async def export_pkcs12(request: web.Request) -> web.Response:
364
+ _verify_token(request)
365
+ serial = int(request.match_info["serial"])
366
+ mgr = _require_factory()
367
+ payload = await _parse(request, ExportP12Request)
368
+ record = await mgr._db.get_by_serial(serial=serial)
369
+ if not record:
370
+ return _err(f"Certificate serial={serial} not found", 404)
371
+ key_path = await get_artifact_path(record.uuid, "key")
372
+ if not key_path:
373
+ return _err("Private key not found in storage", 404)
374
+ async with aiofiles.open(key_path, "rb") as fh:
375
+ private_key = load_pem_private_key(await fh.read(), password=None)
376
+ p12 = await mgr.export_pkcs12(
377
+ cert=x509.load_pem_x509_certificate(record.certificate_pem.encode()),
378
+ private_key=private_key,
379
+ password=payload.password.encode() if payload.password else None,
380
+ )
381
+ fname = (record.common_name or str(serial)).replace(" ", "_")
382
+ return web.Response(
383
+ body=p12,
384
+ content_type="application/x-pkcs12",
385
+ headers={"Content-Disposition": f'attachment; filename="{fname}.p12"'},
386
+ )
387
+
388
+
389
+ # ===========================================================================
390
+ # Mutations
391
+ # ===========================================================================
392
+
393
+
394
+ @routes.patch(Routes.REVOKE)
395
+ async def revoke_certificate(request: web.Request) -> web.Response:
396
+ _verify_token(request)
397
+ payload = await _parse(request, RevokeRequest)
398
+ reason = REASON_MAP.get(payload.reason, x509.ReasonFlags.unspecified)
399
+ ok = await _require_factory().revoke_certificate(
400
+ serial=payload.serial_number, reason=reason
401
+ )
402
+ if not ok:
403
+ return _err(f"No valid certificate with serial={payload.serial_number}", 404)
404
+ return _resp({"revoked": True, "serial_number": payload.serial_number})
405
+
406
+
407
+ @routes.post(Routes.ROTATE)
408
+ async def rotate_certificate(request: web.Request) -> web.Response:
409
+ _verify_token(request)
410
+ serial = int(request.match_info["serial"])
411
+ payload = await _parse(request, IssueCertRequest)
412
+ _uuid = str(_uuid_module.uuid4())
413
+ try:
414
+ new_cert, _, _ = await _require_factory().rotate_certificate(
415
+ serial=serial, config=build_client_config(payload)
416
+ )
417
+ except Exception as exc:
418
+ return _err(str(exc), 404)
419
+ return _resp(
420
+ {
421
+ "uuid": _uuid,
422
+ "serial_number": new_cert.serial_number,
423
+ "common_name": payload.common_name,
424
+ "not_valid_before": new_cert.not_valid_before_utc.isoformat(),
425
+ "not_valid_after": new_cert.not_valid_after_utc.isoformat(),
426
+ }
427
+ )
428
+
429
+
430
+ @routes.post(Routes.RENEW)
431
+ async def renew_certificate(request: web.Request) -> web.Response:
432
+ _verify_token(request)
433
+ serial = int(request.match_info["serial"])
434
+ payload = await _parse(request, RenewRequest)
435
+ try:
436
+ renewed = await _require_factory().renew_certificate(
437
+ serial=serial, days_valid=payload.days_valid
438
+ )
439
+ except Exception as exc:
440
+ return _err(str(exc), 404)
441
+ return _resp(
442
+ {
443
+ "old_serial": serial,
444
+ "new_serial": renewed.serial_number,
445
+ "not_valid_after": renewed.not_valid_after_utc.isoformat(),
446
+ }
447
+ )
448
+
449
+
450
+ @routes.delete(Routes.DELETE)
451
+ async def delete_certificate(request: web.Request) -> web.Response:
452
+ _verify_token(request)
453
+ serial = int(request.match_info["serial"])
454
+ if not await _require_factory().delete_certificate(serial=serial):
455
+ return _err(f"Certificate serial={serial} not found", 404)
456
+ return _resp({"deleted": True, "serial": serial})
457
+
458
+
459
+ # ===========================================================================
460
+ # Inspection
461
+ # ===========================================================================
462
+
463
+
464
+ @routes.get(Routes.STATUS)
465
+ async def get_status(request: web.Request) -> web.Response:
466
+ _verify_token(request)
467
+ serial = int(request.match_info["serial"])
468
+ status = await _require_factory().get_certificate_status(serial=serial)
469
+ return _resp({"serial": serial, "status": str(status)})
470
+
471
+
472
+ @routes.get(Routes.INSPECT)
473
+ async def inspect_certificate(request: web.Request) -> web.Response:
474
+ _verify_token(request)
475
+ serial = int(request.match_info["serial"])
476
+ mgr = _require_factory()
477
+ record = await mgr._db.get_by_serial(serial=serial)
478
+ if not record:
479
+ return _err(f"Certificate serial={serial} not found", 404)
480
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
481
+ return _resp((await mgr.inspect_certificate(cert=cert)).model_dump())
482
+
483
+
484
+ @routes.get(Routes.CHAIN)
485
+ async def get_cert_chain(request: web.Request) -> web.Response:
486
+ _verify_token(request)
487
+ serial = int(request.match_info["serial"])
488
+ mgr = _require_factory()
489
+ record = await mgr._db.get_by_serial(serial=serial)
490
+ if not record:
491
+ return _err(f"Certificate serial={serial} not found", 404)
492
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
493
+ chain = await mgr.get_cert_chain(cert=cert)
494
+ return _resp(
495
+ {
496
+ "serial": serial,
497
+ "chain_length": len(chain),
498
+ "chain": [c.public_bytes(Encoding.PEM).decode() for c in chain],
499
+ }
500
+ )
501
+
502
+
503
+ # ===========================================================================
504
+ # Downloads
505
+ # ===========================================================================
506
+
507
+
508
+ @routes.get(Routes.DOWNLOAD_STREAM)
509
+ async def stream_artifact(request: web.Request) -> web.StreamResponse:
510
+ _verify_token(request)
511
+ uuid_certificate = request.match_info["uuid_certificate"]
512
+ path = await get_artifact_path(
513
+ uuid_certificate,
514
+ request.rel_url.query.get("object_type", "pem"), # type: ignore
515
+ )
516
+ if not path:
517
+ return _err("Artefact not found", 404)
518
+ response = web.StreamResponse(
519
+ headers={"Content-Disposition": f'attachment; filename="{path.name}"'}
520
+ )
521
+ response.content_type = "application/x-pem-file"
522
+ await response.prepare(request)
523
+ async with aiofiles.open(path, "rb") as fh:
524
+ while chunk := await fh.read(1024 * 1024):
525
+ await response.write(chunk)
526
+ await response.write_eof()
527
+ return response
528
+
529
+
530
+ @routes.get(Routes.DOWNLOAD)
531
+ async def download_artifact(request: web.Request) -> web.Response:
532
+ _verify_token(request)
533
+ uuid_certificate = request.match_info["uuid_certificate"]
534
+ path = await get_artifact_path(
535
+ uuid_certificate,
536
+ request.rel_url.query.get("object_type", "pem"), # type: ignore [arg-type]
537
+ )
538
+ if not path:
539
+ return _err("Artefact not found", 404)
540
+ async with aiofiles.open(path, "rb") as fh:
541
+ content = await fh.read()
542
+ return web.Response(
543
+ body=content,
544
+ content_type="application/x-pem-file",
545
+ headers={"Content-Disposition": f'attachment; filename="{path.name}"'},
546
+ )
@@ -0,0 +1,11 @@
1
+ """
2
+ tiny_ca_gateway/aiohttp/lifespan/base_manager.py
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ aiohttp CA manager.
5
+ """
6
+
7
+ from tiny_ca_gateway.core.base_manager import BaseCAManager
8
+
9
+
10
+ class AiohttpCAManager(BaseCAManager):
11
+ _logger_name = "aiohttp-ca"
@@ -0,0 +1,71 @@
1
+ from tiny_ca_gateway.core import schemas
2
+ from tiny_ca_gateway.core.base_manager import BaseCAManager
3
+ from tiny_ca_gateway.core.helpers import (
4
+ build_client_config,
5
+ get_artifact_path,
6
+ load_pem_cert,
7
+ load_pem_crl,
8
+ record_to_item,
9
+ )
10
+ from tiny_ca_gateway.core.routes import Routes
11
+ from tiny_ca_gateway.core.schemas import (
12
+ REASON_MAP,
13
+ ArtifactType,
14
+ CertListItem,
15
+ ChainResponse,
16
+ CosignRequest,
17
+ CosignResponse,
18
+ CreateRootCaResponse,
19
+ CRLRefreshResponse,
20
+ CRLVerifyRequest,
21
+ DeleteResponse,
22
+ ExpiringResponse,
23
+ ExportP12Request,
24
+ IntermediateCARequest,
25
+ IssueCertRequest,
26
+ IssueCertResponse,
27
+ MaintenanceResponse,
28
+ RenewRequest,
29
+ RenewResponse,
30
+ RevokeRequest,
31
+ RevokeResponse,
32
+ RotateResponse,
33
+ StatusResponse,
34
+ VerifyRequest,
35
+ VerifyResponse,
36
+ )
37
+
38
+ __all__ = [
39
+ "BaseCAManager",
40
+ "Routes",
41
+ "schemas",
42
+ "REASON_MAP",
43
+ "ArtifactType",
44
+ "CertListItem",
45
+ "ExpiringResponse",
46
+ "CreateRootCaResponse",
47
+ "IssueCertResponse",
48
+ "MaintenanceResponse",
49
+ "VerifyResponse",
50
+ "CRLRefreshResponse",
51
+ "RevokeResponse",
52
+ "DeleteResponse",
53
+ "StatusResponse",
54
+ "RenewResponse",
55
+ "CosignResponse",
56
+ "ChainResponse",
57
+ "RotateResponse",
58
+ "IntermediateCARequest",
59
+ "IssueCertRequest",
60
+ "CRLVerifyRequest",
61
+ "VerifyRequest",
62
+ "CosignRequest",
63
+ "ExportP12Request",
64
+ "RevokeRequest",
65
+ "RenewRequest",
66
+ "record_to_item",
67
+ "get_artifact_path",
68
+ "load_pem_cert",
69
+ "load_pem_crl",
70
+ "build_client_config",
71
+ ]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from tiny_ca_gateway.models import API_SETTINGS
4
+
5
+
6
+ def get_expected_token() -> str:
7
+ return getattr(API_SETTINGS, "api_token", "") or ""
8
+
9
+
10
+ def auth_enabled() -> bool:
11
+ return bool(get_expected_token())
12
+
13
+
14
+ def check_token(provided: str | None) -> bool:
15
+ expected = get_expected_token()
16
+ if not expected:
17
+ return True
18
+ return provided == expected
19
+
20
+
21
+ def extract_bearer(authorization_header: str | None) -> str | None:
22
+ if not authorization_header:
23
+ return None
24
+ if authorization_header.startswith("Bearer "):
25
+ return authorization_header[7:]
26
+ return None