Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use self-signed jwt for service account #665

Merged
merged 19 commits into from Feb 1, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 12 additions & 4 deletions google/auth/_default.py
Expand Up @@ -69,7 +69,9 @@ def _warn_about_problematic_credentials(credentials):
warnings.warn(_CLOUD_SDK_CREDENTIALS_WARNING)


def load_credentials_from_file(filename, scopes=None, quota_project_id=None):
def load_credentials_from_file(
filename, scopes=None, default_scopes=None, quota_project_id=None
):
"""Loads Google credentials from a file.

The credentials file must be a service account key or stored authorized
Expand All @@ -80,6 +82,8 @@ def load_credentials_from_file(filename, scopes=None, quota_project_id=None):
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If
specified, the credentials will automatically be scoped if
necessary
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
quota_project_id (Optional[str]): The project ID used for
quota and billing.

Expand Down Expand Up @@ -132,7 +136,7 @@ def load_credentials_from_file(filename, scopes=None, quota_project_id=None):

try:
credentials = service_account.Credentials.from_service_account_info(
info, scopes=scopes
info, scopes=scopes, default_scopes=default_scopes
)
except ValueError as caught_exc:
msg = "Failed to load service account credentials from {}".format(filename)
Expand Down Expand Up @@ -248,7 +252,7 @@ def _get_gce_credentials(request=None):
return None, None


def default(scopes=None, request=None, quota_project_id=None):
def default(scopes=None, request=None, quota_project_id=None, default_scopes=None):
"""Gets the default credentials for the current environment.

`Application Default Credentials`_ provides an easy way to obtain
Expand Down Expand Up @@ -312,6 +316,8 @@ def default(scopes=None, request=None, quota_project_id=None):
use the standard library http client to make requests.
quota_project_id (Optional[str]): The project ID used for
quota and billing.
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
Returns:
Tuple[~google.auth.credentials.Credentials, Optional[str]]:
the current environment's credentials and project ID. Project ID
Expand Down Expand Up @@ -339,7 +345,9 @@ def default(scopes=None, request=None, quota_project_id=None):
for checker in checkers:
credentials, project_id = checker()
if credentials is not None:
credentials = with_scopes_if_required(credentials, scopes)
credentials = with_scopes_if_required(
credentials, scopes, default_scopes=default_scopes
)
if quota_project_id:
credentials = credentials.with_quota_project(quota_project_id)

