Skip to content

Commit 6dd304b

Browse files
feat: add fetch_id_token to support id_token adc (#469)
feat: id_token adc with gcloud cred
1 parent 1fea72d commit 6dd304b

File tree

6 files changed

+213
-3
lines changed

6 files changed

+213
-3
lines changed

packages/google-auth/docs/user-guide.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,20 @@ Impersonated ::
291291
target_credentials,
292292
target_audience=target_audience)
293293

294+
If your application runs on `App Engine`_, `Cloud Run`_, `Compute Engine`_, or
295+
has application default credentials set via `GOOGLE_APPLICATION_CREDENTIALS`
296+
environment variable, you can also use `google.oauth2.id_token.fetch_id_token`
297+
to obtain an ID token from your current running environment. The following is an
298+
example ::
299+
300+
import google.oauth2.id_token
301+
import google.auth.transport.requests
302+
303+
request = google.auth.transport.requests.Request()
304+
target_audience = "https://pubsub.googleapis.com"
305+
306+
id_token = google.oauth2.id_token.fetch_id_token(request, target_audience)
307+
294308
IDToken verification can be done for various type of IDTokens using the
295309
:class:`google.oauth2.id_token` module. It supports ID token signed with RS256
296310
and ES256 algorithms. However, ES256 algorithm won't be available unless
@@ -334,8 +348,10 @@ A sample end-to-end flow using an ID Token against a Cloud Run endpoint maybe ::
334348
print(token)
335349
print(id_token.verify_token(token,request))
336350

351+
.. _App Engine: https://cloud.google.com/appengine/
337352
.. _Cloud Functions: https://cloud.google.com/functions/
338353
.. _Cloud Run: https://cloud.google.com/run/
354+
.. _Compute Engine: https://cloud.google.com/compute/
339355
.. _Identity Aware Proxy: https://cloud.google.com/iap/
340356
.. _Google OpenID Connect: https://developers.google.com/identity/protocols/OpenIDConnect
341357
.. _Google ID Token: https://developers.google.com/identity/protocols/OpenIDConnect#validatinganidtoken

