Skip to content

Commit

Permalink
feat: implements the OAuth token exchange spec based on rfc8693 (#598)
Browse files Browse the repository at this point in the history
* refactor: split 'with_quota_project' into separate base class (#561)
* feat: implements the OAuth token exchange spec based on rfc8693
  • Loading branch information
bojeil-google authored Sep 10, 2020
1 parent 57f9922 commit 1b45700
Show file tree
Hide file tree
Showing 12 changed files with 584 additions and 33 deletions.
Binary file added .DS_Store
Binary file not shown.
6 changes: 4 additions & 2 deletions google/auth/app_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def get_project_id():
return app_identity.get_application_id()


class Credentials(credentials.Scoped, credentials.Signing, credentials.Credentials):
class Credentials(
credentials.Scoped, credentials.Signing, credentials.CredentialsWithQuotaProject
):
"""App Engine standard environment credentials.
These credentials use the App Engine App Identity API to obtain access
Expand Down Expand Up @@ -145,7 +147,7 @@ def with_scopes(self, scopes):
quota_project_id=self.quota_project_id,
)

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
scopes=self._scopes,
Expand Down
8 changes: 4 additions & 4 deletions google/auth/compute_engine/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from google.oauth2 import _client


class Credentials(credentials.ReadOnlyScoped, credentials.Credentials):
class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject):
"""Compute Engine Credentials.
These credentials use the Google Compute Engine metadata server to obtain
Expand Down Expand Up @@ -118,7 +118,7 @@ def requires_scopes(self):
"""False: Compute Engine credentials can not be scoped."""
return False

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
service_account_email=self._service_account_email,
Expand All @@ -130,7 +130,7 @@ def with_quota_project(self, quota_project_id):
_DEFAULT_TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token"


class IDTokenCredentials(credentials.Credentials, credentials.Signing):
class IDTokenCredentials(credentials.CredentialsWithQuotaProject, credentials.Signing):
"""Open ID Connect ID Token-based service account credentials.
These credentials relies on the default service account of a GCE instance.
Expand Down Expand Up @@ -254,7 +254,7 @@ def with_target_audience(self, target_audience):
quota_project_id=self._quota_project_id,
)

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):

# since the signer is already instantiated,
Expand Down
9 changes: 5 additions & 4 deletions google/auth/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ def before_request(self, request, method, url, headers):
self.refresh(request)
self.apply(headers)


class CredentialsWithQuotaProject(Credentials):
"""Abstract base for credentials supporting ``with_quota_project`` factory"""

def with_quota_project(self, quota_project_id):
"""Returns a copy of these credentials with a modified quota project
Expand All @@ -143,7 +147,7 @@ def with_quota_project(self, quota_project_id):
Returns:
google.oauth2.credentials.Credentials: A new credentials instance.
"""
raise NotImplementedError("This class does not support quota project.")
raise NotImplementedError("This credential does not support quota project.")


class AnonymousCredentials(Credentials):
Expand Down Expand Up @@ -182,9 +186,6 @@ def apply(self, headers, token=None):
def before_request(self, request, method, url, headers):
"""Anonymous credentials do nothing to the request."""

def with_quota_project(self, quota_project_id):
raise ValueError("Anonymous credentials don't support quota project.")


@six.add_metaclass(abc.ABCMeta)
class ReadOnlyScoped(object):
Expand Down
8 changes: 4 additions & 4 deletions google/auth/impersonated_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def _make_iam_token_request(request, principal, headers, body):
six.raise_from(new_exc, caught_exc)


class Credentials(credentials.Credentials, credentials.Signing):
class Credentials(credentials.CredentialsWithQuotaProject, credentials.Signing):
"""This module defines impersonated credentials which are essentially
impersonated identities.
Expand Down Expand Up @@ -293,7 +293,7 @@ def service_account_email(self):
def signer(self):
return self

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
self._source_credentials,
Expand All @@ -305,7 +305,7 @@ def with_quota_project(self, quota_project_id):
)


class IDTokenCredentials(credentials.Credentials):
class IDTokenCredentials(credentials.CredentialsWithQuotaProject):
"""Open ID Connect ID Token-based service account credentials.
"""
Expand Down Expand Up @@ -359,7 +359,7 @@ def with_include_email(self, include_email):
quota_project_id=self._quota_project_id,
)

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
target_credentials=self._target_credentials,
Expand Down
10 changes: 6 additions & 4 deletions google/auth/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ def decode(token, certs=None, verify=True, audience=None):
return payload


