8000 feat: adds new external account authorized user credentials (#1160) · TJB-1/google-auth-library-python@523f811 · GitHub
[go: up one dir, main page]

Skip to content

Commit 523f811

Browse files
feat: adds new external account authorized user credentials (googleapis#1160)
1 parent 24cfa56 commit 523f811

File tree

7 files changed

+948
-27
lines changed

7 files changed

+948
-27
lines changed

google/auth/_default.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@
3535
_AUTHORIZED_USER_TYPE = "authorized_user"
3636
_SERVICE_ACCOUNT_TYPE = "service_account"
3737
_EXTERNAL_ACCOUNT_TYPE = "external_account"
38+
_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = "external_account_authorized_user"
3839
_IMPERSONATED_SERVICE_ACCOUNT_TYPE = "impersonated_service_account"
3940
_GDCH_SERVICE_ACCOUNT_TYPE = "gdch_service_account"
4041
_VALID_TYPES = (
4142
_AUTHORIZED_USER_TYPE,
4243
_SERVICE_ACCOUNT_TYPE,
4344
_EXTERNAL_ACCOUNT_TYPE,
45+
_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE,
4446
_IMPERSONATED_SERVICE_ACCOUNT_TYPE,
4547
_GDCH_SERVICE_ACCOUNT_TYPE,
4648
)
@@ -158,6 +160,12 @@ def _load_credentials_from_info(
158160
default_scopes=default_scopes,
159161
request=request,
160162
)
163+
164+
elif credential_type == _EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE:
165+
credentials, project_id = _get_external_account_authorized_user_credentials(
166+
filename, info, request
167+
)
168+
161169
elif credential_type == _IMPERSONATED_SERVICE_ACCOUNT_TYPE:
162170
credentials, project_id = _get_impersonated_service_account_credentials(
163171
filename, info, scopes
@@ -363,6 +371,23 @@ def _get_external_account_credentials(
363371
return credentials, credentials.get_project_id(request=request)
364372

365373

374+
def _get_external_account_authorized_user_credentials(
375+
filename, info, scopes=None, default_scopes=None, request=None
376+
):
377+
try:
378+
from google.auth import external_account_authorized_user
379+
380+
credentials = external_account_authorized_user.Credentials.from_info(info)
381+
except ValueError:
382+
raise exceptions.DefaultCredentialsError(
383+
"Failed to load external account authorized user credentials from {}".format(
384+
filename
385+
)
386+
)
387+
388+
return credentials, None
389+
390+
366391
def _get_authorized_user_credentials(filename, info, scopes=None):
367392
from google.oauth2 import credentials
368393

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
# Copyright 2022 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+
"""External Account Authorized User Credentials.
16+
This module provides credentials based on OAuth 2.0 access and refresh tokens.
17+
These credentials usually access resources on behalf of a user (resource
18+
owner).
19+
20+
Specifically, these are sourced using external identities via Workforce Identity Federation.
21+
22+
Obtaining the initial access and refresh token can be done through the Google Cloud CLI.
23+
24+
Example credential:
25+
{
26+
"type": "external_account_authorized_user",
27+
"audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID",
28+
"refresh_token": "refreshToken",
29+
"token_url": "https://sts.googleapis.com/v1/oauth/token",
30+
"token_info_url": "https://sts.googleapis.com/v1/instrospect",
31+
"client_id": "clientId",
32+
"client_secret": "clientSecret"
33+
}
34+
"""
35+
36+
import datetime
37+
import io
38+
import json
39+
40+
from google.auth import _helpers
41+
from google.auth import credentials
42+
from google.auth import exceptions
43+
from google.oauth2 import sts
44+
from google.oauth2 import utils
45+
46+
_EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE = "external_account_authorized_user"
47+
48+
49+
class Credentials(
50+
credentials.CredentialsWithQuotaProject,
51+
credentials.ReadOnlyScoped,
52+
credentials.CredentialsWithTokenUri,
53+
):
54+
"""Credentials for External Account Authorized Users.
55+
56+
This is used to instantiate Credentials for exchanging refresh tokens from
57+
authorized users for Google access token and authorizing requests to Google
58+
APIs.
59+
60+
The credentials are considered immutable. If you want to modify the
61+
quota project, use `with_quota_project` and if you want to modify the token
62+
uri, use `with_token_uri`.
63+
"""
64+
65+
def __init__(
66+
self,
67+
token=None,
68+
expiry=None,
69+
refresh_token=None,
70+
audience=None,
71+
client_id=None,
72+
client_secret=None,
73+
token_url=None,
74+
token_info_url=None,
75+
revoke_url=None,
76+
quota_project_id=None,
77+
):
78+
"""Instantiates a external account authorized user credentials object.
79+
80+
Args:
81+
token (str): The OAuth 2.0 access token. Can be None if refresh information
82+
is provided.
83+
expiry (datetime.datetime): The optional expiration datetime of the OAuth 2.0 access
84+
token.
85+
refresh_token (str): The optional OAuth 2.0 refresh token. If specified,
86+
credentials can be refreshed.
87+
audience (str): The optional STS audience which contains the resource name for the workforce
88+
pool and the provider identifier in that pool.
89+
client_id (str): The OAuth 2.0 client ID. Must be specified for refresh, can be left as
90+
None if the token can not be refreshed.
91+
client_secret (str): The OAuth 2.0 client secret. Must be specified for refresh, can be
92+
left as None if the token can not be refreshed.
93+
token_url (str): The optional STS token exchange endpoint. Must be specified fro refresh,
94+
can be leftas None if the token can not be refreshed.
95+
token_info_url (str): The optional STS endpoint URL for token introspection.
96+
revoke_url (str): The optional STS endpoint URL for revoking tokens.
97+
quota_project_id (str): The optional project ID used for quota and billing.
98+
This project may be different from the project used to
99+
create the credentials.
100+
101+
Returns:
102+
google.auth.external_account_authorized_user.Credentials: The
103+
constructed credentials.
104+
"""
105+
if not any((refresh_token, token)):
106+
raise ValueError("Either `refresh_token` or `token` should be set.")
107+
108+
super(Credentials, self).__init__()
109+
110+
self.token = token
111+
self.expiry = expiry
112+
self._audience = audience
113+
self._refresh_token = refresh_token
114+
self._token_url = token_url
115+
self._token_info_url = token_info_url
116+
self._client_id = client_id
117+
self._client_secret = client_secret
118+
self._revoke_url = revoke_url
119+
self._quota_project_id = quota_project_id
120+
121+
self._client_auth = None
122+
if self._client_id:
123+
self._client_auth = utils.ClientAuthentication(
124+
utils.ClientAuthType.basic, self._client_id, self._client_secret
125+
)
126+
self._sts_client = sts.Client(self._token_url, self._client_auth)
127+
128+
@property
129+
def info(self):
130+
"""Generates the serializable dictionary representation of the current
131+
credentials.
132+
133+
Returns:
134+
Mapping: The dictionary representation of the credentials. This is the
135+
reverse of the "from_info" method defined in this class. It is
136+
useful for serializing the current credentials so it can deserialized
137+
later.
138+
"""
139+
config_info = self.constructor_args()
140+
config_info.update(type=_EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE)
141+
if config_info["expiry"]:
142+
config_info["expiry"] = config_info["expiry"].isoformat() + "Z"
143+
144+
return {key: value for key, value in config_info.items() if value is not None}
145+
146+
def constructor_args(self):
147+
return {
148+
"audience": self._audience,
149+
"refresh_token": self._refresh_token,
150+
"token_url": self._token_url,
151+
"token_info_url": self._token_info_url,
152+
"client_id": self._client_id,
153+
"client_secret": self._client_secret,
154+
"token": self.token,
155+
"expiry": self.expiry,
156+
"revoke_url": self._revoke_url,
157+
"quota_project_id": self._quota_project_id,
158+
}
159+
160+
@property
161+
def requires_scopes(self):
162+
""" False: OAuth 2.0 credentials have their scopes set when
163+
the initial token is requested and can not be changed."""
164+
return False
165+
166+
@property
167+
def is_user(self):
168+
""" True: This credential always represents a user."""
169+
return True
170+
171+
def get_project_id(self):
172+
"""Retrieves the project ID corresponding to the workload identity or workforce pool.
173+
For workforce pool credentials, it returns the project ID corresponding to
174+
the workforce_pool_user_project.
175+
176+
When not determinable, None is returned.
177+
"""
178+
179+
return None
180+
181+
def to_json(self, strip=None):
182+
"""Utility function that creates a JSON representation of this
183+
credential.
184+
Args:
185+
strip (Sequence[str]): Optional list of members to exclude from the
186+
generated JSON.
187+
Returns:
188+
str: A JSON representation of this instance. When converted into
189+
a dictionary, it can be passed to from_info()
190+
to create a new instance.
191+
"""
192+
strip = strip if strip else []
193+
return json.dumps({k: v for (k, v) in self.info.items() if k not in strip})
194+
195+
def refresh(self, request):
196+
"""Refreshes the access token.
197+
198+
Args:
199+
request (google.auth.transport.Request): The object used to make
200+
HTTP requests.
201+
202+
Raises:
203+
google.auth.exceptions.RefreshError: If the credentials could
204+
not be refreshed.
205+
"""
206+
if not all(
207+
(self._refresh_token, self._token_url, self._client_id, self._client_secret)
208+
):
209+
raise exceptions.RefreshError(
210+
"The credentials do not contain the necessary fields need to "
211+
"refresh the access token. You must specify refresh_token, "
212+
"token_url, client_id, and client_secret."
213+
)
214+
215+
now = _helpers.utcnow()
216+
response_data = self._make_sts_request(request)
217+
218+
self.token = response_data.get("access_token")
219+
220+
lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
221+
self.expiry = now + lifetime
222+
223+
if "refresh_token" in response_data:
224+
self._refresh_token = response_data["refresh_token"]
225+
226+
def _make_sts_request(self, request):
227+
return self._sts_client.refresh_token(request, self._refresh_token)
228+
229+
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
230+
def with_quota_project(self, quota_project_id):
231+
kwargs = self.constructor_args()
232+
kwargs.update(quota_project_id=quota_project_id)
233+
return self.__class__(**kwargs)
234+
235+
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
236+
def with_token_uri(self, token_uri):
237+
kwargs = self.constructor_args()
238+
kwargs.update(token_url=token_uri)
239+
return self.__class__(**kwargs)
240+
241+
@classmethod
242+
def from_info(cls, info, **kwargs):
243+
"""Creates a Credentials instance from parsed external account info.
244+
245+
Args:
246+
info (Mapping[str, str]): The external account info in Google
247+
format.
248+
kwargs: Additional arguments to pass to the constructor.
249+
250+
Returns:
251+
google.auth.external_account_authorized_user.Credentials: The
252+
constructed credentials.
253+
254+
Raises:
255+
ValueError: For invalid parameters.
256+
"""
257+
expiry = info.get("expiry")
258+
if expiry:
259+
expiry = datetime.datetime.strptime(
260+
expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S"
261+
)
262+
return cls(
263+
audience=info.get("audience"),
264+
refresh_token=info.get("refresh_token"),
265+
token_url=info.get("token_url"),
266+
token_info_url=info.get("token_info_url"),
267+
client_id=info.get("client_id"),
268+
client_secret=info.get("client_secret"),
269+
token=info.get("token"),
270+
expiry=expiry,
271+
revoke_url=info.get("revoke_url"),
272+
quota_project_id=info.get("quota_project_id"),
273+
**kwargs
274+
)
275+
276+
@classmethod
277+
def from_file(cls, filename, **kwargs):
278+
"""Creates a Credentials instance from an external account json file.
279+
280+
Args:
281+
filename (str): The path to the external account json file.
282+
kwargs: Additional arguments to pass to the constructor.
283+
284+
Returns:
285+
google.auth.external_account_authorized_user.Credentials: The
286+
constructed credentials.
287+
"""
288+
with io.open(filename, "r", encoding="utf-8") as json_file:
289+
data = json.load(json_file)
290+
return cls.from_info(data, **kwargs)

0 commit comments

Comments
 (0)
0