Skip to content

Commit 15246b8

Browse files
feat: fetch id token from GCE metadata server (#462)
feat: fetch id token from GCE metadata server
1 parent b0f511d commit 15246b8

File tree

3 files changed

+279
-35
lines changed

3 files changed

+279
-35
lines changed

packages/google-auth/google/auth/compute_engine/credentials.py

Lines changed: 128 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,24 @@ class IDTokenCredentials(credentials.Credentials, credentials.Signing):
125125
126126
These credentials relies on the default service account of a GCE instance.
127127
128-
In order for this to work, the GCE instance must have been started with
128+
ID token can be requested from `GCE metadata server identity endpoint`_, IAM
129+
token endpoint or other token endpoints you specify. If metadata server
130+
identity endpoint is not used, the GCE instance must have been started with
129131
a service account that has access to the IAM Cloud API.
132+
133+
.. _GCE metadata server identity endpoint:
134+
https://cloud.google.com/compute/docs/instances/verifying-instance-identity
130135
"""
131136

132137
def __init__(
133138
self,
134139
request,
135140
target_audience,
136-
token_uri=_DEFAULT_TOKEN_URI,
141+
token_uri=None,
137142
additional_claims=None,
138143
service_account_email=None,
139144
signer=None,
145+
use_metadata_identity_endpoint=False,
140146
):
141147
"""
142148
Args:
@@ -154,29 +160,54 @@ def __init__(
154160
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
155161
In case the signer is specified, the request argument will be
156162
ignored.
163+
use_metadata_identity_endpoint (bool): Whether to use GCE metadata
164+
identity endpoint. For backward compatibility the default value
165+
is False. If set to True, ``token_uri``, ``additional_claims``,
166+
``service_account_email``, ``signer`` argument should not be set;
167+
otherwise ValueError will be raised.
168+
169+
Raises:
170+
ValueError:
171+
If ``use_metadata_identity_endpoint`` is set to True, and one of
172+
``token_uri``, ``additional_claims``, ``service_account_email``,
173+
``signer`` arguments is set.
157174
"""
158175
super(IDTokenCredentials, self).__init__()
159176

160-
if service_account_email is None:
161-
sa_info = _metadata.get_service_account_info(request)
162-
service_account_email = sa_info["email"]
163-
self._service_account_email = service_account_email
164-
165-
if signer is None:
166-
signer = iam.Signer(
167-
request=request,
168-
credentials=Credentials(),
169-
service_account_email=service_account_email,
170-
)
171-
self._signer = signer
172-
173-
self._token_uri = token_uri
177+
self._use_metadata_identity_endpoint = use_metadata_identity_endpoint
174178
self._target_audience = target_audience
175179

176-
if additional_claims is not None:
177-
self._additional_claims = additional_claims
180+
if use_metadata_identity_endpoint:
181+
if token_uri or additional_claims or service_account_email or signer:
182+
raise ValueError(
183+
"If use_metadata_identity_endpoint is set, token_uri, "
184+
"additional_claims, service_account_email, signer arguments"
185+
" must not be set"
186+
)
187+
self._token_uri = None
188+
self._additional_claims = None
189+
self._signer = None
190+
191+
if service_account_email is None:
192+
sa_info = _metadata.get_service_account_info(request)
193+
self._service_account_email = sa_info["email"]
178194
else:
179-
self._additional_claims = {}
195+
self._service_account_email = service_account_email
196+
197+
if not use_metadata_identity_endpoint:
198+
if signer is None:
199+
signer = iam.Signer(
200+
request=request,
201+
credentials=Credentials(),
202+
service_account_email=self._service_account_email,
203+
)
204+
self._signer = signer
205+
self._token_uri = token_uri or _DEFAULT_TOKEN_URI
206+
207+
if additional_claims is not None:
208+
self._additional_claims = additional_claims
209+
else:
210+
self._additional_claims = {}
180211

181212
def with_target_audience(self, target_audience):
182213
"""Create a copy of these credentials with the specified target
@@ -190,14 +221,22 @@ def with_target_audience(self, target_audience):
190221
"""
191222
# since the signer is already instantiated,
192223
# the request is not needed
193-
return self.__class__(
194-
None,
195-
service_account_email=self._service_account_email,
196-
token_uri=self._token_uri,
197-
target_audience=target_audience,
198-
additional_claims=self._additional_claims.copy(),
199-
signer=self.signer,
200-
)
224+
if self._use_metadata_identity_endpoint:
225+
return self.__class__(
226+
None,
227+
target_audience=target_audience,
228+
use_metadata_identity_endpoint=True,
229+
)
230+
else:
231+
return self.__class__(
232+
None,
233+
service_account_email=self._service_account_email,
234+
token_uri=self._token_uri,
235+
target_audience=target_audience,
236+
additional_claims=self._additional_claims.copy(),
237+
signer=self.signer,
238+
use_metadata_identity_endpoint=False,
239+
)
201240

202241
def _make_authorization_grant_assertion(self):
203242
"""Create the OAuth 2.0 assertion.
@@ -228,22 +267,76 @@ def _make_authorization_grant_assertion(self):
228267

229268
return token
230269

231-
@_helpers.copy_docstring(credentials.Credentials)
270+
def _call_metadata_identity_endpoint(self, request):
271+
"""Request ID token from metadata identity endpoint.
272+
273+
Args:
274+
request (google.auth.transport.Request): The object used to make
275+
HTTP requests.
276+
277+
Raises:
278+
google.auth.exceptions.RefreshError: If the Compute Engine metadata
279+
service can't be reached or if the instance has no credentials.
280+
ValueError: If extracting expiry from the obtained ID token fails.
281+
"""
282+
try:
283+
id_token = _metadata.get(
284+
request,
285+
"instance/service-accounts/default/identity?audience={}&format=full".format(
286+
self._target_audience
287+
),
288+
)
289+
except exceptions.TransportError as caught_exc:
290+
new_exc = exceptions.RefreshError(caught_exc)
291+
six.raise_from(new_exc, caught_exc)
292+
293+
_, payload, _, _ = jwt._unverified_decode(id_token)
294+
return id_token, payload["exp"]
295+
232296
def refresh(self, request):
233-
assertion = self._make_authorization_grant_assertion()
234-
access_token, expiry, _ = _client.id_token_jwt_grant(
235-
request, self._token_uri, assertion
236-
)
237-
self.token = access_token
238-
self.expiry = expiry
297+
"""Refreshes the ID token.
298+
299+
Args:
300+
request (google.auth.transport.Request): The object used to make
301+
HTTP requests.
302+
303+
Raises:
304+
google.auth.exceptions.RefreshError: If the credentials could
305+
not be refreshed.
306+
ValueError: If extracting expiry from the obtained ID token fails.
307+
"""
308+
if self._use_metadata_identity_endpoint:
309+
self.token, self.expiry = self._call_metadata_identity_endpoint(request)
310+
else:
311+
assertion = self._make_authorization_grant_assertion()
312+
access_token, expiry, _ = _client.id_token_jwt_grant(
313+
request, self._token_uri, assertion
314+
)
315+
self.token = access_token
316+
self.expiry = expiry
239317

240318
@property
241319
@_helpers.copy_docstring(credentials.Signing)
242320
def signer(self):
243321
return self._signer
244322

245-
@_helpers.copy_docstring(credentials.Signing)
246323
def sign_bytes(self, message):
324+
"""Signs the given message.
325+
326+
Args:
327+
message (bytes): The message to sign.
328+
329+
Returns:
330+
bytes: The message's cryptographic signature.
331+
332+
Raises:
333+
ValueError:
334+
Signer is not available if metadata identity endpoint is used.
335+
"""
336+
if self._use_metadata_identity_endpoint:
337+
raise ValueError(
338+
"Signer is not available if metadata identity endpoint is used"
339+
)
247340
return self._signer.sign(message)
248341

249342
@property

packages/google-auth/system_tests/test_compute_engine.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from google.auth import compute_engine
1919
from google.auth import _helpers
2020
from google.auth import exceptions
21+
from google.auth import jwt
2122
from google.auth.compute_engine import _metadata
2223

2324

@@ -48,3 +49,14 @@ def test_default(verify_refresh):
4849
assert project_id is not None
4950
assert isinstance(credentials, compute_engine.Credentials)
5051
verify_refresh(credentials)
52+
53+
54+
def test_id_token_from_metadata(http_request):
55+
credentials = compute_engine.IDTokenCredentials(
56+
http_request, "target_audience", use_metadata_identity_endpoint=True
57+
)
58+
credentials.refresh(http_request)
59+
60+
_, payload, _, _ = jwt._unverified_decode(credentials.token)
61+
assert payload["aud"] == "target_audience"
62+
assert payload["exp"] == credentials.expiry

packages/google-auth/tests/compute_engine/test_credentials.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@
2525
from google.auth.compute_engine import credentials
2626
from google.auth.transport import requests
2727

28+
SAMPLE_ID_TOKEN_EXP = 1584393400
29+
30+
# header: {"alg": "RS256", "typ": "JWT", "kid": "1"}
31+
# payload: {"iss": "issuer", "iat": 1584393348, "sub": "subject",
32+
# "exp": 1584393400,"aud": "audience"}
33+
SAMPLE_ID_TOKEN = (
34+
b"eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCIsICJraWQiOiAiMSJ9."
35+
b"eyJpc3MiOiAiaXNzdWVyIiwgImlhdCI6IDE1ODQzOTMzNDgsICJzdWIiO"
36+
b"iAic3ViamVjdCIsICJleHAiOiAxNTg0MzkzNDAwLCAiYXVkIjogImF1ZG"
37+
b"llbmNlIn0."
38+
b"OquNjHKhTmlgCk361omRo18F_uY-7y0f_AmLbzW062Q1Zr61HAwHYP5FM"
39+
b"316CK4_0cH8MUNGASsvZc3VqXAqub6PUTfhemH8pFEwBdAdG0LhrNkU0H"
40+
b"WN1YpT55IiQ31esLdL5q-qDsOPpNZJUti1y1lAreM5nIn2srdWzGXGs4i"
41+
b"TRQsn0XkNUCL4RErpciXmjfhMrPkcAjKA-mXQm2fa4jmTlEZFqFmUlym1"
42+
b"ozJ0yf5grjN6AslN4OGvAv1pS-_Ko_pGBS6IQtSBC6vVKCUuBfaqNjykg"
43+
b"bsxbLa6Fp0SYeYwO8ifEnkRvasVpc1WTQqfRB2JCj5pTBDzJpIpFCMmnQ"
44+
)
45+
2846

2947
class TestCredentials(object):
3048
credentials = None
@@ -238,6 +256,26 @@ def test_additional_claims(self, sign, get, utcnow):
238256
"foo": "bar",
239257
}
240258

259+
def test_token_uri(self):
260+
request = mock.create_autospec(transport.Request, instance=True)
261+
262+
self.credentials = credentials.IDTokenCredentials(
263+
request=request,
264+
signer=mock.Mock(),
265+
service_account_email="foo@example.com",
266+
target_audience="https://audience.com",
267+
)
268+
assert self.credentials._token_uri == credentials._DEFAULT_TOKEN_URI
269+
270+
self.credentials = credentials.IDTokenCredentials(
271+
request=request,
272+
signer=mock.Mock(),
273+
service_account_email="foo@example.com",
274+
target_audience="https://audience.com",
275+
token_uri="https://example.com/token",
276+
)
277+
assert self.credentials._token_uri == "https://example.com/token"
278+
241279
@mock.patch(
242280
"google.auth._helpers.utcnow",
243281
return_value=datetime.datetime.utcfromtimestamp(0),
@@ -469,3 +507,104 @@ def test_sign_bytes(self, sign, get):
469507

470508
# The JWT token signature is 'signature' encoded in base 64:
471509
assert signature == b"signature"
510+
511+
@mock.patch(
512+
"google.auth.compute_engine._metadata.get_service_account_info", autospec=True
513+
)
514+
@mock.patch("google.auth.compute_engine._metadata.get", autospec=True)
515+
def test_get_id_token_from_metadata(self, get, get_service_account_info):
516+
get.return_value = SAMPLE_ID_TOKEN
517+
get_service_account_info.return_value = {"email": "foo@example.com"}
518+
519+
cred = credentials.IDTokenCredentials(
520+
mock.Mock(), "audience", use_metadata_identity_endpoint=True
521+
)
522+
cred.refresh(request=mock.Mock())
523+
524+
assert cred.token == SAMPLE_ID_TOKEN
525+
assert cred.expiry == SAMPLE_ID_TOKEN_EXP
526+
assert cred._use_metadata_identity_endpoint
527+
assert cred._signer is None
528+
assert cred._token_uri is None
529+
assert cred._service_account_email == "foo@example.com"
530+
assert cred._target_audience == "audience"
531+
with pytest.raises(ValueError):
532+
cred.sign_bytes(b"bytes")
533+
534+
@mock.patch(
535+
"google.auth.compute_engine._metadata.get_service_account_info", autospec=True
536+
)
537+
def test_with_target_audience_for_metadata(self, get_service_account_info):
538+
get_service_account_info.return_value = {"email": "foo@example.com"}
539+
540+
cred = credentials.IDTokenCredentials(
541+
mock.Mock(), "audience", use_metadata_identity_endpoint=True
542+
)
543+
cred = cred.with_target_audience("new_audience")
544+
545+
assert cred._target_audience == "new_audience"
546+
assert cred._use_metadata_identity_endpoint
547+
assert cred._signer is None
548+
assert cred._token_uri is None
549+
assert cred._service_account_email == "foo@example.com"
550+
551+
@mock.patch(
552+
"google.auth.compute_engine._metadata.get_service_account_info", autospec=True
553+
)
554+
@mock.patch("google.auth.compute_engine._metadata.get", autospec=True)
555+
def test_invalid_id_token_from_metadata(self, get, get_service_account_info):
556+
get.return_value = "invalid_id_token"
557+
get_service_account_info.return_value = {"email": "foo@example.com"}
558+
559+
cred = credentials.IDTokenCredentials(
560+
mock.Mock(), "audience", use_metadata_identity_endpoint=True
561+
)
562+
563+
with pytest.raises(ValueError):
564+
cred.refresh(request=mock.Mock())
565+
566+
@mock.patch(
567+
"google.auth.compute_engine._metadata.get_service_account_info", autospec=True
568+
)
569+
@mock.patch("google.auth.compute_engine._metadata.get", autospec=True)
570+
def test_transport_error_from_metadata(self, get, get_service_account_info):
571+
get.side_effect = exceptions.TransportError("transport error")
572+
get_service_account_info.return_value = {"email": "foo@example.com"}
573+
574+
cred = credentials.IDTokenCredentials(
575+
mock.Mock(), "audience", use_metadata_identity_endpoint=True
576+
)
577+
578+
with pytest.raises(exceptions.RefreshError) as excinfo:
579+
cred.refresh(request=mock.Mock())
580+
assert excinfo.match(r"transport error")
581+
582+
def test_get_id_token_from_metadata_constructor(self):
583+
with pytest.raises(ValueError):
584+
credentials.IDTokenCredentials(
585+
mock.Mock(),
586+
"audience",
587+
use_metadata_identity_endpoint=True,
588+
token_uri="token_uri",
589+
)
590+
with pytest.raises(ValueError):
591+
credentials.IDTokenCredentials(
592+
mock.Mock(),
593+
"audience",
594+
use_metadata_identity_endpoint=True,
595+
signer=mock.Mock(),
596+
)
597+
with pytest.raises(ValueError):
598+
credentials.IDTokenCredentials(
599+
mock.Mock(),
600+
"audience",
601+
use_metadata_identity_endpoint=True,
602+
additional_claims={"key", "value"},
603+
)
604+
with pytest.raises(ValueError):
605+
credentials.IDTokenCredentials(
606+
mock.Mock(),
607+
"audience",
608+
use_metadata_identity_endpoint=True,
609+
service_account_email="foo@example.com",
610+
)

0 commit comments

Comments
 (0)