class Credentials(google.auth.credentials.Signing, google.auth.credentials.Credentials):
class Credentials(
google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject
):
"""Credentials that use a JWT as the bearer token.
These credentials require an "audience" claim. This claim identifies the
Expand Down Expand Up @@ -493,7 +495,7 @@ def with_claims(
quota_project_id=self._quota_project_id,
)

@_helpers.copy_docstring(google.auth.credentials.Credentials)
@_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
self._signer,
Expand Down Expand Up @@ -554,7 +556,7 @@ def signer(self):


class OnDemandCredentials(
google.auth.credentials.Signing, google.auth.credentials.Credentials
google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject
):
"""On-demand JWT credentials.
Expand Down Expand Up @@ -721,7 +723,7 @@ def with_claims(self, issuer=None, subject=None, additional_claims=None):
quota_project_id=self._quota_project_id,
)

@_helpers.copy_docstring(google.auth.credentials.Credentials)
@_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):

return self.__class__(
Expand Down
8 changes: 4 additions & 4 deletions google/oauth2/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"


class Credentials(credentials.ReadOnlyScoped, credentials.Credentials):
class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject):
"""Credentials using OAuth 2.0 access and refresh tokens.
The credentials are considered immutable. If you want to modify the
Expand Down Expand Up @@ -161,7 +161,7 @@ def requires_scopes(self):
the initial token is requested and can not be changed."""
return False

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):

return self.__class__(
Expand Down Expand Up @@ -305,7 +305,7 @@ def to_json(self, strip=None):
return json.dumps(prep)


class UserAccessTokenCredentials(credentials.Credentials):
class UserAccessTokenCredentials(credentials.CredentialsWithQuotaProject):
"""Access token credentials for user account.
Obtain the access token for a given user account or the current active
Expand Down Expand Up @@ -336,7 +336,7 @@ def with_account(self, account):
"""
return self.__class__(account=account, quota_project_id=self._quota_project_id)

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(account=self._account, quota_project_id=quota_project_id)

Expand Down
10 changes: 6 additions & 4 deletions google/oauth2/service_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds


class Credentials(credentials.Signing, credentials.Scoped, credentials.Credentials):
class Credentials(
credentials.Signing, credentials.Scoped, credentials.CredentialsWithQuotaProject
):
"""Service account credentials
Usually, you'll create these credentials with one of the helper
Expand Down Expand Up @@ -306,7 +308,7 @@ def with_claims(self, additional_claims):
additional_claims=new_additional_claims,
)

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):

return self.__class__(
Expand Down Expand Up @@ -375,7 +377,7 @@ def signer_email(self):
return self._service_account_email


class IDTokenCredentials(credentials.Signing, credentials.Credentials):
class IDTokenCredentials(credentials.Signing, credentials.CredentialsWithQuotaProject):
"""Open ID Connect ID Token-based service account credentials.
These credentials are largely similar to :class:`.Credentials`, but instead
Expand Down Expand Up @@ -533,7 +535,7 @@ def with_target_audience(self, target_audience):
quota_project_id=self.quota_project_id,
)

@_helpers.copy_docstring(credentials.Credentials)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
self._signer,
Expand Down
155 changes: 155 additions & 0 deletions google/oauth2/sts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Copyright 2020 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.

