11from collections .abc import Generator
2+ from dataclasses import dataclass
23from typing import Annotated
4+
35import hashlib
46import secrets
7+ import httpx
58
6- from fastapi import Depends , HTTPException , status , Security
9+ from fastapi import Depends , Header , HTTPException , Security , status
710from fastapi .security import HTTPBearer , HTTPAuthorizationCredentials
811from sqlmodel import Session
912
@@ -17,34 +20,102 @@ def get_db() -> Generator[Session, None, None]:
1720
1821
1922SessionDep = Annotated [Session , Depends (get_db )]
23+
24+
25+ # Static bearer token auth for internal routes.
2026security = HTTPBearer (auto_error = False )
2127
2228
2329def _hash_token (token : str ) -> str :
2430 return hashlib .sha256 (token .encode ("utf-8" )).hexdigest ()
2531
2632
33+ def _unauthorized (detail : str ) -> HTTPException :
34+ return HTTPException (
35+ status_code = status .HTTP_401_UNAUTHORIZED ,
36+ detail = detail ,
37+ )
38+
39+
2740def verify_bearer_token (
2841 credentials : Annotated [
2942 HTTPAuthorizationCredentials | None ,
3043 Security (security ),
31- ]
32- ):
44+ ],
45+ ) -> bool :
3346 if credentials is None :
34- raise HTTPException (
35- status_code = status .HTTP_401_UNAUTHORIZED ,
36- detail = "Missing Authorization header" ,
37- )
47+ raise _unauthorized ("Missing Authorization header" )
3848
3949 if not secrets .compare_digest (
40- _hash_token (credentials .credentials ), settings .AUTH_TOKEN
50+ _hash_token (credentials .credentials ),
51+ settings .AUTH_TOKEN ,
4152 ):
42- raise HTTPException (
43- status_code = status .HTTP_401_UNAUTHORIZED ,
44- detail = "Invalid authorization token" ,
45- )
53+ raise _unauthorized ("Invalid authorization token" )
4654
4755 return True
4856
4957
5058AuthDep = Annotated [bool , Depends (verify_bearer_token )]
59+
60+
61+ # Multitenant auth context resolved from X-API-KEY.
62+ @dataclass
63+ class TenantContext :
64+ organization_id : int
65+ project_id : int
66+
67+
68+ def _fetch_tenant_from_backend (token : str ) -> TenantContext :
69+ if not settings .KAAPI_AUTH_URL :
70+ raise HTTPException (
71+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
72+ detail = "KAAPI_AUTH_URL is not configured" ,
73+ )
74+
75+ try :
76+ response = httpx .get (
77+ f"{ settings .KAAPI_AUTH_URL } /apikeys/verify" ,
78+ headers = {"X-API-KEY" : f"ApiKey { token } " },
79+ timeout = settings .KAAPI_AUTH_TIMEOUT ,
80+ )
81+ except httpx .RequestError :
82+ raise HTTPException (
83+ status_code = status .HTTP_503_SERVICE_UNAVAILABLE ,
84+ detail = "Auth service unavailable" ,
85+ )
86+
87+ if response .status_code != 200 :
88+ raise _unauthorized ("Invalid API key" )
89+
90+ data = response .json ()
91+ if not isinstance (data , dict ) or data .get ("success" ) is not True :
92+ raise _unauthorized ("Invalid API key" )
93+
94+ record = data .get ("data" )
95+ if not isinstance (record , dict ):
96+ raise _unauthorized ("Invalid API key" )
97+
98+ organization_id = record .get ("organization_id" )
99+ project_id = record .get ("project_id" )
100+ if not isinstance (organization_id , int ) or not isinstance (project_id , int ):
101+ raise _unauthorized ("Invalid API key" )
102+
103+ return TenantContext (
104+ organization_id = organization_id ,
105+ project_id = project_id ,
106+ )
107+
108+
109+ def validate_multitenant_key (
110+ x_api_key : Annotated [str | None , Header (alias = "X-API-KEY" )] = None ,
111+ ) -> TenantContext :
112+ if not x_api_key or not x_api_key .strip ():
113+ raise _unauthorized ("Missing X-API-KEY header" )
114+
115+ return _fetch_tenant_from_backend (x_api_key .strip ())
116+
117+
118+ MultitenantAuthDep = Annotated [
119+ TenantContext ,
120+ Depends (validate_multitenant_key ),
121+ ]
0 commit comments