packages/google-auth/google/oauth2/id_token.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,16 @@
5959
"""
6060

6161
import json
62+
import os
6263

64+
import six
6365
from six.moves import http_client
6466

67+
from google.auth import environment_vars
6568
from google.auth import exceptions
6669
from google.auth import jwt
6770

71+
6872
# The URL that provides public certificates for verifying ID tokens issued
6973
# by Google's OAuth 2.0 authorization server.
7074
_GOOGLE_OAUTH2_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs"
@@ -159,3 +163,90 @@ def verify_firebase_token(id_token, request, audience=None):
159163
return verify_token(
160164
id_token, request, audience=audience, certs_url=_GOOGLE_APIS_CERTS_URL
161165
)
166+
167+
168+
def fetch_id_token(request, audience):
169+
"""Fetch the ID Token from the current environment.
170+
171+
This function acquires ID token from the environment in the following order:
172+
173+
1. If the application is running in Compute Engine, App Engine or Cloud Run,
174+
then the ID token are obtained from the metadata server.
175+
2. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
176+
to the path of a valid service account JSON file, then ID token is
177+
acquired using this service account credentials.
178+
3. If metadata server doesn't exist and no valid service account credentials
179+
are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will
180+
be raised.
181+
182+
Example::
183+
184+
import google.oauth2.id_token
185+
import google.auth.transport.requests
186+
187+
request = google.auth.transport.requests.Request()
188+
target_audience = "https://pubsub.googleapis.com"
189+
190+
id_token = google.oauth2.id_token.fetch_id_token(request, target_audience)
191+
192+
Args:
193+
request (google.auth.transport.Request): A callable used to make
194+
HTTP requests.
195+
audience (str): The audience that this ID token is intended for.
196+
197+
Returns:
198+
str: The ID token.
199+
200+
Raises:
201+
~google.auth.exceptions.DefaultCredentialsError:
202+
If metadata server doesn't exist and no valid service account
203+
credentials are found.
204+
"""
205+
# 1. First try to fetch ID token from metada server if it exists. The code
206+
# works for GAE and Cloud Run metadata server as well.
207+
try:
208+
from google.auth import compute_engine
209+
210+
credentials = compute_engine.IDTokenCredentials(
211+
request, audience, use_metadata_identity_endpoint=True
212+
)
213+
credentials.refresh(request)
214+
return credentials.token
215+
except (ImportError, exceptions.TransportError):
216+
pass
217+
218+
# 2. Try to use service account credentials to get ID token.
219+
220+
# Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment
221+
# variable.
222+
credentials_filename = os.environ.get(environment_vars.CREDENTIALS)
223+
if not (
224+
credentials_filename
225+
and os.path.exists(credentials_filename)
226+
and os.path.isfile(credentials_filename)
227+
):
228+
raise exceptions.DefaultCredentialsError(
229+
"Neither metadata server or valid service account credentials are found."
230+
)
231+
232+
try:
233+
with open(credentials_filename, "r") as f:
234+
info = json.load(f)
235+
credentials_content = (
236+
(info.get("type") == "service_account") and info or None
237+
)
238+
239+
from google.oauth2 import service_account
240+
241+
credentials = service_account.IDTokenCredentials.from_service_account_info(
242+
credentials_content, target_audience=audience
243+
)
244+
except ValueError as caught_exc:
245+
new_exc = exceptions.DefaultCredentialsError(
246+
"Neither metadata server or valid service account credentials are found.",
247+
caught_exc,
248+
)
249+
six.raise_from(new_exc, caught_exc)
250+
251+
credentials.refresh(request)
252+
return credentials.token

packages/google-auth/system_tests/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def default_explicit_service_account(session):
200200
session.env[EXPECT_PROJECT_ENV] = "1"
201201
session.install(*TEST_DEPENDENCIES)
202202
session.install(LIBRARY_DIR)
203-
session.run("pytest", "test_default.py")
203+
session.run("pytest", "test_default.py", "test_id_token.py")
204204

205205

206206
@nox.session(python=PYTHON_VERSIONS)

packages/google-auth/system_tests/test_compute_engine.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from google.auth import exceptions
2121
from google.auth import jwt
2222
from google.auth.compute_engine import _metadata
23+
import google.oauth2.id_token
24+
25+
AUDIENCE = "https://pubsub.googleapis.com"
2326

2427

2528
@pytest.fixture(autouse=True)
@@ -53,10 +56,17 @@ def test_default(verify_refresh):
5356

5457
def test_id_token_from_metadata(http_request):
5558
credentials = compute_engine.IDTokenCredentials(
56-
http_request, "target_audience", use_metadata_identity_endpoint=True
59+
http_request, AUDIENCE, use_metadata_identity_endpoint=True
5760
)
5861
credentials.refresh(http_request)
5962

6063
_, payload, _, _ = jwt._unverified_decode(credentials.token)
61-
assert payload["aud"] == "target_audience"
64+
assert payload["aud"] == AUDIENCE
6265
assert payload["exp"] == credentials.expiry
66+
67+
68+
def test_fetch_id_token(http_request):
69+
token = google.oauth2.id_token.fetch_id_token(http_request, AUDIENCE)
70+
71+
_, payload, _, _ = jwt._unverified_decode(token)
72+
assert payload["aud"] == AUDIENCE
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2020 Google LLC
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+
import pytest
15+
16+
from google.auth import jwt
17+
import google.oauth2.id_token
18+
19+
20+
def test_fetch_id_token(http_request):
21+
audience = "https://pubsub.googleapis.com"
22+
token = google.oauth2.id_token.fetch_id_token(http_request, audience)
23+
24+
_, payload, _, _ = jwt._unverified_decode(token)
25+
assert payload["aud"] == audience

packages/google-auth/tests/oauth2/test_id_token.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@
1313
# limitations under the License.
1414

1515
import json
16+
import os
1617

1718
import mock
1819
import pytest
1920

21+
from google.auth import environment_vars
2022
from google.auth import exceptions
2123
from google.auth import transport
24+
import google.auth.compute_engine._metadata
2225
from google.oauth2 import id_token
2326

27+
SERVICE_ACCOUNT_FILE = os.path.join(
28+
os.path.dirname(__file__), "../data/service_account.json"
29+
)
30+
2431

2532
def make_request(status, data=None):
2633
response = mock.create_autospec(transport.Response, instance=True)
@@ -114,3 +121,64 @@ def test_verify_firebase_token(verify_token):
114121
audience=mock.sentinel.audience,
115122
certs_url=id_token._GOOGLE_APIS_CERTS_URL,
116123
)
124+
125+
126+
def test_fetch_id_token_from_metadata_server():
127+
def mock_init(self, request, audience, use_metadata_identity_endpoint):
128+
assert use_metadata_identity_endpoint
129+
self.token = "id_token"
130+
131+
with mock.patch.multiple(
132+
google.auth.compute_engine.IDTokenCredentials,
133+
__init__=mock_init,
134+
refresh=mock.Mock(),
135+
):
136+
request = mock.Mock()
137+
token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com")
138+
assert token == "id_token"
139+
140+
141+
@mock.patch.object(
142+
google.auth.compute_engine.IDTokenCredentials,
143+
"__init__",
144+
side_effect=exceptions.TransportError(),
145+
)
146+
def test_fetch_id_token_from_explicit_cred_json_file(mock_init, monkeypatch):
147+
monkeypatch.setenv(environment_vars.CREDENTIALS, SERVICE_ACCOUNT_FILE)
148+
149+
def mock_refresh(self, request):
150+
self.token = "id_token"
151+
152+
with mock.patch.object(
153+
google.oauth2.service_account.IDTokenCredentials, "refresh", mock_refresh
154+
):
155+
request = mock.Mock()
156+
token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com")
157+
assert token == "id_token"
158+
159+
160+
@mock.patch.object(
161+
google.auth.compute_engine.IDTokenCredentials,
162+
"__init__",
163+
side_effect=exceptions.TransportError(),
164+
)
165+
def test_fetch_id_token_no_cred_json_file(mock_init, monkeypatch):
166+
monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False)
167+
168+
with pytest.raises(exceptions.DefaultCredentialsError):
169+
request = mock.Mock()
170+
id_token.fetch_id_token(request, "https://pubsub.googleapis.com")
171+
172+
173+
@mock.patch.object(
174+
google.auth.compute_engine.IDTokenCredentials,
175+
"__init__",
176+
side_effect=exceptions.TransportError(),
177+
)
178+
def test_fetch_id_token_invalid_cred_file(mock_init, monkeypatch):
179+
not_json_file = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem")
180+
monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file)
181+
182+
with pytest.raises(exceptions.DefaultCredentialsError):
183+
request = mock.Mock()
184+
id_token.fetch_id_token(request, "https://pubsub.googleapis.com")

0 commit comments

Comments
 (0)