Skip to content

Commit ff87f10

Browse files
feat: add api key support (#826)
* feat: add api key support * chore: update * Update google/auth/_default.py Co-authored-by: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Co-authored-by: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com>
1 parent 2174d68 commit ff87f10

File tree

7 files changed

+299
-6
lines changed

7 files changed

+299
-6
lines changed

packages/google-auth/google/auth/_default.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,24 @@ def _get_external_account_credentials(
353353
return credentials, credentials.get_project_id(request=request)
354354

355355

356+
def _get_api_key_credentials(quota_project_id=None):
357+
"""Gets API key credentials and project ID."""
358+
from google.auth import api_key
359+
360+
api_key_value = os.environ.get(environment_vars.API_KEY)
361+
if api_key_value:
362+
return api_key.Credentials(api_key_value), quota_project_id
363+
else:
364+
return None, None
365+
366+
367+
def get_api_key_credentials(api_key_value):
368+
"""Gets API key credentials using the given api key value."""
369+
from google.auth import api_key
370+
371+
return api_key.Credentials(api_key_value)
372+
373+
356374
def default(scopes=None, request=None, quota_project_id=None, default_scopes=None):
357375
"""Gets the default credentials for the current environment.
358376
@@ -361,7 +379,14 @@ def default(scopes=None, request=None, quota_project_id=None, default_scopes=Non
361379
This function acquires credentials from the environment in the following
362380
order:
363381
364-
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
382+
1. If both ``GOOGLE_API_KEY`` and ``GOOGLE_APPLICATION_CREDENTIALS``
383+
environment variables are set, throw an exception.
384+
385+
If ``GOOGLE_API_KEY`` is set, an `API Key`_ credentials will be returned.
386+
The project ID returned is the one defined by ``GOOGLE_CLOUD_PROJECT`` or
387+
``GCLOUD_PROJECT`` environment variables.
388+
389+
If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
365390
to the path of a valid service account JSON private key file, then it is
366391
loaded and returned. The project ID returned is the project ID defined
367392
in the service account file if available (some older files do not
@@ -409,6 +434,7 @@ def default(scopes=None, request=None, quota_project_id=None, default_scopes=Non
409434
.. _Metadata Service: https://cloud.google.com/compute/docs\
410435
/storing-retrieving-metadata
411436
.. _Cloud Run: https://cloud.google.com/run
437+
.. _API Key: https://cloud.google.com/docs/authentication/api-keys
412438
413439
Example::
414440
@@ -444,16 +470,25 @@ def default(scopes=None, request=None, quota_project_id=None, default_scopes=Non
444470
invalid.
445471
"""
446472
from google.auth.credentials import with_scopes_if_required
473+
from google.auth.credentials import CredentialsWithQuotaProject
447474

448475
explicit_project_id = os.environ.get(
449476
environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT)
450477
)
451478

479+
if os.environ.get(environment_vars.API_KEY) and os.environ.get(
480+
environment_vars.CREDENTIALS
481+
):
482+
raise exceptions.DefaultCredentialsError(
483+
"Environment variables GOOGLE_API_KEY and GOOGLE_APPLICATION_CREDENTIALS are mutually exclusive"
484+
)
485+
452486
checkers = (
453487
# Avoid passing scopes here to prevent passing scopes to user credentials.
454488
# with_scopes_if_required() below will ensure scopes/default scopes are
455489
# safely set on the returned credentials since requires_scopes will
456490
# guard against setting scopes on user credentials.
491+
lambda: _get_api_key_credentials(quota_project_id=quota_project_id),
457492
lambda: _get_explicit_environ_credentials(quota_project_id=quota_project_id),
458493
lambda: _get_gcloud_sdk_credentials(quota_project_id=quota_project_id),
459494
_get_gae_credentials,
@@ -477,7 +512,9 @@ def default(scopes=None, request=None, quota_project_id=None, default_scopes=Non
477512
request = google.auth.transport.requests.Request()
478513
project_id = credentials.get_project_id(request=request)
479514

480-
if quota_project_id:
515+
if quota_project_id and isinstance(
516+
credentials, CredentialsWithQuotaProject
517+
):
481518
credentials = credentials.with_quota_project(quota_project_id)
482519

483520
effective_project_id = explicit_project_id or project_id

packages/google-auth/google/auth/_default_async.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,24 @@ def _get_gae_credentials():
161161
return _default._get_gae_credentials()
162162

163163

164+
def _get_api_key_credentials(quota_project_id=None):
165+
"""Gets API key credentials and project ID."""
166+
from google.auth import api_key
167+
168+
api_key_value = os.environ.get(environment_vars.API_KEY)
169+
if api_key_value:
170+
return api_key.Credentials(api_key_value), quota_project_id
171+
else:
172+
return None, None
173+
174+
175+
def get_api_key_credentials(api_key_value):
176+
"""Gets API key credentials using the given api key value."""
177+
from google.auth import api_key
178+
179+
return api_key.Credentials(api_key_value)
180+
181+
164182
def _get_gce_credentials(request=None):
165183
"""Gets credentials and project ID from the GCE Metadata Service."""
166184
# Ping requires a transport, but we want application default credentials
@@ -182,7 +200,14 @@ def default_async(scopes=None, request=None, quota_project_id=None):
182200
This function acquires credentials from the environment in the following
183201
order:
184202
185-
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
203+
1. If both ``GOOGLE_API_KEY`` and ``GOOGLE_APPLICATION_CREDENTIALS``
204+
environment variables are set, throw an exception.
205+
206+
If ``GOOGLE_API_KEY`` is set, an `API Key`_ credentials will be returned.
207+
The project ID returned is the one defined by ``GOOGLE_CLOUD_PROJECT`` or
208+
``GCLOUD_PROJECT`` environment variables.
209+
210+
If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
186211
to the path of a valid service account JSON private key file, then it is
187212
loaded and returned. The project ID returned is the project ID defined
188213
in the service account file if available (some older files do not
@@ -221,6 +246,7 @@ def default_async(scopes=None, request=None, quota_project_id=None):
221246
.. _Metadata Service: https://cloud.google.com/compute/docs\
222247
/storing-retrieving-metadata
223248
.. _Cloud Run: https://cloud.google.com/run
249+
.. _API Key: https://cloud.google.com/docs/authentication/api-keys
224250
225251
Example::
226252
@@ -250,12 +276,21 @@ def default_async(scopes=None, request=None, quota_project_id=None):
250276
invalid.
251277
"""
252278
from google.auth._credentials_async import with_scopes_if_required
279+
from google.auth.credentials import CredentialsWithQuotaProject
253280

254281
explicit_project_id = os.environ.get(
255282
environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT)
256283
)
257284

285+
if os.environ.get(environment_vars.API_KEY) and os.environ.get(
286+
environment_vars.CREDENTIALS
287+
):
288+
raise exceptions.DefaultCredentialsError(
289+
"GOOGLE_API_KEY and GOOGLE_APPLICATION_CREDENTIALS are mutually exclusive"
290+
)
291+
258292
checkers = (
293+
lambda: _get_api_key_credentials(quota_project_id=quota_project_id),
259294
lambda: _get_explicit_environ_credentials(quota_project_id=quota_project_id),
260295
lambda: _get_gcloud_sdk_credentials(quota_project_id=quota_project_id),
261296
_get_gae_credentials,
@@ -265,9 +300,11 @@ def default_async(scopes=None, request=None, quota_project_id=None):
265300
for checker in checkers:
266301
credentials, project_id = checker()
267302
if credentials is not None:
268-
credentials = with_scopes_if_required(
269-
credentials, scopes
270-
).with_quota_project(quota_project_id)
303+
credentials = with_scopes_if_required(credentials, scopes)
304+
if quota_project_id and isinstance(
305+
credentials, CredentialsWithQuotaProject
306+
):
307+
credentials = credentials.with_quota_project(quota_project_id)
271308
effective_project_id = explicit_project_id or project_id
272309
if not effective_project_id:
273310
_default._LOGGER.warning(
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright 2021 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+
15+
"""Google API key support.
16+
17+
This module provides authentication using the `API key`_.
18+
19+
20+
.. _API key:
21+
https://cloud.google.com/docs/authentication/api-keys/
22+
"""
23+
24+
from google.auth import _helpers
25+
from google.auth import credentials
26+
27+
28+
class Credentials(credentials.Credentials):
29+
"""API key credentials.
30+
31+
These credentials use API key to provide authorization to applications.
32+
"""
33+
34+
def __init__(self, token):
35+
"""
36+
Args:
37+
token (str): API key string
38+
39+
Raises:
40+
ValueError: If the provided API key is not a non-empty string.
41+
"""
42+
if not token:
43+
raise ValueError("Token must be a non-empty API key string")
44+
super(Credentials, self).__init__()
45+
self.token = token
46+
47+
@property
48+
def expired(self):
49+
return False
50+
51+
@property
52+
def valid(self):
53+
return True
54+
55+
@_helpers.copy_docstring(credentials.Credentials)
56+
def refresh(self, request):
57+
return
58+
59+
def apply(self, headers, token=None):
60+
"""Apply the API key token to the x-goog-api-key header.
61+
62+
Args:
63+
headers (Mapping): The HTTP request headers.
64+
token (Optional[str]): If specified, overrides the current access
65+
token.
66+
"""
67+
headers["x-goog-api-key"] = token or self.token
68+
69+
def before_request(self, request, method, url, headers):
70+
"""Performs credential-specific before request logic.
71+
72+
Refreshes the credentials if necessary, then calls :meth:`apply` to
73+
apply the token to the x-goog-api-key header.
74+
75+
Args:
76+
request (google.auth.transport.Request): The object used to make
77+
HTTP requests.
78+
method (str): The request's HTTP method or the RPC method being
79+
invoked.
80+
url (str): The request's URI or the RPC service's URI.
81+
headers (Mapping): The request's headers.
82+
"""
83+
self.apply(headers)

packages/google-auth/google/auth/environment_vars.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
"""Environment variable defining the location of Google application default
3434
credentials."""
3535

36+
API_KEY = "GOOGLE_API_KEY"
37+
"""Environment variable defining the API key value."""
38+
3639
# The environment variable name which can replace ~/.config if set.
3740
CLOUD_SDK_CONFIG_DIR = "CLOUDSDK_CONFIG"
3841
"""Environment variable defines the location of Google Cloud SDK's config

packages/google-auth/tests/test__default.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import pytest # type: ignore
2020

2121
from google.auth import _default
22+
from google.auth import api_key
2223
from google.auth import app_engine
2324
from google.auth import aws
2425
from google.auth import compute_engine
@@ -994,3 +995,46 @@ def test_default_no_warning_with_quota_project_id_for_user_creds(get_adc_path):
994995
get_adc_path.return_value = AUTHORIZED_USER_CLOUD_SDK_FILE
995996

996997
credentials, project_id = _default.default(quota_project_id="project-foo")
998+
999+
1000+
def test__get_api_key_credentials_no_env_var():
1001+
cred, project_id = _default._get_api_key_credentials(quota_project_id="project-foo")
1002+
assert cred is None
1003+
assert project_id is None
1004+
1005+
1006+
def test__get_api_key_credentials_from_env_var():
1007+
with mock.patch.dict(os.environ, {environment_vars.API_KEY: "api-key"}):
1008+
cred, project_id = _default._get_api_key_credentials(
1009+
quota_project_id="project-foo"
1010+
)
1011+
assert isinstance(cred, api_key.Credentials)
1012+
assert cred.token == "api-key"
1013+
assert project_id == "project-foo"
1014+
1015+
1016+
def test_exception_with_api_key_and_adc_env_var():
1017+
with mock.patch.dict(os.environ, {environment_vars.API_KEY: "api-key"}):
1018+
with mock.patch.dict(
1019+
os.environ, {environment_vars.CREDENTIALS: "/path/to/json"}
1020+
):
1021+
with pytest.raises(exceptions.DefaultCredentialsError) as excinfo:
1022+
_default.default()
1023+
1024+
assert excinfo.match(
1025+
r"GOOGLE_API_KEY and GOOGLE_APPLICATION_CREDENTIALS are mutually exclusive"
1026+
)
1027+
1028+
1029+
def test_default_api_key_from_env_var():
1030+
with mock.patch.dict(os.environ, {environment_vars.API_KEY: "api-key"}):
1031+
cred, project_id = _default.default()
1032+
assert isinstance(cred, api_key.Credentials)
1033+
assert cred.token == "api-key"
1034+
assert project_id is None
1035+
1036+
1037+
def test_get_api_key_credentials():
1038+
cred = _default.get_api_key_credentials("api-key")
1039+
assert isinstance(cred, api_key.Credentials)
1040+
assert cred.token == "api-key"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2021 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+
15+
import pytest # type: ignore
16+
17+
from google.auth import api_key
18+
19+
20+
def test_credentials_constructor():
21+
with pytest.raises(ValueError) as excinfo:
22+
api_key.Credentials("")
23+
24+
assert excinfo.match(r"Token must be a non-empty API key string")
25+
26+
27+
def test_expired_and_valid():
28+
credentials = api_key.Credentials("api-key")
29+
30+
assert credentials.valid
31+
assert credentials.token == "api-key"
32+
assert not credentials.expired
33+
34+
credentials.refresh(None)
35+
assert credentials.valid
36+
assert credentials.token == "api-key"
37+
assert not credentials.expired
38+
39+
40+
def test_before_request():
41+
credentials = api_key.Credentials("api-key")
42+
headers = {}
43+
44+
credentials.before_request(None, "http://example.com", "GET", headers)
45+
assert headers["x-goog-api-key"] == "api-key"

0 commit comments

Comments
 (0)