1212import os
1313import threading
1414import time
15+ from functools import lru_cache
1516from typing import Optional
1617
1718import httpx
2122GATEWAY_AUTH_AUDIENCE_ENV = "GATEWAY_AUTH_AUDIENCE"
2223GATEWAY_AUTH_CLIENT_ID_ENV = "GATEWAY_AUTH_CLIENT_ID"
2324GATEWAY_AUTH_CLIENT_SECRET_ENV = "GATEWAY_AUTH_CLIENT_SECRET"
25+ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV = "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE"
26+ GATEWAY_AUTH_REQUIRED_ENV = "GATEWAY_AUTH_REQUIRED"
2427
25- GATEWAY_AUTH_ENV_VARS = (
28+ GATEWAY_AUTH_CORE_ENV_VARS = (
2629 GATEWAY_AUTH_ISSUER_ENV ,
2730 GATEWAY_AUTH_AUDIENCE_ENV ,
2831 GATEWAY_AUTH_CLIENT_ID_ENV ,
32+ )
33+
34+ GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS = (
2935 GATEWAY_AUTH_CLIENT_SECRET_ENV ,
36+ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV ,
37+ )
38+
39+ GATEWAY_AUTH_ENV_VARS = (
40+ * GATEWAY_AUTH_CORE_ENV_VARS ,
41+ * GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS ,
3042)
3143
3244
3345class GatewayAuthError (RuntimeError ):
3446 """Raised when the gateway auth config is missing or the token fetch fails."""
3547
3648
49+ def gateway_auth_required () -> bool :
50+ """True iff this runtime requires gateway auth to be configured."""
51+ return os .environ .get (GATEWAY_AUTH_REQUIRED_ENV , "" ).lower () in (
52+ "1" ,
53+ "true" ,
54+ "yes" ,
55+ "on" ,
56+ )
57+
58+
59+ @lru_cache (maxsize = None )
60+ def _load_secret_from_secret_manager (resource_name : str ) -> str :
61+ """Fetch one secret payload from Google Secret Manager."""
62+ from google .cloud import secretmanager
63+
64+ client = secretmanager .SecretManagerServiceClient ()
65+ response = client .access_secret_version (request = {"name" : resource_name })
66+ return response .payload .data .decode ("utf-8" )
67+
68+
3769def _require_all_or_none_gateway_auth_env () -> None :
38- """Refuse startup when the four GATEWAY_AUTH_* env vars are partially set."""
39- present = [name for name in GATEWAY_AUTH_ENV_VARS if os .environ .get (name )]
40- if present and len (present ) != len (GATEWAY_AUTH_ENV_VARS ):
41- missing = [name for name in GATEWAY_AUTH_ENV_VARS if not os .environ .get (name )]
70+ """Refuse startup when gateway auth is partially or ambiguously configured."""
71+ present_core = [name for name in GATEWAY_AUTH_CORE_ENV_VARS if os .environ .get (name )]
72+ present_secret_sources = [
73+ name for name in GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS if os .environ .get (name )
74+ ]
75+ if len (present_secret_sources ) > 1 :
4276 raise GatewayAuthError (
43- "Gateway auth is partially configured: "
44- f"{ ', ' .join (present )} set but { ', ' .join (missing )} missing. "
45- "Set all four or none."
77+ "Gateway auth is ambiguously configured: both "
78+ f"{ GATEWAY_AUTH_CLIENT_SECRET_ENV } and "
79+ f"{ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV } are set. "
80+ "Set exactly one secret source."
4681 )
82+ if present_core or present_secret_sources :
83+ missing_core = [
84+ name for name in GATEWAY_AUTH_CORE_ENV_VARS if not os .environ .get (name )
85+ ]
86+ if missing_core or not present_secret_sources :
87+ missing = [
88+ * missing_core ,
89+ * (
90+ []
91+ if present_secret_sources
92+ else [
93+ f"{ GATEWAY_AUTH_CLIENT_SECRET_ENV } or "
94+ f"{ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV } "
95+ ]
96+ ),
97+ ]
98+ present = [* present_core , * present_secret_sources ]
99+ raise GatewayAuthError (
100+ "Gateway auth is partially configured: "
101+ f"{ ', ' .join (present )} set but { ', ' .join (missing )} missing. "
102+ "Set issuer, audience, client ID, and exactly one secret source."
103+ )
47104
48105
49106class GatewayAuthTokenProvider :
@@ -57,6 +114,7 @@ def __init__(
57114 audience : Optional [str ] = None ,
58115 client_id : Optional [str ] = None ,
59116 client_secret : Optional [str ] = None ,
117+ client_secret_resource : Optional [str ] = None ,
60118 * ,
61119 http_timeout : float = 10.0 ,
62120 ):
@@ -80,6 +138,11 @@ def __init__(
80138 if client_secret is not None
81139 else os .environ .get (GATEWAY_AUTH_CLIENT_SECRET_ENV , "" )
82140 )
141+ self ._client_secret_resource = (
142+ client_secret_resource
143+ if client_secret_resource is not None
144+ else os .environ .get (GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV , "" )
145+ )
83146 self ._http_timeout = http_timeout
84147 self ._token : Optional [str ] = None
85148 self ._expires_at : float = 0.0
@@ -93,7 +156,7 @@ def configured(self) -> bool:
93156 self ._issuer ,
94157 self ._audience ,
95158 self ._client_id ,
96- self ._client_secret ,
159+ self ._client_secret or self . _client_secret_resource ,
97160 )
98161 )
99162
@@ -103,8 +166,9 @@ def get_token(self) -> str:
103166 raise GatewayAuthError (
104167 "Gateway auth not configured: set "
105168 f"{ GATEWAY_AUTH_ISSUER_ENV } , { GATEWAY_AUTH_AUDIENCE_ENV } , "
106- f"{ GATEWAY_AUTH_CLIENT_ID_ENV } , and "
107- f"{ GATEWAY_AUTH_CLIENT_SECRET_ENV } ."
169+ f"{ GATEWAY_AUTH_CLIENT_ID_ENV } , and either "
170+ f"{ GATEWAY_AUTH_CLIENT_SECRET_ENV } or "
171+ f"{ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV } ."
108172 )
109173
110174 with self ._lock :
@@ -118,12 +182,13 @@ def get_token(self) -> str:
118182
119183 def _fetch_locked (self ) -> None :
120184 """Call Auth0's /oauth/token. Caller must hold _lock."""
185+ client_secret = self ._get_client_secret_locked ()
121186 try :
122187 response = httpx .post (
123188 f"{ self ._issuer } /oauth/token" ,
124189 json = {
125190 "client_id" : self ._client_id ,
126- "client_secret" : self . _client_secret ,
191+ "client_secret" : client_secret ,
127192 "audience" : self ._audience ,
128193 "grant_type" : "client_credentials" ,
129194 },
@@ -153,6 +218,31 @@ def _fetch_locked(self) -> None:
153218 self ._token = token
154219 self ._expires_at = time .time () + expires_in
155220
221+ def _get_client_secret_locked (self ) -> str :
222+ """Resolve the client secret from env or Secret Manager."""
223+ if self ._client_secret :
224+ return self ._client_secret
225+ if not self ._client_secret_resource :
226+ raise GatewayAuthError (
227+ "Gateway auth client secret not configured: set "
228+ f"{ GATEWAY_AUTH_CLIENT_SECRET_ENV } or "
229+ f"{ GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV } ."
230+ )
231+ try :
232+ self ._client_secret = _load_secret_from_secret_manager (
233+ self ._client_secret_resource
234+ )
235+ except Exception as exc :
236+ raise GatewayAuthError (
237+ "Failed to load gateway auth client secret from Secret Manager "
238+ f"resource { self ._client_secret_resource } : { exc } "
239+ ) from exc
240+ if not self ._client_secret :
241+ raise GatewayAuthError (
242+ "Secret Manager returned an empty gateway auth client secret."
243+ )
244+ return self ._client_secret
245+
156246 def invalidate (self ) -> None :
157247 """Drop the cached token so the next call refetches it."""
158248 with self ._lock :
0 commit comments