1# Copyright 2016 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"""OAuth 2.0 Credentials.
16
17This module provides credentials based on OAuth 2.0 access and refresh tokens.
18These credentials usually access resources on behalf of a user (resource
19owner).
20
21Specifically, this is intended to use access tokens acquired using the
22`Authorization Code grant`_ and can refresh those tokens using a
23optional `refresh token`_.
24
25Obtaining the initial access and refresh token is outside of the scope of this
26module. Consult `rfc6749 section 4.1`_ for complete details on the
27Authorization Code grant flow.
28
29.. _Authorization Code grant: https://tools.ietf.org/html/rfc6749#section-1.3.1
30.. _refresh token: https://tools.ietf.org/html/rfc6749#section-6
31.. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1
32"""
33
34from datetime import datetime
35import io
36import json
37
38import six
39
40from google.auth import _cloud_sdk
41from google.auth import _helpers
42from google.auth import credentials
43from google.auth import exceptions
44from google.oauth2 import reauth
45
46
47# The Google OAuth 2.0 token endpoint. Used for authorized user credentials.
48_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
49
50
51class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject):
52    """Credentials using OAuth 2.0 access and refresh tokens.
53
54    The credentials are considered immutable. If you want to modify the
55    quota project, use :meth:`with_quota_project` or ::
56
57        credentials = credentials.with_quota_project('myproject-123)
58
59    Reauth is disabled by default. To enable reauth, set the
60    `enable_reauth_refresh` parameter to True in the constructor. Note that
61    reauth feature is intended for gcloud to use only.
62    If reauth is enabled, `pyu2f` dependency has to be installed in order to use security
63    key reauth feature. Dependency can be installed via `pip install pyu2f` or `pip install
64    google-auth[reauth]`.
65    """
66
67    def __init__(
68        self,
69        token,
70        refresh_token=None,
71        id_token=None,
72        token_uri=None,
73        client_id=None,
74        client_secret=None,
75        scopes=None,
76        default_scopes=None,
77        quota_project_id=None,
78        expiry=None,
79        rapt_token=None,
80        refresh_handler=None,
81        enable_reauth_refresh=False,
82    ):
83        """
84        Args:
85            token (Optional(str)): The OAuth 2.0 access token. Can be None
86                if refresh information is provided.
87            refresh_token (str): The OAuth 2.0 refresh token. If specified,
88                credentials can be refreshed.
89            id_token (str): The Open ID Connect ID Token.
90            token_uri (str): The OAuth 2.0 authorization server's token
91                endpoint URI. Must be specified for refresh, can be left as
92                None if the token can not be refreshed.
93            client_id (str): The OAuth 2.0 client ID. Must be specified for
94                refresh, can be left as None if the token can not be refreshed.
95            client_secret(str): The OAuth 2.0 client secret. Must be specified
96                for refresh, can be left as None if the token can not be
97                refreshed.
98            scopes (Sequence[str]): The scopes used to obtain authorization.
99                This parameter is used by :meth:`has_scopes`. OAuth 2.0
100                credentials can not request additional scopes after
101                authorization. The scopes must be derivable from the refresh
102                token if refresh information is provided (e.g. The refresh
103                token scopes are a superset of this or contain a wild card
104                scope like 'https://www.googleapis.com/auth/any-api').
105            default_scopes (Sequence[str]): Default scopes passed by a
106                Google client library. Use 'scopes' for user-defined scopes.
107            quota_project_id (Optional[str]): The project ID used for quota and billing.
108                This project may be different from the project used to
109                create the credentials.
110            rapt_token (Optional[str]): The reauth Proof Token.
111            refresh_handler (Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]):
112                A callable which takes in the HTTP request callable and the list of
113                OAuth scopes and when called returns an access token string for the
114                requested scopes and its expiry datetime. This is useful when no
115                refresh tokens are provided and tokens are obtained by calling
116                some external process on demand. It is particularly useful for
117                retrieving downscoped tokens from a token broker.
118            enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
119                should be used. This flag is for gcloud to use only.
120        """
121        super(Credentials, self).__init__()
122        self.token = token
123        self.expiry = expiry
124        self._refresh_token = refresh_token
125        self._id_token = id_token
126        self._scopes = scopes
127        self._default_scopes = default_scopes
128        self._token_uri = token_uri
129        self._client_id = client_id
130        self._client_secret = client_secret
131        self._quota_project_id = quota_project_id
132        self._rapt_token = rapt_token
133        self.refresh_handler = refresh_handler
134        self._enable_reauth_refresh = enable_reauth_refresh
135
136    def __getstate__(self):
137        """A __getstate__ method must exist for the __setstate__ to be called
138        This is identical to the default implementation.
139        See https://docs.python.org/3.7/library/pickle.html#object.__setstate__
140        """
141        state_dict = self.__dict__.copy()
142        # Remove _refresh_handler function as there are limitations pickling and
143        # unpickling certain callables (lambda, functools.partial instances)
144        # because they need to be importable.
145        # Instead, the refresh_handler setter should be used to repopulate this.
146        del state_dict["_refresh_handler"]
147        return state_dict
148
149    def __setstate__(self, d):
150        """Credentials pickled with older versions of the class do not have
151        all the attributes."""
152        self.token = d.get("token")
153        self.expiry = d.get("expiry")
154        self._refresh_token = d.get("_refresh_token")
155        self._id_token = d.get("_id_token")
156        self._scopes = d.get("_scopes")
157        self._default_scopes = d.get("_default_scopes")
158        self._token_uri = d.get("_token_uri")
159        self._client_id = d.get("_client_id")
160        self._client_secret = d.get("_client_secret")
161        self._quota_project_id = d.get("_quota_project_id")
162        self._rapt_token = d.get("_rapt_token")
163        self._enable_reauth_refresh = d.get("_enable_reauth_refresh")
164        # The refresh_handler setter should be used to repopulate this.
165        self._refresh_handler = None
166
167    @property
168    def refresh_token(self):
169        """Optional[str]: The OAuth 2.0 refresh token."""
170        return self._refresh_token
171
172    @property
173    def scopes(self):
174        """Optional[str]: The OAuth 2.0 permission scopes."""
175        return self._scopes
176
177    @property
178    def token_uri(self):
179        """Optional[str]: The OAuth 2.0 authorization server's token endpoint
180        URI."""
181        return self._token_uri
182
183    @property
184    def id_token(self):
185        """Optional[str]: The Open ID Connect ID Token.
186
187        Depending on the authorization server and the scopes requested, this
188        may be populated when credentials are obtained and updated when
189        :meth:`refresh` is called. This token is a JWT. It can be verified
190        and decoded using :func:`google.oauth2.id_token.verify_oauth2_token`.
191        """
192        return self._id_token
193
194    @property
195    def client_id(self):
196        """Optional[str]: The OAuth 2.0 client ID."""
197        return self._client_id
198
199    @property
200    def client_secret(self):
201        """Optional[str]: The OAuth 2.0 client secret."""
202        return self._client_secret
203
204    @property
205    def requires_scopes(self):
206        """False: OAuth 2.0 credentials have their scopes set when
207        the initial token is requested and can not be changed."""
208        return False
209
210    @property
211    def rapt_token(self):
212        """Optional[str]: The reauth Proof Token."""
213        return self._rapt_token
214
215    @property
216    def refresh_handler(self):
217        """Returns the refresh handler if available.
218
219        Returns:
220           Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]:
221               The current refresh handler.
222        """
223        return self._refresh_handler
224
225    @refresh_handler.setter
226    def refresh_handler(self, value):
227        """Updates the current refresh handler.
228
229        Args:
230            value (Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]):
231                The updated value of the refresh handler.
232
233        Raises:
234            TypeError: If the value is not a callable or None.
235        """
236        if not callable(value) and value is not None:
237            raise TypeError("The provided refresh_handler is not a callable or None.")
238        self._refresh_handler = value
239
240    @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
241    def with_quota_project(self, quota_project_id):
242
243        return self.__class__(
244            self.token,
245            refresh_token=self.refresh_token,
246            id_token=self.id_token,
247            token_uri=self.token_uri,
248            client_id=self.client_id,
249            client_secret=self.client_secret,
250            scopes=self.scopes,
251            default_scopes=self.default_scopes,
252            quota_project_id=quota_project_id,
253            rapt_token=self.rapt_token,
254            enable_reauth_refresh=self._enable_reauth_refresh,
255        )
256
257    @_helpers.copy_docstring(credentials.Credentials)
258    def refresh(self, request):
259        scopes = self._scopes if self._scopes is not None else self._default_scopes
260        # Use refresh handler if available and no refresh token is
261        # available. This is useful in general when tokens are obtained by calling
262        # some external process on demand. It is particularly useful for retrieving
263        # downscoped tokens from a token broker.
264        if self._refresh_token is None and self.refresh_handler:
265            token, expiry = self.refresh_handler(request, scopes=scopes)
266            # Validate returned data.
267            if not isinstance(token, six.string_types):
268                raise exceptions.RefreshError(
269                    "The refresh_handler returned token is not a string."
270                )
271            if not isinstance(expiry, datetime):
272                raise exceptions.RefreshError(
273                    "The refresh_handler returned expiry is not a datetime object."
274                )
275            if _helpers.utcnow() >= expiry - _helpers.REFRESH_THRESHOLD:
276                raise exceptions.RefreshError(
277                    "The credentials returned by the refresh_handler are "
278                    "already expired."
279                )
280            self.token = token
281            self.expiry = expiry
282            return
283
284        if (
285            self._refresh_token is None
286            or self._token_uri is None
287            or self._client_id is None
288            or self._client_secret is None
289        ):
290            raise exceptions.RefreshError(
291                "The credentials do not contain the necessary fields need to "
292                "refresh the access token. You must specify refresh_token, "
293                "token_uri, client_id, and client_secret."
294            )
295
296        (
297            access_token,
298            refresh_token,
299            expiry,
300            grant_response,
301            rapt_token,
302        ) = reauth.refresh_grant(
303            request,
304            self._token_uri,
305            self._refresh_token,
306            self._client_id,
307            self._client_secret,
308            scopes=scopes,
309            rapt_token=self._rapt_token,
310            enable_reauth_refresh=self._enable_reauth_refresh,
311        )
312
313        self.token = access_token
314        self.expiry = expiry
315        self._refresh_token = refresh_token
316        self._id_token = grant_response.get("id_token")
317        self._rapt_token = rapt_token
318
319        if scopes and "scope" in grant_response:
320            requested_scopes = frozenset(scopes)
321            granted_scopes = frozenset(grant_response["scope"].split())
322            scopes_requested_but_not_granted = requested_scopes - granted_scopes
323            if scopes_requested_but_not_granted:
324                raise exceptions.RefreshError(
325                    "Not all requested scopes were granted by the "
326                    "authorization server, missing scopes {}.".format(
327                        ", ".join(scopes_requested_but_not_granted)
328                    )
329                )
330
331    @classmethod
332    def from_authorized_user_info(cls, info, scopes=None):
333        """Creates a Credentials instance from parsed authorized user info.
334
335        Args:
336            info (Mapping[str, str]): The authorized user info in Google
337                format.
338            scopes (Sequence[str]): Optional list of scopes to include in the
339                credentials.
340
341        Returns:
342            google.oauth2.credentials.Credentials: The constructed
343                credentials.
344
345        Raises:
346            ValueError: If the info is not in the expected format.
347        """
348        keys_needed = set(("refresh_token", "client_id", "client_secret"))
349        missing = keys_needed.difference(six.iterkeys(info))
350
351        if missing:
352            raise ValueError(
353                "Authorized user info was not in the expected format, missing "
354                "fields {}.".format(", ".join(missing))
355            )
356
357        # access token expiry (datetime obj); auto-expire if not saved
358        expiry = info.get("expiry")
359        if expiry:
360            expiry = datetime.strptime(
361                expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S"
362            )
363        else:
364            expiry = _helpers.utcnow() - _helpers.REFRESH_THRESHOLD
365
366        # process scopes, which needs to be a seq
367        if scopes is None and "scopes" in info:
368            scopes = info.get("scopes")
369            if isinstance(scopes, six.string_types):
370                scopes = scopes.split(" ")
371
372        return cls(
373            token=info.get("token"),
374            refresh_token=info.get("refresh_token"),
375            token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT,  # always overrides
376            scopes=scopes,
377            client_id=info.get("client_id"),
378            client_secret=info.get("client_secret"),
379            quota_project_id=info.get("quota_project_id"),  # may not exist
380            expiry=expiry,
381            rapt_token=info.get("rapt_token"),  # may not exist
382        )
383
384    @classmethod
385    def from_authorized_user_file(cls, filename, scopes=None):
386        """Creates a Credentials instance from an authorized user json file.
387
388        Args:
389            filename (str): The path to the authorized user json file.
390            scopes (Sequence[str]): Optional list of scopes to include in the
391                credentials.
392
393        Returns:
394            google.oauth2.credentials.Credentials: The constructed
395                credentials.
396
397        Raises:
398            ValueError: If the file is not in the expected format.
399        """
400        with io.open(filename, "r", encoding="utf-8") as json_file:
401            data = json.load(json_file)
402            return cls.from_authorized_user_info(data, scopes)
403
404    def to_json(self, strip=None):
405        """Utility function that creates a JSON representation of a Credentials
406        object.
407
408        Args:
409            strip (Sequence[str]): Optional list of members to exclude from the
410                                   generated JSON.
411
412        Returns:
413            str: A JSON representation of this instance. When converted into
414            a dictionary, it can be passed to from_authorized_user_info()
415            to create a new credential instance.
416        """
417        prep = {
418            "token": self.token,
419            "refresh_token": self.refresh_token,
420            "token_uri": self.token_uri,
421            "client_id": self.client_id,
422            "client_secret": self.client_secret,
423            "scopes": self.scopes,
424            "rapt_token": self.rapt_token,
425        }
426        if self.expiry:  # flatten expiry timestamp
427            prep["expiry"] = self.expiry.isoformat() + "Z"
428
429        # Remove empty entries (those which are None)
430        prep = {k: v for k, v in prep.items() if v is not None}
431
432        # Remove entries that explicitely need to be removed
433        if strip is not None:
434            prep = {k: v for k, v in prep.items() if k not in strip}
435
436        return json.dumps(prep)
437
438
439class UserAccessTokenCredentials(credentials.CredentialsWithQuotaProject):
440    """Access token credentials for user account.
441
442    Obtain the access token for a given user account or the current active
443    user account with the ``gcloud auth print-access-token`` command.
444
445    Args:
446        account (Optional[str]): Account to get the access token for. If not
447            specified, the current active account will be used.
448        quota_project_id (Optional[str]): The project ID used for quota
449            and billing.
450    """
451
452    def __init__(self, account=None, quota_project_id=None):
453        super(UserAccessTokenCredentials, self).__init__()
454        self._account = account
455        self._quota_project_id = quota_project_id
456
457    def with_account(self, account):
458        """Create a new instance with the given account.
459
460        Args:
461            account (str): Account to get the access token for.
462
463        Returns:
464            google.oauth2.credentials.UserAccessTokenCredentials: The created
465                credentials with the given account.
466        """
467        return self.__class__(account=account, quota_project_id=self._quota_project_id)
468
469    @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
470    def with_quota_project(self, quota_project_id):
471        return self.__class__(account=self._account, quota_project_id=quota_project_id)
472
473    def refresh(self, request):
474        """Refreshes the access token.
475
476        Args:
477            request (google.auth.transport.Request): This argument is required
478                by the base class interface but not used in this implementation,
479                so just set it to `None`.
480
481        Raises:
482            google.auth.exceptions.UserAccessTokenError: If the access token
483                refresh failed.
484        """
485        self.token = _cloud_sdk.get_auth_access_token(self._account)
486
487    @_helpers.copy_docstring(credentials.Credentials)
488    def before_request(self, request, method, url, headers):
489        self.refresh(request)
490        self.apply(headers)
491