Skip to content

Commit

Permalink
Add IAM signer (#108)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Wayne Parrott committed Feb 16, 2017
1 parent b0c6d19 commit 924191c
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 0 deletions.
7 changes: 7 additions & 0 deletions docs/reference/google.auth.iam.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
google.auth.iam module
======================

.. automodule:: google.auth.iam
:members:
:inherited-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/reference/google.auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ Submodules
google.auth.crypt
google.auth.environment_vars
google.auth.exceptions
google.auth.iam
google.auth.jwt

1 change: 1 addition & 0 deletions google/auth/crypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class Signer(object):
def __init__(self, private_key, key_id=None):
self._key = private_key
self.key_id = key_id
"""Optional[str]: The key ID used to identify this private key."""

def sign(self, message):
"""Signs a message.
Expand Down
117 changes: 117 additions & 0 deletions google/auth/iam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Copyright 2017 Google Inc.
#
# 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.

"""Tools for using the Google `Cloud Identity and Access Management (IAM)
API`_'s auth-related functionality.
.. _Cloud Identity and Access Management (IAM) API:
https://cloud.google.com/iam/docs/
"""

import base64
import json

from six.moves import http_client

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

_IAM_API_ROOT_URI = 'https://iam.googleapis.com/v1'
_SIGN_BLOB_URI = (
_IAM_API_ROOT_URI + '/projects/-/serviceAccounts/{}:signBlob?alt=json')


class Signer(object):
"""Signs messages using the IAM `signBlob API`_.
This is useful when you need to sign bytes but do not have access to the
credential's private key file.
.. warning::
The IAM API signs bytes using Google-managed keys. Because of this
it's possible that the key used to sign bytes will change. In some
cases this change can occur between successive calls to :attr:`key_id`
and :meth:`sign`. This could result in a signature that was signed
with a different key than the one indicated by :attr:`key_id`. It's
recommended that if you use this in your code that you account for
this behavior by building in retry logic.
.. _signBlob API:
https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts
/signBlob
"""

def __init__(self, request, credentials, service_account_email):
"""
Args:
request (google.auth.transport.Request): The object used to make
HTTP requests.
credentials (google.auth.credentials.Credentials): The credentials
that will be used to authenticate the request to the IAM API.
The credentials must have of one the following scopes:
- https://www.googleapis.com/auth/iam
- https://www.googleapis.com/auth/cloud-platform
service_account_email (str): The service account email identifying
which service account to use to sign bytes. Often, this can
be the same as the service account email in the given
credentials.
"""
self._request = request
self._credentials = credentials
self._service_account_email = service_account_email

def _make_signing_request(self, message):
"""Makes a request to the API signBlob API."""
message = _helpers.to_bytes(message)

method = 'POST'
url = _SIGN_BLOB_URI.format(self._service_account_email)
headers = {}
body = json.dumps({
'bytesToSign': base64.b64encode(message).decode('utf-8'),
})

self._credentials.before_request(self._request, method, url, headers)
response = self._request(
url=url, method=method, body=body, headers=headers)

if response.status != http_client.OK:
raise exceptions.TransportError(
'Error calling the IAM signBytes API: {}'.format(
response.data))

return json.loads(response.data.decode('utf-8'))

@property
def key_id(self):
"""Optional[str]: The key ID used to identify this private key.
.. note::
This makes an API request to the IAM API.
"""
response = self._make_signing_request('')
return response['keyId']

def sign(self, message):
"""Signs a message.
Args:
message (Union[str, bytes]): The message to be signed.
Returns:
bytes: The signature of the message.
"""
response = self._make_signing_request(message)
return base64.b64decode(response['signature'])
100 changes: 100 additions & 0 deletions tests/test_iam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright 2017 Google Inc.
#
# 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.

import base64
import datetime
import json

import mock
import pytest
from six.moves import http_client

from google.auth import exceptions
from google.auth import iam
from google.auth import transport
import google.auth.credentials


def make_request(status, data=None):
response = mock.Mock(spec=transport.Response)
response.status = status

if data is not None:
response.data = json.dumps(data).encode('utf-8')

return mock.Mock(return_value=response, spec=transport.Request)


def make_credentials():
class CredentialsImpl(google.auth.credentials.Credentials):
def __init__(self):
super(CredentialsImpl, self).__init__()
self.token = 'token'
# Force refresh
self.expiry = datetime.datetime.min

def refresh(self, request):
pass

return CredentialsImpl()


class TestSigner(object):
def test_constructor(self):
request = mock.sentinel.request
credentials = mock.Mock(spec=google.auth.credentials.Credentials)

signer = iam.Signer(
request, credentials, mock.sentinel.service_account_email)

assert signer._request == mock.sentinel.request
assert signer._credentials == credentials
assert (signer._service_account_email ==
mock.sentinel.service_account_email)

def test_key_id(self):
key_id = '123'
request = make_request(http_client.OK, data={'keyId': key_id})
credentials = make_credentials()

signer = iam.Signer(
request, credentials, mock.sentinel.service_account_email)

assert signer.key_id == '123'
auth_header = request.call_args[1]['headers']['authorization']
assert auth_header == 'Bearer token'

def test_sign_bytes(self):
signature = b'DEADBEEF'
encoded_signature = base64.b64encode(signature).decode('utf-8')
request = make_request(
http_client.OK, data={'signature': encoded_signature})
credentials = make_credentials()

signer = iam.Signer(
request, credentials, mock.sentinel.service_account_email)

returned_signature = signer.sign('123')

assert returned_signature == signature

def test_sign_bytes_failure(self):
request = make_request(http_client.UNAUTHORIZED)
credentials = make_credentials()

signer = iam.Signer(
request, credentials, mock.sentinel.service_account_email)

with pytest.raises(exceptions.TransportError):
signer.sign('123')

0 comments on commit 924191c

Please sign in to comment.