3838import json
3939import os
4040import subprocess
41+ import sys
4142import time
4243
4344from google .auth import _helpers
4748# The max supported executable spec version.
4849EXECUTABLE_SUPPORTED_MAX_VERSION = 1
4950
51+ EXECUTABLE_TIMEOUT_MILLIS_DEFAULT = 30 * 1000 # 30 seconds
52+ EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 1000 # 5 seconds
53+ EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND = 120 * 1000 # 2 minutes
54+
55+ EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT = 5 * 60 * 1000 # 5 minutes
56+ EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 60 * 1000 # 5 minutes
57+ EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND = 30 * 60 * 1000 # 30 minutes
58+
5059
5160class Credentials (external_account .Credentials ):
5261 """External account credentials sourced from executables."""
@@ -92,6 +101,7 @@ def __init__(
92101 :meth:`from_info` are used instead of calling the constructor directly.
93102 """
94103
104+ self .interactive = kwargs .pop ("interactive" , False )
95105 super (Credentials , self ).__init__ (
96106 audience = audience ,
97107 subject_token_type = subject_token_type ,
@@ -116,37 +126,51 @@ def __init__(
116126 self ._credential_source_executable_timeout_millis = self ._credential_source_executable .get (
117127 "timeout_millis"
118128 )
129+ self ._credential_source_executable_interactive_timeout_millis = self ._credential_source_executable .get (
130+ "interactive_timeout_millis"
131+ )
119132 self ._credential_source_executable_output_file = self ._credential_source_executable .get (
120133 "output_file"
121134 )
135+ self ._tokeninfo_username = kwargs .get ("tokeninfo_username" , "" ) # dummy value
122136
123137 if not self ._credential_source_executable_command :
124138 raise ValueError (
125139 "Missing command field. Executable command must be provided."
126140 )
127141 if not self ._credential_source_executable_timeout_millis :
128- self ._credential_source_executable_timeout_millis = 30 * 1000
142+ self ._credential_source_executable_timeout_millis = (
143+ EXECUTABLE_TIMEOUT_MILLIS_DEFAULT
144+ )
129145 elif (
130- self ._credential_source_executable_timeout_millis < 5 * 1000
131- or self ._credential_source_executable_timeout_millis > 120 * 1000
146+ self ._credential_source_executable_timeout_millis
147+ < EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND
148+ or self ._credential_source_executable_timeout_millis
149+ > EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND
132150 ):
133151 raise ValueError ("Timeout must be between 5 and 120 seconds." )
134152
153+ if not self ._credential_source_executable_interactive_timeout_millis :
154+ self ._credential_source_executable_interactive_timeout_millis = (
155+ EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT
156+ )
157+ elif (
158+ self ._credential_source_executable_interactive_timeout_millis
159+ < EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND
160+ or self ._credential_source_executable_interactive_timeout_millis
161+ > EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND
162+ ):
163+ raise ValueError ("Interactive timeout must be between 5 and 30 minutes." )
164+
135165 @_helpers .copy_docstring (external_account .Credentials )
136166 def retrieve_subject_token (self , request ):
137- env_allow_executables = os .environ .get (
138- "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
139- )
140- if env_allow_executables != "1" :
141- raise ValueError (
142- "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run."
143- )
167+ self ._validate_running_mode ()
144168
145169 # Check output file.
146170 if self ._credential_source_executable_output_file is not None :
147171 try :
148172 with open (
149- self ._credential_source_executable_output_file
173+ self ._credential_source_executable_output_file , encoding = "utf-8"
150174 ) as output_file :
151175 response = json .load (output_file )
152176 except Exception :
@@ -155,6 +179,10 @@ def retrieve_subject_token(self, request):
155179 try :
156180 # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again.
157181 subject_token = self ._parse_subject_token (response )
182+ if (
183+ "expiration_time" not in response
184+ ): # Always treat missing expiration_time as expired and proceed to executable run.
185+ raise exceptions .RefreshError
158186 except ValueError :
159187 raise
160188 except exceptions .RefreshError :
@@ -169,46 +197,102 @@ def retrieve_subject_token(self, request):
169197
170198 # Inject env vars.
171199 env = os .environ .copy ()
172- env ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE" ] = self ._audience
173- env ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" ] = self ._subject_token_type
174- env [
175- "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"
176- ] = "0" # Always set to 0 until interactive mode is implemented.
177- if self ._service_account_impersonation_url is not None :
178- env [
179- "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"
180- ] = self .service_account_email
181- if self ._credential_source_executable_output_file is not None :
182- env [
183- "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"
184- ] = self ._credential_source_executable_output_file
200+ self ._inject_env_variables (env )
201+ env ["GOOGLE_EXTERNAL_ACCOUNT_REVOKE" ] = "0"
185202
186- try :
187- result = subprocess .run (
188- self ._credential_source_executable_command .split (),
189- timeout = self ._credential_source_executable_timeout_millis / 1000 ,
190- stdout = subprocess .PIPE ,
191- stderr = subprocess .STDOUT ,
192- env = env ,
193- )
194- if result .returncode != 0 :
195- raise exceptions .RefreshError (
196- "Executable exited with non-zero return code {}. Error: {}" .format (
197- result .returncode , result .stdout
198- )
203+ # Run executable.
204+ exe_timeout = (
205+ self ._credential_source_executable_interactive_timeout_millis / 1000
206+ if self .interactive
207+ else self ._credential_source_executable_timeout_millis / 1000
208+ )
209+ exe_stdin = sys .stdin if self .interactive else None
210+ exe_stdout = sys .stdout if self .interactive else subprocess .PIPE
211+ exe_stderr = sys .stdout if self .interactive else subprocess .STDOUT
212+
213+ result = subprocess .run (
214+ self ._credential_source_executable_command .split (),
215+ timeout = exe_timeout ,
216+ stdin = exe_stdin ,
217+ stdout = exe_stdout ,
218+ stderr = exe_stderr ,
219+ env = env ,
220+ )
221+ if result .returncode != 0 :
222+ raise exceptions .RefreshError (
223+ "Executable exited with non-zero return code {}. Error: {}" .format (
224+ result .returncode , result .stdout
199225 )
200- except Exception :
201- raise
202- else :
203- try :
204- data = result .stdout .decode ("utf-8" )
205- response = json .loads (data )
206- subject_token = self ._parse_subject_token (response )
207- except Exception :
208- raise
226+ )
227+
228+ # Handle executable output.
229+ response = json .loads (result .stdout .decode ("utf-8" )) if result .stdout else None
230+ if not response and self ._credential_source_executable_output_file is not None :
231+ response = json .load (
232+ open (self ._credential_source_executable_output_file , encoding = "utf-8" )
233+ )
209234
235+ subject_token = self ._parse_subject_token (response )
210236 return subject_token
211237
238+ def revoke (self , request ):
239+ """Revokes the subject token using the credential_source object.
240+
241+ Args:
242+ request (google.auth.transport.Request): A callable used to make
243+ HTTP requests.
244+ Raises:
245+ google.auth.exceptions.RefreshError: If the executable revocation
246+ not properly executed.
247+
248+ """
249+ if not self .interactive :
250+ raise ValueError ("Revoke is only enabled under interactive mode." )
251+ self ._validate_running_mode ()
252+
253+ if not _helpers .is_python_3 ():
254+ raise exceptions .RefreshError (
255+ "Pluggable auth is only supported for python 3.6+"
256+ )
257+
258+ # Inject variables
259+ env = os .environ .copy ()
260+ self ._inject_env_variables (env )
261+ env ["GOOGLE_EXTERNAL_ACCOUNT_REVOKE" ] = "1"
262+
263+ # Run executable
264+ result = subprocess .run (
265+ self ._credential_source_executable_command .split (),
266+ timeout = self ._credential_source_executable_interactive_timeout_millis
267+ / 1000 ,
268+ stdout = subprocess .PIPE ,
269+ stderr = subprocess .STDOUT ,
270+ env = env ,
271+ )
272+
273+ if result .returncode != 0 :
274+ raise exceptions .RefreshError (
275+ "Auth revoke failed on executable. Exit with non-zero return code {}. Error: {}" .format (
276+ result .returncode , result .stdout
277+ )
278+ )
279+
280+ response = json .loads (result .stdout .decode ("utf-8" ))
281+ self ._validate_revoke_response (response )
282+
283+ @property
284+ def external_account_id (self ):
285+ """Returns the external account identifier.
286+
287+ When service account impersonation is used the identifier is the service
288+ account email.
289+
290+ Without service account impersonation, this returns None, unless it is
291+ being used by the Google Cloud CLI which populates this field.
292+ """
293+
294+ return self .service_account_email or self ._tokeninfo_username
295+
212296 @classmethod
213297 def from_info (cls , info , ** kwargs ):
214298 """Creates a Pluggable Credentials instance from parsed external account info.
@@ -241,17 +325,23 @@ def from_file(cls, filename, **kwargs):
241325 """
242326 return super (Credentials , cls ).from_file (filename , ** kwargs )
243327
328+ def _inject_env_variables (self , env ):
329+ env ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE" ] = self ._audience
330+ env ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" ] = self ._subject_token_type
331+ env ["GOOGLE_EXTERNAL_ACCOUNT_ID" ] = self .external_account_id
332+ env ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE" ] = "1" if self .interactive else "0"
333+
334+ if self ._service_account_impersonation_url is not None :
335+ env [
336+ "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"
337+ ] = self .service_account_email
338+ if self ._credential_source_executable_output_file is not None :
339+ env [
340+ "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"
341+ ] = self ._credential_source_executable_output_file
342+
244343 def _parse_subject_token (self , response ):
245- if "version" not in response :
246- raise ValueError ("The executable response is missing the version field." )
247- if response ["version" ] > EXECUTABLE_SUPPORTED_MAX_VERSION :
248- raise exceptions .RefreshError (
249- "Executable returned unsupported version {}." .format (
250- response ["version" ]
251- )
252- )
253- if "success" not in response :
254- raise ValueError ("The executable response is missing the success field." )
344+ self ._validate_response_schema (response )
255345 if not response ["success" ]:
256346 if "code" not in response or "message" not in response :
257347 raise ValueError (
@@ -262,13 +352,6 @@ def _parse_subject_token(self, response):
262352 response ["code" ], response ["message" ]
263353 )
264354 )
265- if (
266- "expiration_time" not in response
267- and self ._credential_source_executable_output_file
268- ):
269- raise ValueError (
270- "The executable response must contain an expiration_time for successful responses when an output_file has been specified in the configuration."
271- )
272355 if "expiration_time" in response and response ["expiration_time" ] < time .time ():
273356 raise exceptions .RefreshError (
274357 "The token returned by the executable is expired."
@@ -284,3 +367,38 @@ def _parse_subject_token(self, response):
284367 return response ["saml_response" ]
285368 else :
286369 raise exceptions .RefreshError ("Executable returned unsupported token type." )
370+
371+ def _validate_revoke_response (self , response ):
372+ self ._validate_response_schema (response )
373+ if not response ["success" ]:
374+ raise exceptions .RefreshError ("Revoke failed with unsuccessful response." )
375+
376+ def _validate_response_schema (self , response ):
377+ if "version" not in response :
378+ raise ValueError ("The executable response is missing the version field." )
379+ if response ["version" ] > EXECUTABLE_SUPPORTED_MAX_VERSION :
380+ raise exceptions .RefreshError (
381+ "Executable returned unsupported version {}." .format (
382+ response ["version" ]
383+ )
384+ )
385+
386+ if "success" not in response :
387+ raise ValueError ("The executable response is missing the success field." )
388+
389+ def _validate_running_mode (self ):
390+ env_allow_executables = os .environ .get (
391+ "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
392+ )
393+ if env_allow_executables != "1" :
394+ raise ValueError (
395+ "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run."
396+ )
397+
398+ if self .interactive and not self ._credential_source_executable_output_file :
399+ raise ValueError (
400+ "An output_file must be specified in the credential configuration for interactive mode."
401+ )
402+
403+ if self .interactive and not self .is_workforce_pool :
404+ raise ValueError ("Interactive mode is only enabled for workforce pool." )
0 commit comments