Skip to content

Commit

Permalink
feat: use self-signed jwt for service account (#665)
Browse files Browse the repository at this point in the history
  • Loading branch information
busunkim96 committed Feb 1, 2021
1 parent 3ca1039 commit bf5ce0c
Show file tree
Hide file tree
Showing 15 changed files with 373 additions and 38 deletions.
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):
"""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: 19 additions & 1 deletion 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.
default_host (Optional[str]): A host like "pubsub.googleapis.com".
This is used when a self-signed JWT is created from service
account credentials.
"""

def __init__(self, credentials, request):
def __init__(self, credentials, request, default_host=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._default_host = default_host

def _get_authorization_headers(self, context):
"""Gets the authorization headers for a request.
Expand All @@ -69,6 +74,19 @@ 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.
# A default host must be explicitly provided since it cannot always
# be determined from the context.service_url.
if (
isinstance(self._credentials, service_account.Credentials)
and self._default_host
):
self._credentials._create_self_signed_jwt(
"https://{}/".format(self._default_host)
)

self._credentials.before_request(
self._request, context.method_name, context.service_url, headers
)
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

0 comments on commit bf5ce0c

Please sign in to comment.