Expand Down
21 changes: 15 additions & 6 deletions google/auth/app_engine.py
Expand Up @@ -86,11 +86,19 @@ class Credentials(
tokens.
"""

def __init__(self, scopes=None, service_account_id=None, quota_project_id=None):
def __init__(
self,
scopes=None,
default_scopes=None,
service_account_id=None,
quota_project_id=None,
):
"""
Args:
scopes (Sequence[str]): Scopes to request from the App Identity
API.
default_scopes (Sequence[str]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
service_account_id (str): The service account ID passed into
:func:`google.appengine.api.app_identity.get_access_token`.
If not specified, the default application service account
Expand All @@ -109,16 +117,16 @@ def __init__(self, scopes=None, service_account_id=None, quota_project_id=None):

super(Credentials, self).__init__()
self._scopes = scopes
self._default_scopes = default_scopes
self._service_account_id = service_account_id
self._signer = Signer()
self._quota_project_id = quota_project_id

@_helpers.copy_docstring(credentials.Credentials)
def refresh(self, request):
scopes = self._scopes if self._scopes is not None else self._default_scopes
# pylint: disable=unused-argument
token, ttl = app_identity.get_access_token(
self._scopes, self._service_account_id
)
token, ttl = app_identity.get_access_token(scopes, self._service_account_id)
expiry = datetime.datetime.utcfromtimestamp(ttl)

self.token, self.expiry = token, expiry
Expand All @@ -137,12 +145,13 @@ def requires_scopes(self):
Returns:
bool: True if there are no scopes set otherwise False.
"""
return not self._scopes
return not self._scopes and not self._default_scopes

@_helpers.copy_docstring(credentials.Scoped)
def with_scopes(self, scopes):
def with_scopes(self, scopes, default_scopes=None):
return self.__class__(
scopes=scopes,
default_scopes=default_scopes,
service_account_id=self._service_account_id,
quota_project_id=self.quota_project_id,
)
Expand Down
18 changes: 13 additions & 5 deletions google/auth/compute_engine/credentials.py
Expand Up @@ -52,7 +52,11 @@ class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject):
"""

def __init__(
self, service_account_email="default", quota_project_id=None, scopes=None
self,
service_account_email="default",
quota_project_id=None,
scopes=None,
default_scopes=None,
):
"""
Args:
Expand All @@ -61,11 +65,15 @@ def __init__(
accounts.
quota_project_id (Optional[str]): The project ID used for quota and
billing.
scopes (Optional[Sequence[str]]): The list of scopes for the credentials.
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
"""
super(Credentials, self).__init__()
self._service_account_email = service_account_email
self._quota_project_id = quota_project_id
self._scopes = scopes
self._default_scopes = default_scopes

def _retrieve_info(self, request):
"""Retrieve information about the service account.
Expand Down Expand Up @@ -98,12 +106,11 @@ def refresh(self, request):
service can't be reached if if the instance has not
credentials.
"""
scopes = self._scopes if self._scopes is not None else self._default_scopes
try:
self._retrieve_info(request)
self.token, self.expiry = _metadata.get_service_account_token(
request,
service_account=self._service_account_email,
scopes=self._scopes,
request, service_account=self._service_account_email, scopes=scopes
)
except exceptions.TransportError as caught_exc:
new_exc = exceptions.RefreshError(caught_exc)
Expand Down Expand Up @@ -131,12 +138,13 @@ def with_quota_project(self, quota_project_id):
)

@_helpers.copy_docstring(credentials.Scoped)
def with_scopes(self, scopes):
def with_scopes(self, scopes, default_scopes=None):
# Compute Engine credentials can not be scoped (the metadata service
# ignores the scopes parameter). App Engine, Cloud Run and Flex support
# requesting scopes.
return self.__class__(
scopes=scopes,
default_scopes=default_scopes,
service_account_email=self._service_account_email,
quota_project_id=self._quota_project_id,
)
Expand Down
19 changes: 15 additions & 4 deletions google/auth/credentials.py
Expand Up @@ -220,12 +220,18 @@ class ReadOnlyScoped(object):
def __init__(self):
super(ReadOnlyScoped, self).__init__()
self._scopes = None
self._default_scopes = None

@property
def scopes(self):
"""Sequence[str]: the credentials' current set of scopes."""
return self._scopes

@property
def default_scopes(self):
"""Sequence[str]: the credentials' current set of default scopes."""
return self._default_scopes

@abc.abstractproperty
def requires_scopes(self):
"""True if these credentials require scopes to obtain an access token.
Expand All @@ -244,7 +250,10 @@ def has_scopes(self, scopes):
Returns:
bool: True if the credentials have the given scopes.
"""
return set(scopes).issubset(set(self._scopes or []))
credential_scopes = (
self._scopes if self._scopes is not None else self._default_scopes
)
return set(scopes).issubset(set(credential_scopes or []))


class Scoped(ReadOnlyScoped):
Expand Down Expand Up @@ -277,7 +286,7 @@ class Scoped(ReadOnlyScoped):
"""

@abc.abstractmethod
def with_scopes(self, scopes):
def with_scopes(self, scopes, default_scopes=None):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is adding an additional optional parameter to an abstract method a breaking change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""Create a copy of these credentials with the specified scopes.

Args:
Expand All @@ -292,7 +301,7 @@ def with_scopes(self, scopes):
raise NotImplementedError("This class does not require scoping.")


def with_scopes_if_required(credentials, scopes):
def with_scopes_if_required(credentials, scopes, default_scopes=None):
"""Creates a copy of the credentials with scopes if scoping is required.

This helper function is useful when you do not know (or care to know) the
Expand All @@ -306,14 +315,16 @@ def with_scopes_if_required(credentials, scopes):
credentials (google.auth.credentials.Credentials): The credentials to
scope if necessary.
scopes (Sequence[str]): The list of scopes to use.
default_scopes (Sequence[str]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.

Returns:
google.auth.credentials.Credentials: Either a new set of scoped
credentials, or the passed in credentials instance if no scoping
was required.
"""
if isinstance(credentials, Scoped) and credentials.requires_scopes:
return credentials.with_scopes(scopes)
return credentials.with_scopes(scopes, default_scopes=default_scopes)
else:
return credentials

Expand Down
20 changes: 18 additions & 2 deletions google/auth/transport/grpc.py
Expand Up @@ -24,6 +24,7 @@
from google.auth import environment_vars
from google.auth import exceptions
from google.auth.transport import _mtls_helper
from google.oauth2 import service_account

try:
import grpc
Expand Down Expand Up @@ -51,15 +52,19 @@ class AuthMetadataPlugin(grpc.AuthMetadataPlugin):
add to requests.
request (google.auth.transport.Request): A HTTP transport request
object used to refresh credentials as needed.
audience (Optional[str]): An audience like "pubsub.googleapis.com".
busunkim96 marked this conversation as resolved.
Show resolved Hide resolved
This is used when a self-signed JWT is created from service
account credentials.
"""

def __init__(self, credentials, request):
def __init__(self, credentials, request, audience=None):
# pylint: disable=no-value-for-parameter
# pylint doesn't realize that the super method takes no arguments
# because this class is the same name as the superclass.
super(AuthMetadataPlugin, self).__init__()
self._credentials = credentials
self._request = request
self._audience = audience

def _get_authorization_headers(self, context):
"""Gets the authorization headers for a request.
Expand All @@ -69,6 +74,17 @@ def _get_authorization_headers(self, context):
to add to the request.
"""
headers = {}

# https://google.aip.dev/auth/4111
# Attempt to use self-signed JWTs when a service account is used.
# An audience must be explicitly provided since it cannot always
# be determined from the context.service_url.
if (
isinstance(self._credentials, service_account.Credentials)
and self._audience
):
self._credentials._create_self_signed_jwt(f"https://{self._audience}")

self._credentials.before_request(
self._request, context.method_name, context.service_url, headers
)
Expand All @@ -92,7 +108,7 @@ def secure_authorized_channel(
target,
ssl_credentials=None,
client_cert_callback=None,
**kwargs
**kwargs,
):
"""Creates a secure authorized gRPC channel.

Expand Down
14 changes: 11 additions & 3 deletions google/oauth2/credentials.py
Expand Up @@ -66,6 +66,7 @@ def __init__(
client_id=None,
client_secret=None,
scopes=None,
default_scopes=None,
quota_project_id=None,
expiry=None,
):
Expand All @@ -91,6 +92,8 @@ def __init__(
token if refresh information is provided (e.g. The refresh
token scopes are a superset of this or contain a wild card
scope like 'https://www.googleapis.com/auth/any-api').
default_scopes (Sequence[str]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
quota_project_id (Optional[str]): The project ID used for quota and billing.
This project may be different from the project used to
create the credentials.
Expand All @@ -101,6 +104,7 @@ def __init__(
self._refresh_token = refresh_token
self._id_token = id_token
self._scopes = scopes
self._default_scopes = default_scopes
self._token_uri = token_uri
self._client_id = client_id
self._client_secret = client_secret
Expand All @@ -121,6 +125,7 @@ def __setstate__(self, d):
self._refresh_token = d.get("_refresh_token")
self._id_token = d.get("_id_token")
self._scopes = d.get("_scopes")
self._default_scopes = d.get("_default_scopes")
self._token_uri = d.get("_token_uri")
self._client_id = d.get("_client_id")
self._client_secret = d.get("_client_secret")
Expand Down Expand Up @@ -180,6 +185,7 @@ def with_quota_project(self, quota_project_id):
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
default_scopes=self.default_scopes,
quota_project_id=quota_project_id,
)

Expand All @@ -197,22 +203,24 @@ def refresh(self, request):
"token_uri, client_id, and client_secret."
)

scopes = self._scopes if self._scopes is not None else self._default_scopes

access_token, refresh_token, expiry, grant_response = _client.refresh_grant(
request,
self._token_uri,
self._refresh_token,
self._client_id,
self._client_secret,
self._scopes,
scopes,
)

self.token = access_token
self.expiry = expiry
self._refresh_token = refresh_token
self._id_token = grant_response.get("id_token")

if self._scopes and "scopes" in grant_response:
requested_scopes = frozenset(self._scopes)
if scopes and "scopes" in grant_response:
requested_scopes = frozenset(scopes)
granted_scopes = frozenset(grant_response["scopes"].split())
scopes_requested_but_not_granted = requested_scopes - granted_scopes
if scopes_requested_but_not_granted:
Expand Down