1+ import asyncio
12import base64
23import binascii
4+ import concurrent .futures
35import json
6+ import multiprocessing
47import time
58from json import JSONDecodeError
6- from typing import Annotated , cast
9+ from typing import Annotated , Any , cast
710
811import svcs
912from argon2 import PasswordHasher
1619
1720hasher = PasswordHasher ()
1821
22+ VERIFY_POOL_WORKERS = 10
23+
24+ # argon2 verification is CPU-bound and holds the GIL, so running it on the
25+ # event loop (or in a thread) stalls every other request on the worker. Offload
26+ # it to a process pool, which has independent GILs. "spawn" avoids forking a
27+ # process that already runs the asyncio loop and aiosqlite threads.
28+ verify_pool : concurrent .futures .ProcessPoolExecutor | None = None
29+
30+
31+ def start_verify_pool () -> None :
32+ global verify_pool
33+ verify_pool = concurrent .futures .ProcessPoolExecutor (
34+ max_workers = VERIFY_POOL_WORKERS ,
35+ mp_context = multiprocessing .get_context ("spawn" ),
36+ )
37+
38+
39+ def stop_verify_pool () -> None :
40+ global verify_pool
41+ if verify_pool is None :
42+ return
43+ verify_pool .shutdown (wait = False , cancel_futures = True )
44+ verify_pool = None
45+
46+
47+ def verify_password (secret_hash : str , secret : str ) -> bool :
48+ # Runs in a pool worker process. Catch everything (not just mismatch) and
49+ # report failure, so an unexpected library error can't leak into the caller
50+ # as anything other than a rejected token.
51+ try :
52+ hasher .verify (secret_hash , secret )
53+ return True
54+ except Exception :
55+ return False
56+
1957
2058class AuthTokens (aramaki .Collection ):
2159 collection = "fc.directory.ai.token"
@@ -36,19 +74,20 @@ class Cache:
3674 TTL = 300
3775
3876 def __init__ (self ):
39- self .cache : dict [str , float ] = {}
77+ self .cache : dict [str , tuple [ float , Any ] ] = {}
4078
41- def __contains__ (self , key : str ):
79+ def __getitem__ (self , key : str ):
4280 now = time .time ()
4381 if key not in self .cache :
44- return False
45- if self .cache [key ] < now :
82+ raise KeyError (key )
83+ expiry , payload = self .cache [key ]
84+ if expiry < now :
4685 del self .cache [key ]
47- return False
48- return True
86+ raise KeyError ( key )
87+ return payload
4988
50- def add (self , key : str ):
51- self .cache [key ] = time .time () + self .TTL
89+ def add (self , key : str , payload : Any ):
90+ self .cache [key ] = ( time .time () + self .TTL , payload )
5291
5392
5493cache = Cache () # XXX turn into service
@@ -59,16 +98,20 @@ async def verify_token(
5998 credentials : Annotated [HTTPAuthorizationCredentials , Depends (_bearer_auth )],
6099 services : svcs .fastapi .DepContainer ,
61100) -> None :
62- if credentials .credentials in cache :
63- return
101+ try :
102+ token_id = cache [credentials .credentials ]
103+ except KeyError :
104+ pass
105+ else :
106+ request .state .token_id = token_id
64107
65108 try :
66109 admin_tokens = services .get (AdminTokens )
67110 except svcs .exceptions .ServiceNotFoundError :
68111 pass
69112 else :
70113 if credentials .credentials in admin_tokens .tokens :
71- request .state .token_id = "admin-token"
114+ request .state .token_id = "< admin-token> "
72115 return
73116
74117 # XXX There's a lot of type issues going on here, because the mechanics of passing through
@@ -92,14 +135,20 @@ async def verify_token(
92135 )
93136 if not db_token :
94137 raise HTTPException (401 , detail = "Bad authentication" )
95- try :
96- assert isinstance (db_token ["secret_hash" ], str )
97- hasher .verify (db_token ["secret_hash" ], client_token ["secret" ])
98- request .state .token_id = client_token ["id" ]
99- cache .add (credentials .credentials )
100- # We could specify explicit exceptions here but go the safe route and just catch all in case the lib addes one
101- except Exception :
138+ assert isinstance (db_token ["secret_hash" ], str )
139+ loop = asyncio .get_running_loop ()
140+ # verify_pool is None outside a running app (e.g. tests); run_in_executor
141+ # then falls back to the loop's default thread executor.
142+ valid = await loop .run_in_executor (
143+ verify_pool ,
144+ verify_password ,
145+ db_token ["secret_hash" ],
146+ client_token ["secret" ],
147+ )
148+ if not valid :
102149 raise HTTPException (401 , detail = "Bad authentication" )
150+ request .state .token_id = client_token ["id" ]
151+ cache .add (credentials .credentials , client_token ["id" ])
103152
104153
105154async def verify_admin_token (
@@ -114,4 +163,4 @@ async def verify_admin_token(
114163 raise HTTPException (401 , detail = "No admin tokens configured" )
115164 if credentials .credentials not in admin_tokens .tokens :
116165 raise HTTPException (401 , detail = "Bad authentication" )
117- request .state .token_id = "admin-token"
166+ request .state .token_id = "< admin-token> "
0 commit comments