38
38
import json
39
39
import os
40
40
import subprocess
41
+ import sys
41
42
import time
42
43
43
44
from google .auth import _helpers
47
48
# The max supported executable spec version.
48
49
EXECUTABLE_SUPPORTED_MAX_VERSION = 1
49
50
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
+
50
59
51
60
class Credentials (external_account .Credentials ):
52
61
"""External account credentials sourced from executables."""
@@ -92,6 +101,7 @@ def __init__(
92
101
:meth:`from_info` are used instead of calling the constructor directly.
93
102
"""
94
103
104
+ self .interactive = kwargs .pop ("interactive" , False )
95
105
super (Credentials , self ).__init__ (
96
106
audience = audience ,
97
107
subject_token_type = subject_token_type ,
@@ -116,37 +126,51 @@ def __init__(
116
126
self ._credential_source_executable_timeout_millis = self ._credential_source_executable .get (
117
127
"timeout_millis"
118
128
)
129
+ self ._credential_source_executable_interactive_timeout_millis = self ._credential_source_executable .get (
130
+ "interactive_timeout_millis"
131
+ )
119
132
self ._credential_source_executable_output_file = self ._credential_source_executable .get (
120
133
"output_file"
121
134
)
135
+ self ._tokeninfo_username = kwargs .get ("tokeninfo_username" , "" ) # dummy value
122
136
123
137
if not self ._credential_source_executable_command :
124
138
raise ValueError (
125
139
"Missing command field. Executable command must be provided."
126
140
)
127
141
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
+ )
129
145
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
132
150
):
133
151
raise ValueError ("Timeout must be between 5 and 120 seconds." )
134
152
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
+
135
165
@_helpers .copy_docstring (external_account .Credentials )
136
166
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 ()
144
168
145
169
# Check output file.
146
170
if self ._credential_source_executable_output_file is not None :
147
171
try :
148
172
with open (
149
- self ._credential_source_executable_output_file
173
+ self ._credential_source_executable_output_file , encoding = "utf-8"
150
174
) as output_file :
151
175
response = json .load (output_file )
152
176
except Exception :
@@ -155,6 +179,10 @@ def retrieve_subject_token(self, request):
155
179
try :
156
180
# If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again.
157
181
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
158
186
except ValueError :
159
187
raise
160
188
except exceptions .RefreshError :
@@ -169,46 +197,102 @@ def retrieve_subject_token(self, request):
169
197
170
198
# Inject env vars.
171
199
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"
185
202
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
199
225
)
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
+ )
209
234
235
+ subject_token = self ._parse_subject_token (response )
210
236
return subject_token
211
237
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
+
212
296
@classmethod
213
297
def from_info (cls , info , ** kwargs ):
214
298
"""Creates a Pluggable Credentials instance from parsed external account info.
@@ -241,17 +325,23 @@ def from_file(cls, filename, **kwargs):
241
325
"""
242
326
return super (Credentials , cls ).from_file (filename , ** kwargs )
243
327
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
+
244
343
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 )
255
345
if not response ["success" ]:
256
346
if "code" not in response or "message" not in response :
257
347
raise ValueError (
@@ -262,13 +352,6 @@ def _parse_subject_token(self, response):
262
352
response ["code" ], response ["message" ]
263
353
)
264
354
)
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
- )
272
355
if "expiration_time" in response and response ["expiration_time" ] < time .time ():
273
356
raise exceptions .RefreshError (
274
357
"The token returned by the executable is expired."
@@ -284,3 +367,38 @@ def _parse_subject_token(self, response):
284
367
return response ["saml_response" ]
285
368
else :
286
369
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