Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit f36e5e8

Browse files
author
Chuan Ren
authored
feat: Add file caching (#990)
* Add file cache * feat: add output file cache support
1 parent 63ad5e9 commit f36e5e8

2 files changed

Lines changed: 92 additions & 22 deletions

File tree

google/auth/pluggable.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import json
4242
import os
4343
import subprocess
44+
import time
4445

4546
from google.auth import _helpers
4647
from google.auth import exceptions
@@ -163,6 +164,17 @@ def retrieve_subject_token(self, request):
163164
"Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run."
164165
)
165166

167+
# Check output file
168+
if self._credential_source_executable_output_file is not None:
169+
try:
170+
with open(self._credential_source_executable_output_file) as output_file:
171+
response = json.load(output_file)
172+
subject_token = self._parse_subject_token(response)
173+
except:
174+
pass
175+
else:
176+
return subject_token
177+
166178
# Inject env vars
167179
original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE")
168180
os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience
@@ -208,23 +220,8 @@ def retrieve_subject_token(self, request):
208220
else:
209221
data = result.stdout.decode('utf-8')
210222
response = json.loads(data)
211-
if not response['success']:
212-
raise exceptions.RefreshError(
213-
"Executable returned unsuccessful response: {}.".format(response)
214-
)
215-
elif response['version'] > EXECUTABLE_SUPPORTED_MAX_VERSION:
216-
raise exceptions.RefreshError(
217-
"Executable returned unsupported version {}.".format(response['version'])
218-
)
219-
elif response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token": # OIDC
220-
return response["id_token"]
221-
elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML
222-
return response["saml_response"]
223-
else:
224-
raise exceptions.RefreshError(
225-
"Executable returned unsupported token type."
226-
)
227-
223+
return self._parse_subject_token(response)
224+
228225
@classmethod
229226
def from_info(cls, info, **kwargs):
230227
"""Creates a Pluggable Credentials instance from parsed external account info.
@@ -271,3 +268,25 @@ def from_file(cls, filename, **kwargs):
271268
with io.open(filename, "r", encoding="utf-8") as json_file:
272269
data = json.load(json_file)
273270
return cls.from_info(data, **kwargs)
271+
272+
def _parse_subject_token(self, response):
273+
if not response['success']:
274+
raise exceptions.RefreshError(
275+
"Executable returned unsuccessful response: {}.".format(response)
276+
)
277+
elif response['version'] > EXECUTABLE_SUPPORTED_MAX_VERSION:
278+
raise exceptions.RefreshError(
279+
"Executable returned unsupported version {}.".format(response['version'])
280+
)
281+
elif response['expiration_time'] < time.time():
282+
raise exceptions.RefreshError(
283+
"The token returned by the executable is expired."
284+
)
285+
elif response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token": # OIDC
286+
return response["id_token"]
287+
elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML
288+
return response["saml_response"]
289+
else:
290+
raise exceptions.RefreshError(
291+
"Executable returned unsupported token type."
292+
)

tests/test_pluggable.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,11 @@
4949

5050
class TestCredentials(object):
5151
CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = "/fake/external/excutable --arg1=value1 --arg2=value2"
52+
CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file"
5253
CREDENTIAL_SOURCE_EXECUTABLE = {
5354
"command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND,
5455
"timeout_millis": 5000,
55-
"output_file": "/fake/output/file"
56+
"output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE
5657
}
5758
CREDENTIAL_SOURCE = {
5859
"executable": CREDENTIAL_SOURCE_EXECUTABLE
@@ -78,7 +79,7 @@ class TestCredentials(object):
7879
"success": True,
7980
"token_type": "urn:ietf:params:oauth:token-type:saml2",
8081
"saml_response": EXECUTABLE_SAML_TOKEN,
81-
"expiration_time": 1620433341
82+
"expiration_time": 9999999999
8283
}
8384
EXECUTABLE_FAILED_RESPONSE = {
8485
"version": 1,
@@ -488,7 +489,20 @@ def test_retrieve_subject_token_failed(self, fp):
488489
subject_token = credentials.retrieve_subject_token(None)
489490

490491
assert excinfo.match(r"Executable returned unsuccessful response")
491-
492+
493+
@mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"})
494+
def test_retrieve_subject_token_not_allowd(self, fp):
495+
fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN))
496+
497+
credentials = self.make_pluggable(
498+
credential_source=self.CREDENTIAL_SOURCE
499+
)
500+
501+
with pytest.raises(ValueError) as excinfo:
502+
subject_token = credentials.retrieve_subject_token(None)
503+
504+
assert excinfo.match(r"Executables need to be explicitly allowed")
505+
492506
@mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"})
493507
def test_retrieve_subject_token_invalid_version(self, fp):
494508
EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = {
@@ -498,7 +512,7 @@ def test_retrieve_subject_token_invalid_version(self, fp):
498512
"id_token": self.EXECUTABLE_OIDC_TOKEN,
499513
"expiration_time": 9999999999
500514
}
501-
515+
502516
fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2))
503517

504518
credentials = self.make_pluggable(
@@ -508,4 +522,41 @@ def test_retrieve_subject_token_invalid_version(self, fp):
508522
with pytest.raises(exceptions.RefreshError) as excinfo:
509523
subject_token = credentials.retrieve_subject_token(None)
510524

511-
assert excinfo.match(r"Executable returned unsupported version")
525+
assert excinfo.match(r"Executable returned unsupported version")
526+
527+
@mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"})
528+
def test_retrieve_subject_token_expired_token(self, fp):
529+
EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED= {
530+
"version": 1,
531+
"success": True,
532+
"token_type": "urn:ietf:params:oauth:token-type:id_token",
533+
"id_token": self.EXECUTABLE_OIDC_TOKEN,
534+
"expiration_time": 0
535+
}
536+
537+
fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED))
538+
539+
credentials = self.make_pluggable(
540+
credential_source=self.CREDENTIAL_SOURCE
541+
)
542+
543+
with pytest.raises(exceptions.RefreshError) as excinfo:
544+
subject_token = credentials.retrieve_subject_token(None)
545+
546+
assert excinfo.match(r"The token returned by the executable is expired")
547+
548+
@mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"})
549+
def test_retrieve_subject_token_file_cache(self, fp):
550+
with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, 'w') as output_file:
551+
json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file)
552+
553+
credentials = self.make_pluggable(
554+
credential_source=self.CREDENTIAL_SOURCE
555+
)
556+
557+
subject_token = credentials.retrieve_subject_token(None)
558+
559+
assert subject_token == self.EXECUTABLE_OIDC_TOKEN
560+
561+
if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE):
562+
os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE)

0 commit comments

Comments
 (0)