Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit 924191c

Browse files
author
Jon Wayne Parrott
authored
Add IAM signer (#108)
1 parent b0c6d19 commit 924191c

5 files changed

Lines changed: 226 additions & 0 deletions

File tree

docs/reference/google.auth.iam.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
google.auth.iam module
2+
======================
3+
4+
.. automodule:: google.auth.iam
5+
:members:
6+
:inherited-members:
7+
:show-inheritance:

docs/reference/google.auth.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ Submodules
2424
google.auth.crypt
2525
google.auth.environment_vars
2626
google.auth.exceptions
27+
google.auth.iam
2728
google.auth.jwt
2829

google/auth/crypt.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ class Signer(object):
182182
def __init__(self, private_key, key_id=None):
183183
self._key = private_key
184184
self.key_id = key_id
185+
"""Optional[str]: The key ID used to identify this private key."""
185186

186187
def sign(self, message):
187188
"""Signs a message.

google/auth/iam.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tools for using the Google `Cloud Identity and Access Management (IAM)
16+
API`_'s auth-related functionality.
17+
18+
.. _Cloud Identity and Access Management (IAM) API:
19+
https://cloud.google.com/iam/docs/
20+
"""
21+
22+
import base64
23+
import json
24+
25+
from six.moves import http_client
26+
27+
from google.auth import _helpers
28+
from google.auth import exceptions
29+
30+
_IAM_API_ROOT_URI = 'https://iam.googleapis.com/v1'
31+
_SIGN_BLOB_URI = (
32+
_IAM_API_ROOT_URI + '/projects/-/serviceAccounts/{}:signBlob?alt=json')
33+
34+
35+
class Signer(object):
36+
"""Signs messages using the IAM `signBlob API`_.
37+
38+
This is useful when you need to sign bytes but do not have access to the
39+
credential's private key file.
40+
41+
.. warning::
42+
The IAM API signs bytes using Google-managed keys. Because of this
43+
it's possible that the key used to sign bytes will change. In some
44+
cases this change can occur between successive calls to :attr:`key_id`
45+
and :meth:`sign`. This could result in a signature that was signed
46+
with a different key than the one indicated by :attr:`key_id`. It's
47+
recommended that if you use this in your code that you account for
48+
this behavior by building in retry logic.
49+
50+
.. _signBlob API:
51+
https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts
52+
/signBlob
53+
"""
54+
55+
def __init__(self, request, credentials, service_account_email):
56+
"""
57+
Args:
58+
request (google.auth.transport.Request): The object used to make
59+
HTTP requests.
60+
credentials (google.auth.credentials.Credentials): The credentials
61+
that will be used to authenticate the request to the IAM API.
62+
The credentials must have of one the following scopes:
63+
64+
- https://www.googleapis.com/auth/iam
65+
- https://www.googleapis.com/auth/cloud-platform
66+
service_account_email (str): The service account email identifying
67+
which service account to use to sign bytes. Often, this can
68+
be the same as the service account email in the given
69+
credentials.
70+
"""
71+
self._request = request
72+
self._credentials = credentials
73+
self._service_account_email = service_account_email
74+
75+
def _make_signing_request(self, message):
76+
"""Makes a request to the API signBlob API."""
77+
message = _helpers.to_bytes(message)
78+
79+
method = 'POST'
80+
url = _SIGN_BLOB_URI.format(self._service_account_email)
81+
headers = {}
82+
body = json.dumps({
83+
'bytesToSign': base64.b64encode(message).decode('utf-8'),
84+
})
85+
86+
self._credentials.before_request(self._request, method, url, headers)
87+
response = self._request(
88+
url=url, method=method, body=body, headers=headers)
89+
90+
if response.status != http_client.OK:
91+
raise exceptions.TransportError(
92+
'Error calling the IAM signBytes API: {}'.format(
93+
response.data))
94+
95+
return json.loads(response.data.decode('utf-8'))
96+
97+
@property
98+
def key_id(self):
99+
"""Optional[str]: The key ID used to identify this private key.
100+
101+
.. note::
102+
This makes an API request to the IAM API.
103+
"""
104+
response = self._make_signing_request('')
105+
return response['keyId']
106+
107+
def sign(self, message):
108+
"""Signs a message.
109+
110+
Args:
111+
message (Union[str, bytes]): The message to be signed.
112+
113+
Returns:
114+
bytes: The signature of the message.
115+
"""
116+
response = self._make_signing_request(message)
117+
return base64.b64decode(response['signature'])

tests/test_iam.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import base64
16+
import datetime
17+
import json
18+
19+
import mock
20+
import pytest
21+
from six.moves import http_client
22+
23+
from google.auth import exceptions
24+
from google.auth import iam
25+
from google.auth import transport
26+
import google.auth.credentials
27+
28+
29+
def make_request(status, data=None):
30+
response = mock.Mock(spec=transport.Response)
31+
response.status = status
32+
33+
if data is not None:
34+
response.data = json.dumps(data).encode('utf-8')
35+
36+
return mock.Mock(return_value=response, spec=transport.Request)
37+
38+
39+
def make_credentials():
40+
class CredentialsImpl(google.auth.credentials.Credentials):
41+
def __init__(self):
42+
super(CredentialsImpl, self).__init__()
43+
self.token = 'token'
44+
# Force refresh
45+
self.expiry = datetime.datetime.min
46+
47+
def refresh(self, request):
48+
pass
49+
50+
return CredentialsImpl()
51+
52+
53+
class TestSigner(object):
54+
def test_constructor(self):
55+
request = mock.sentinel.request
56+
credentials = mock.Mock(spec=google.auth.credentials.Credentials)
57+
58+
signer = iam.Signer(
59+
request, credentials, mock.sentinel.service_account_email)
60+
61+
assert signer._request == mock.sentinel.request
62+
assert signer._credentials == credentials
63+
assert (signer._service_account_email ==
64+
mock.sentinel.service_account_email)
65+
66+
def test_key_id(self):
67+
key_id = '123'
68+
request = make_request(http_client.OK, data={'keyId': key_id})
69+
credentials = make_credentials()
70+
71+
signer = iam.Signer(
72+
request, credentials, mock.sentinel.service_account_email)
73+
74+
assert signer.key_id == '123'
75+
auth_header = request.call_args[1]['headers']['authorization']
76+
assert auth_header == 'Bearer token'
77+
78+
def test_sign_bytes(self):
79+
signature = b'DEADBEEF'
80+
encoded_signature = base64.b64encode(signature).decode('utf-8')
81+
request = make_request(
82+
http_client.OK, data={'signature': encoded_signature})
83+
credentials = make_credentials()
84+
85+
signer = iam.Signer(
86+
request, credentials, mock.sentinel.service_account_email)
87+
88+
returned_signature = signer.sign('123')
89+
90+
assert returned_signature == signature
91+
92+
def test_sign_bytes_failure(self):
93+
request = make_request(http_client.UNAUTHORIZED)
94+
credentials = make_credentials()
95+
96+
signer = iam.Signer(
97+
request, credentials, mock.sentinel.service_account_email)
98+
99+
with pytest.raises(exceptions.TransportError):
100+
signer.sign('123')

0 commit comments

Comments
 (0)