"""OAuth 2.0 Token Exchange Spec.
This module defines a token exchange utility based on the `OAuth 2.0 Token
Exchange`_ spec. This will be mainly used to exchange external credentials
for GCP access tokens in workload identity pools to access Google APIs.
The implementation will support various types of client authentication as
allowed in the spec.
A deviation on the spec will be for additional Google specific options that
cannot be easily mapped to parameters defined in the RFC.
The returned dictionary response will be based on the `rfc8693 section 2.2.1`_
spec JSON response.
.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693
.. _rfc8693 section 2.2.1: https://tools.ietf.org/html/rfc8693#section-2.2.1
"""

import json

from six.moves import http_client
from six.moves import urllib

from google.oauth2 import utils


_URLENCODED_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}


class Client(utils.OAuthClientAuthHandler):
"""Implements the OAuth 2.0 token exchange spec based on
https://tools.ietf.org/html/rfc8693.
"""

def __init__(self, token_exchange_endpoint, client_authentication=None):
"""Initializes an STS client instance.
Args:
token_exchange_endpoint (str): The token exchange endpoint.
client_authentication (Optional(google.oauth2.oauth2_utils.ClientAuthentication)):
The optional OAuth client authentication credentials if available.
"""
super(Client, self).__init__(client_authentication)
self._token_exchange_endpoint = token_exchange_endpoint

def exchange_token(
self,
request,
grant_type,
subject_token,
subject_token_type,
resource=None,
audience=None,
scopes=None,
requested_token_type=None,
actor_token=None,
actor_token_type=None,
additional_options=None,
additional_headers=None,
):
"""Exchanges the provided token for another type of token based on the
rfc8693 spec.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
grant_type (str): The OAuth 2.0 token exchange grant type.
subject_token (str): The OAuth 2.0 token exchange subject token.
subject_token_type (str): The OAuth 2.0 token exchange subject token type.
resource (Optional[str]): The optional OAuth 2.0 token exchange resource field.
audience (Optional[str]): The optional OAuth 2.0 token exchange audience field.
scopes (Optional[Sequence[str]]): The optional list of scopes to use.
requested_token_type (Optional[str]): The optional OAuth 2.0 token exchange requested
token type.
actor_token (Optional[str]): The optional OAuth 2.0 token exchange actor token.
actor_token_type (Optional[str]): The optional OAuth 2.0 token exchange actor token type.
additional_options (Optional[Mapping[str, str]]): The optional additional
non-standard Google specific options.
additional_headers (Optional[Mapping[str, str]]): The optional additional
headers to pass to the token exchange endpoint.
Returns:
Mapping[str, str]: The token exchange JSON-decoded response data containing
the requested token and its expiration time.
Raises:
google.auth.exceptions.OAuthError: If the token endpoint returned
an error.
"""
# Initialize request headers.
headers = _URLENCODED_HEADERS.copy()
# Inject additional headers.
if additional_headers:
for k, v in dict(additional_headers).items():
headers[k] = v
# Initialize request body.
request_body = {
"grant_type": grant_type,
"resource": resource,
"audience": audience,
"scope": " ".join(scopes or []),
"requested_token_type": requested_token_type,
"subject_token": subject_token,
"subject_token_type": subject_token_type,
"actor_token": actor_token,
"actor_token_type": actor_token_type,
"options": None,
}
# Add additional non-standard options.
if additional_options:
request_body["options"] = urllib.parse.quote(json.dumps(additional_options))
# Remove empty fields in request body.
for k, v in dict(request_body).items():
if v is None or v == "":
del request_body[k]
# Apply OAuth client authentication.
self.apply_client_authentication_options(headers, request_body)

# Execute request.
response = request(
url=self._token_exchange_endpoint,
method="POST",
headers=headers,
body=urllib.parse.urlencode(request_body).encode("utf-8"),
)

response_body = (
response.data.decode("utf-8")
if hasattr(response.data, "decode")
else response.data
)

# If non-200 response received, translate to OAuthError exception.
if response.status != http_client.OK:
utils.handle_error_response(response_body)

response_data = json.loads(response_body)

# Return successful response.
return response_data
Loading

0 comments on commit 1b45700

Please # to comment.