Skip to content

Commit

Permalink
feat: Pluggable auth support (#995)
Browse files Browse the repository at this point in the history
* feat: Add Pluggable auth support (#988)

* Port identity pool credentials

* access_token retrieved

* -> pluggable

* Update pluggable.py

* Create test_pluggable.py

* Unit tests

* Address pr issues

* feat: Add file caching (#990)

* Add file cache

* feat: add output file cache support

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update pluggable.py

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update pluggable.py

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update setup.py

* Update setup.py

* Update setup.py

* pytest_subprocess

* timeout

* Update pluggable.py

* env

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update _default.py

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update requirements.txt

* Update _default.py

* Update pluggable.py

* Update pluggable.py

* Update pluggable.py

* Update test_pluggable.py

* format validations

* Update _default.py

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update requirements.txt

* Revert "Update requirements.txt"

This reverts commit 1c9b6db.

* Revert "Update _default.py"

This reverts commit ac6c360.

* Revert "Revert "Update _default.py""

This reverts commit 1c08483.

* Raise output format error but retry parsing token if `success` is 0

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update requirements.txt

* Delete test_pluggable.py

* Revert "Delete test_pluggable.py"

This reverts commit 74beba9.

* Update pluggable.py

* Update pluggable.py

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* pytest-subprocess

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* lint

* Update pluggable.py

* nox cover

nox cover

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* lint

* Update test_pluggable.py

* 🦉 Updates from OwlBot post-processor

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

* Update test_pluggable.py

Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
Co-authored-by: arithmetic1728 <58957152+arithmetic1728@users.noreply.github.com>
  • Loading branch information
3 people committed May 10, 2022
1 parent 2598080 commit 62daa73
Show file tree
Hide file tree
Showing 4 changed files with 1,071 additions and 1 deletion.
11 changes: 10 additions & 1 deletion google/auth/_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,23 @@ def _get_external_account_credentials(
google.auth.exceptions.DefaultCredentialsError: if the info dictionary
is in the wrong format or is missing required information.
"""
# There are currently 2 types of external_account credentials.
# There are currently 3 types of external_account credentials.
if info.get("subject_token_type") == _AWS_SUBJECT_TOKEN_TYPE:
# Check if configuration corresponds to an AWS credentials.
from google.auth import aws

credentials = aws.Credentials.from_info(
info, scopes=scopes, default_scopes=default_scopes
)
elif (
info.get("credential_source") is not None
and info.get("credential_source").get("executable") is not None
):
from google.auth import pluggable

credentials = pluggable.Credentials.from_info(
info, scopes=scopes, default_scopes=default_scopes
)
else:
try:
# Check if configuration corresponds to an Identity Pool credentials.
Expand Down
349 changes: 349 additions & 0 deletions google/auth/pluggable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Pluggable Credentials.
Pluggable Credentials are initialized using external_account arguments which
are typically loaded from third-party executables. Unlike other
credentials that can be initialized with a list of explicit arguments, secrets
or credentials, external account clients use the environment and hints/guidelines
provided by the external_account JSON file to retrieve credentials and exchange
them for Google access tokens.
Example credential_source for pluggable credential:
{
"executable": {
"command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2",
"timeout_millis": 5000,
"output_file": "/path/to/generated/cached/credentials"
}
}
"""

try:
from collections.abc import Mapping
# Python 2.7 compatibility
except ImportError: # pragma: NO COVER
from collections import Mapping
import io
import json
import os
import subprocess
import time

from google.auth import _helpers
from google.auth import exceptions
from google.auth import external_account

# The max supported executable spec version.
EXECUTABLE_SUPPORTED_MAX_VERSION = 1


class Credentials(external_account.Credentials):
"""External account credentials sourced from executables."""

def __init__(
self,
audience,
subject_token_type,
token_url,
credential_source,
service_account_impersonation_url=None,
client_id=None,
client_secret=None,
quota_project_id=None,
scopes=None,
default_scopes=None,
workforce_pool_user_project=None,
):
"""Instantiates an external account credentials object from a executables.
Args:
audience (str): The STS audience field.
subject_token_type (str): The subject token type.
token_url (str): The STS endpoint URL.
credential_source (Mapping): The credential source dictionary used to
provide instructions on how to retrieve external credential to be
exchanged for Google access tokens.
Example credential_source for pluggable credential:
{
"executable": {
"command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2",
"timeout_millis": 5000,
"output_file": "/path/to/generated/cached/credentials"
}
}
service_account_impersonation_url (Optional[str]): The optional service account
impersonation getAccessToken URL.
client_id (Optional[str]): The optional client ID.
client_secret (Optional[str]): The optional client secret.
quota_project_id (Optional[str]): The optional quota project ID.
scopes (Optional[Sequence[str]]): Optional scopes to request during the
authorization grant.
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
workforce_pool_user_project (Optona[str]): The optional workforce pool user
project number when the credential corresponds to a workforce pool and not
a workload Pluggable. The underlying principal must still have
serviceusage.services.use IAM permission to use the project for
billing/quota.
Raises:
google.auth.exceptions.RefreshError: If an error is encountered during
access token retrieval logic.
ValueError: For invalid parameters.
.. note:: Typically one of the helper constructors
:meth:`from_file` or
:meth:`from_info` are used instead of calling the constructor directly.
"""

super(Credentials, self).__init__(
audience=audience,
subject_token_type=subject_token_type,
token_url=token_url,
credential_source=credential_source,
service_account_impersonation_url=service_account_impersonation_url,
client_id=client_id,
client_secret=client_secret,
quota_project_id=quota_project_id,
scopes=scopes,
default_scopes=default_scopes,
workforce_pool_user_project=workforce_pool_user_project,
)
if not isinstance(credential_source, Mapping):
self._credential_source_executable = None
raise ValueError(
"Missing credential_source. The credential_source is not a dict."
)
self._credential_source_executable = credential_source.get("executable")
if not self._credential_source_executable:
raise ValueError(
"Missing credential_source. An 'executable' must be provided."
)
self._credential_source_executable_command = self._credential_source_executable.get(
"command"
)
self._credential_source_executable_timeout_millis = self._credential_source_executable.get(
"timeout_millis"
)
self._credential_source_executable_output_file = self._credential_source_executable.get(
"output_file"
)

if not self._credential_source_executable_command:
raise ValueError(
"Missing command field. Executable command must be provided."
)
if not self._credential_source_executable_timeout_millis:
self._credential_source_executable_timeout_millis = 30 * 1000
elif (
self._credential_source_executable_timeout_millis < 5 * 1000
or self._credential_source_executable_timeout_millis > 120 * 1000
):
raise ValueError("Timeout must be between 5 and 120 seconds.")

@_helpers.copy_docstring(external_account.Credentials)
def retrieve_subject_token(self, request):
env_allow_executables = os.environ.get(
"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
)
if env_allow_executables != "1":
raise ValueError(
"Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run."
)

# Check output file.
if self._credential_source_executable_output_file is not None:
try:
with open(
self._credential_source_executable_output_file
) as output_file:
response = json.load(output_file)
except Exception:
pass
else:
try:
# If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again.
subject_token = self._parse_subject_token(response)
except ValueError:
raise
except exceptions.RefreshError:
pass
else:
return subject_token

# Inject env vars.
original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE")
os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience
original_subject_token_type = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE")
os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type
original_interactive = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE")
os.environ[
"GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"
] = "0" # Always set to 0 until interactive mode is implemented.
original_service_account_impersonation_url = os.getenv(
"GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"
)
if self._service_account_impersonation_url is not None:
os.environ[
"GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"
] = self.service_account_email
original_credential_source_executable_output_file = os.getenv(
"GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"
)
if self._credential_source_executable_output_file is not None:
os.environ[
"GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"
] = self._credential_source_executable_output_file

try:
result = subprocess.check_output(
self._credential_source_executable_command.split(),
stderr=subprocess.STDOUT,
)
except subprocess.CalledProcessError as e:
raise exceptions.RefreshError(
"Executable exited with non-zero return code {}. Error: {}".format(
e.returncode, e.output
)
)
else:
try:
data = result.decode("utf-8")
response = json.loads(data)
subject_token = self._parse_subject_token(response)
except Exception:
raise

# Reset env vars.
if original_audience is not None:
os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience
else:
del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"]
if original_subject_token_type is not None:
os.environ[
"GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"
] = original_subject_token_type
else:
del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"]
if original_interactive is not None:
os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = original_interactive
else:
del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"]
if original_service_account_impersonation_url is not None:
os.environ[
"GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"
] = original_service_account_impersonation_url
elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None:
del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"]
if original_credential_source_executable_output_file is not None:
os.environ[
"GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"
] = original_credential_source_executable_output_file
elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None:
del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"]

return subject_token

@classmethod
def from_info(cls, info, **kwargs):
"""Creates a Pluggable Credentials instance from parsed external account info.
Args:
info (Mapping[str, str]): The Pluggable external account info in Google
format.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.pluggable.Credentials: The constructed
credentials.
Raises:
ValueError: For invalid parameters.
"""
return cls(
audience=info.get("audience"),
subject_token_type=info.get("subject_token_type"),
token_url=info.get("token_url"),
service_account_impersonation_url=info.get(
"service_account_impersonation_url"
),
client_id=info.get("client_id"),
client_secret=info.get("client_secret"),
credential_source=info.get("credential_source"),
quota_project_id=info.get("quota_project_id"),
workforce_pool_user_project=info.get("workforce_pool_user_project"),
**kwargs
)

@classmethod
def from_file(cls, filename, **kwargs):
"""Creates an Pluggable Credentials instance from an external account json file.
Args:
filename (str): The path to the Pluggable external account json file.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.pluggable.Credentials: The constructed
credentials.
"""
with io.open(filename, "r", encoding="utf-8") as json_file:
data = json.load(json_file)
return cls.from_info(data, **kwargs)

def _parse_subject_token(self, response):
if "version" not in response:
raise ValueError("The executable response is missing the version field.")
if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION:
raise exceptions.RefreshError(
"Executable returned unsupported version {}.".format(
response["version"]
)
)
if "success" not in response:
raise ValueError("The executable response is missing the success field.")
if not response["success"]:
if "code" not in response or "message" not in response:
raise ValueError(
"Error code and message fields are required in the response."
)
raise exceptions.RefreshError(
"Executable returned unsuccessful response: code: {}, message: {}.".format(
response["code"], response["message"]
)
)
if "expiration_time" not in response:
raise ValueError(
"The executable response is missing the expiration_time field."
)
if response["expiration_time"] < time.time():
raise exceptions.RefreshError(
"The token returned by the executable is expired."
)
if "token_type" not in response:
raise ValueError("The executable response is missing the token_type field.")
if (
response["token_type"] == "urn:ietf:params:oauth:token-type:jwt"
or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token"
): # OIDC
return response["id_token"]
elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML
return response["saml_response"]
else:
raise exceptions.RefreshError("Executable returned unsupported token type.")
Loading

0 comments on commit 62daa73

Please sign in to comment.