Skip to content

Commit 9ca7df6

Browse files
committed
Sundry updates and bugfixes
- Update to 1.0.0 - Fix the issue where the token is written as `str` instead of `bytes` - Add documentation - Update `README.md`
1 parent 0df281f commit 9ca7df6

20 files changed

+307
-71
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.idea/
2+
.venv/
23
.vscode/
34
**/local/
45
**/logs/

README.md

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,86 @@ This library will store OAuth tokens in any of the following places:
1515
1. Google Cloud Storage files
1616
1. A local `json` file
1717

18-
Other storage locations can be added at will simply by forking this library and
19-
extending the appropriate abstract classes.
18+
Other storage locations can be added at will simply by extending the
19+
`AbstractDatastore` class in the same way as the four examples.
2020

2121

2222
## Initial Setup And Installation
2323

24-
##
24+
## Enable the APIs on Google Cloud
2525

26+
In order to use the connectors to any of the Google Cloud storage methods
27+
(Secret Manager, Firestore and Google Cloud Storage) you will have to ensure
28+
that the relevant APIs have been enabled. Follow the instructions listed in the
29+
[developer documentation](https://cloud.google.com/apis/docs/getting-started)
30+
to enable the API you need.
31+
32+
## Ensure the app's service account has acces to the APIs
33+
34+
## Implementation specific
35+
36+
### Secret Manager
37+
38+
Two secrets will need to be manually added to Secret Manager before the library
39+
can be used. These are the client id and client secret. The easiest way to do
40+
this is using a small shell script like this:
41+
42+
```
43+
#!/bin/bash
44+
45+
while [[ $1 == -* ]] ; do
46+
case $1 in
47+
--project*)
48+
IFS="=" read _cmd PROJECT <<< "$1" && [ -z ${PROJECT} ] && shift && PROJECT=$1
49+
;;
50+
--client-id*)
51+
IFS="=" read _cmd CLIENT_ID <<< "$1" && [ -z ${CLIENT_ID} ] && shift && CLIENT_ID=$1
52+
;;
53+
--client-secret*)
54+
IFS="=" read _cmd CLIENT_SECRET <<< "$1" && [ -z ${CLIENT_SECRET} ] && shift && CLIENT_SECRET=$1
55+
;;
56+
*)
57+
usage
58+
echo ""
59+
echo "Unknown parameter $1."
60+
exit
61+
esac
62+
shift
63+
done
64+
65+
if [ -z ${CLIENT_ID} ] || [ -z ${CLIENT_SECRET} ] || [ -z ${PROJECT} ]; then
66+
echo You must supply CLIENT_ID and CLIENT_SECRET.
67+
exit
68+
fi
69+
70+
gcloud --project ${PROJECT} secrets create client_id --replication-policy=automatic 2>/dev/null
71+
echo "{ \"client_id\": \"${CLIENT_ID}\" }" | gcloud --project ${PROJECT} secrets versions add client_id --data-file=-
72+
73+
gcloud --project ${PROJECT} secrets create client_secret --replication-policy=automatic 2>/dev/null
74+
echo "{ \"client_id\": \"${CLIENT_SECRET}\" }" | gcloud --project ${PROJECT} secrets versions add client_secret --data-file=-
75+
76+
```
77+
78+
The library will create any further secrets and versions automatically. It will
79+
also remove all but the latest secret each time an update occurs. This reduces
80+
the usage cost of Secret Manager substantially as projects are charged based
81+
partially on number of _active_ (ie not destroyed) secret versions.
82+
83+
### Firestore
84+
85+
Firestore requires no additional configuration.
86+
87+
### Google Cloud Storage
88+
89+
To use Google Cloud Storage you must have a bucket created in which the user
90+
token files and project secrets are to be stored and to which the app's service
91+
account has read/write access. This should then be locked down so that no other
92+
non-administrators have access.
93+
94+
### Local files
95+
96+
No special configuration is required. This implementation is HIGHLY insecure,
97+
and is provided simply for testing/development purposes.
2698

2799
## Examples
28100

@@ -54,7 +126,9 @@ key = manager.get_document(encode_key('<token id>'))
54126

55127
All that changes is where the datastore is!
56128

57-
### Storing a token in Secret Manager
129+
### Storing a token
130+
131+
#### Secret Manager
58132

59133
```
60134
from auth.secret_manager import SecretManager
@@ -66,21 +140,20 @@ manager.update_document(id=encode_key('<token_id>'), new_data=<token string>)
66140
This will implicitly create a `secret` if there was not one already, or simply
67141
update an existing secret with a new 'live' version of the secret.
68142

69-
### Listing all the available secrets
143+
### Removing a secret
70144

71145
```
72146
from auth.secret_manager import SecretManager
73147
manager = SecretManager(project='<gcp project name>')
74148
75-
manager.list_documents()
149+
manager.delete_document(id=encode_key('<token_id>'))
76150
```
77151

78-
79-
### Removing a secret
152+
### Listing all the available secrets
80153

81154
```
82155
from auth.secret_manager import SecretManager
83156
manager = SecretManager(project='<gcp project name>')
84157
85-
manager.delete_document(id=encode_key('<token_id>'))
158+
manager.list_documents()
86159
```

auth/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2020 Google LLC
1+
# Copyright 2024 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.

auth/abstract_datastore.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2020 Google LLC
1+
# Copyright 2024 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.

auth/credentials.py

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2022 Google LLC
1+
# Copyright 2024 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
from datetime import datetime
1818
import json
1919
from typing import Any, Dict, Mapping, Type, TypeVar, Union
20+
from io import BytesIO
2021

2122
import pytz
2223
from dateutil.relativedelta import relativedelta
@@ -25,13 +26,18 @@
2526

2627
from auth import decorators
2728

28-
from .abstract_datastore import AbstractDatastore
29-
from .credentials_helpers import encode_key
30-
from .exceptions import CredentialsError
29+
from auth.abstract_datastore import AbstractDatastore
30+
from auth.credentials_helpers import encode_key
31+
from auth.exceptions import CredentialsError
3132

3233

3334
@dataclass
3435
class ProjectCredentials(object):
36+
"""ProjectCredentials
37+
38+
A dataclass to hold the client is and secret to be passed around within the
39+
functions.
40+
"""
3541
client_id: str
3642
client_secret: str
3743

@@ -69,11 +75,33 @@ def datastore(self) -> AbstractDatastore:
6975

7076
@datastore.setter
7177
def datastore(self, f: AbstractDatastore) -> None:
78+
"""datastore setter
79+
80+
This will always raise an exception. Once the `Credentials` object has been
81+
created, the datastore cannot be changed. If you need to move the object
82+
from one datastore to another, then create a second `Credentials` object
83+
pointing to the new datastore and pass in the OAuth Credentials, either
84+
as the OAuth credentials object or as a `Mapping[str, Any]`.
85+
86+
Args:
87+
f (AbstractDatastore): the datastore
88+
89+
Raises:
90+
KeyError: the datastore should be immutable
91+
"""
7292
raise KeyError('Datastore can only be set on instantiation.')
7393

7494
@decorators.lazy_property
7595
def project_credentials(self) -> ProjectCredentials:
76-
"""The project credentials."""
96+
"""The project credentials.
97+
98+
This is lazy-evaluated using the decorator, so the project credentials
99+
are only fetched once from the datastore for the life of the object,
100+
reducing API calls and thus improving speed and reducing cost.
101+
102+
Returns:
103+
ProjectCredentials: the project client id and secret as a dataclass
104+
"""
77105
secrets = None
78106
if secrets := self.datastore.get_document(id='client_id'):
79107
secrets |= self.datastore.get_document(id='client_secret')
@@ -92,7 +120,7 @@ def project_credentials(self) -> ProjectCredentials:
92120

93121
@decorators.lazy_property
94122
def token_details(self) -> Dict[str, Any]:
95-
"""The users's refresh and access token."""
123+
"""The users's OAuth token."""
96124
return self.datastore.get_document(id=encode_key(self._email))
97125

98126
def store_credentials(self,
@@ -111,7 +139,8 @@ def store_credentials(self,
111139
key = encode_key(self._email)
112140

113141
if isinstance(creds, oauth.Credentials):
114-
self.datastore.update_document(id=key, new_data=creds.to_json())
142+
self.datastore.update_document(id=key,
143+
new_data=self._to_dict(creds))
115144
else:
116145
self.datastore.update_document(id=key, new_data=creds)
117146

@@ -125,23 +154,60 @@ def _refresh_credentials(self, creds: oauth.Credentials) -> None:
125154
self.store_credentials(creds)
126155

127156
def _to_utc(self, last_date: datetime) -> datetime:
157+
"""Convert a datetime to UTC
158+
159+
Args:
160+
last_date (datetime): the date to convert
161+
162+
Returns:
163+
datetime: the date in UTC
164+
"""
128165
if (last_date.tzinfo is None or
129166
last_date.tzinfo.utcoffset(last_date) is None):
130167
last_date = pytz.UTC.localize(last_date)
131168

132169
return last_date
133170

134-
@property
171+
def _to_dict(self, credentials: oauth.Credentials) -> Mapping[str, Any]:
172+
"""Convert an OAuth token to a dict
173+
174+
Note the conversion of the expiry date (A `datetime` object) to the Zulu
175+
format date string. This is because the primary function of the dict is to
176+
be serialized to the `Datastore` and we have to ensure that all fields can
177+
be turned to `json` for this purpose. When reinstantiated, the OAuth
178+
Credentials object takes care of converting the expiry date back to a
179+
`datetime` for us.
180+
181+
Args:
182+
credentials (oauth.Credentials): the OAuth credentials
183+
184+
Returns:
185+
Mapping[str, Any]: the credentials as a `dict[str, Any]`
186+
"""
187+
return {'token': credentials.token,
188+
'refresh_token': credentials.refresh_token,
189+
'token_uri': credentials.token_uri,
190+
'client_id': credentials.client_id,
191+
'client_secret': credentials.client_secret,
192+
'scopes': credentials.scopes,
193+
'default_scopes': credentials.default_scopes,
194+
'expiry': credentials.expiry.strftime('%Y-%m-%dT%H:%M:%SZ')}
195+
196+
@ property
135197
def credentials(self) -> oauth.Credentials:
136198
"""Fetches the credentials.
137199
138200
Returns:
139201
(google.oauth2.credentials.Credentials): the credentials
140202
"""
141203
expiry = self._to_utc(
142-
datetime.now().astimezone(pytz.utc) + relativedelta(minutes=30))
204+
datetime.now().astimezone(pytz.utc) + relativedelta(minutes=60))
205+
143206
if token := self.token_details:
144-
creds = oauth.Credentials.from_authorized_user_info(json.loads(token))
207+
if isinstance(token, str):
208+
token = json.loads(token)
209+
210+
creds = oauth.Credentials.from_authorized_user_info(token)
145211

146212
if creds.expired:
147213
creds.expiry = expiry
@@ -153,7 +219,7 @@ def credentials(self) -> oauth.Credentials:
153219

154220
return creds
155221

156-
@property
222+
@ property
157223
def auth_headers(self) -> Dict[str, Any]:
158224
"""Returns authorized http headers.
159225

auth/credentials_helpers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2022 Google LLC
1+
# Copyright 2024 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -15,7 +15,7 @@
1515

1616
import base64
1717

18-
from .exceptions import KeyEncodingError
18+
from auth.exceptions import KeyEncodingError
1919

2020

2121
def encode_key(key: str) -> str:
@@ -32,7 +32,7 @@ def encode_key(key: str) -> str:
3232
"""
3333
try:
3434
if encoded_key := base64.b64encode(
35-
key.encode('utf-8')).decode('utf-8').rstrip('='):
35+
key.encode('utf-8')).decode('utf-8').rstrip('='):
3636
return encoded_key
3737

3838
except:

auth/credentials_helpers_test.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2021 Google LLC
1+
# Copyright 2024 Google LLC
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -16,8 +16,8 @@
1616
import unittest
1717
from unittest import mock
1818

19-
from .credentials_helpers import encode_key
20-
from .exceptions import KeyEncodingError
19+
from auth.credentials_helpers import encode_key
20+
from auth.exceptions import KeyEncodingError
2121

2222

2323
class CredentialsHelpersTest(unittest.TestCase):

0 commit comments

Comments
 (0)