Skip to content

Commit 44a189f

Browse files
BigTailWolflsiracclundin25
authored
feat: implement pluggable auth interactive mode (#1131)
For interactive mode: 1. Always using output to read the result. 2. Make `expiration_time` optional for all mode. 3. Implement interactive mode run executable 4. Implement `revoke()` function. 5. Refactor tests Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com>
1 parent 2608ed8 commit 44a189f

File tree

4 files changed

+554
-168
lines changed

4 files changed

+554
-168
lines changed

docs/user-guide.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,10 @@ Response format fields summary:
434434
- ``version``: The version of the JSON output. Currently only version 1 is
435435
supported.
436436
- ``success``: The status of the response.
437-
- When true, the response must contain the 3rd party token, token type, and expiration. The executable must also exit with exit code 0.
438-
- When false, the response must contain the error code and message fields and exit with a non-zero value.
437+
- When true, the response must contain the 3rd party token, token type, and
438+
expiration. The executable must also exit with exit code 0.
439+
- When false, the response must contain the error code and message fields
440+
and exit with a non-zero value.
439441
- ``token_type``: The 3rd party subject token type. Must be
440442
- *urn:ietf:params:oauth:token-type:jwt*
441443
- *urn:ietf:params:oauth:token-type:id_token*
@@ -450,7 +452,9 @@ Response format fields summary:
450452
All response types must include both the ``version`` and ``success`` fields.
451453
Successful responses must include the ``token_type``, and one of ``id_token``
452454
or ``saml_response``.
453-
If output file is specified, ``expiration_time`` is mandatory.
455+
``expiration_time`` is optional. If the output file does not contain the
456+
``expiration_time`` field, the response will be considered expired and the
457+
executable will be called.
454458
Error responses must include both the ``code`` and ``message`` fields.
455459

456460
The library will populate the following environment variables when the

google/auth/pluggable.py

Lines changed: 181 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import json
3939
import os
4040
import subprocess
41+
import sys
4142
import time
4243

4344
from google.auth import _helpers
@@ -47,6 +48,14 @@
4748
# The max supported executable spec version.
4849
EXECUTABLE_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

5160
class 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.")

system_tests/secrets.tar.enc

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)