Skip to content

Commit 6f7938c

Browse files
committed
fix: [Python] Auth App
1 parent 76fceff commit 6f7938c

File tree

6 files changed

+308
-193
lines changed

6 files changed

+308
-193
lines changed

python/auth-app/README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,27 @@ please read the
2424
1. Enable the Cloud Datastore API for your project using
2525
[this wizard](https://console.cloud.google.com/flows/enableapi?apiid=datastore.googleapis.com).
2626
1. Follow [instructions](https://support.google.com/googleapi/answer/6158849?hl=en) for creating
27-
an oauth client ID for your project. Use the type "Web application" and a redirect
28-
URI of \
29-
`https://<project ID>.appspot.com/auth/callback`.
27+
an oauth client ID for your project. Use the type "Web application".
3028
1. Download the associated JSON file, move it to this directory, and name it
31-
`client_secret.json`.
29+
`client_secrets.json`.
3230

3331
1. Run the following command to deploy the app:
3432
```
3533
gcloud app deploy
3634
```
35+
1. Fetch the URL:
36+
```
37+
gcloud app browse
38+
```
39+
1. Replace the `redirect_uris` in your `client_secrets.json` with `<URL from the previous step>/auth/callback`.
40+
1. Create a [service account](https://support.google.com/a/answer/7378726?hl=en#)
41+
1. Make the service account the default in the [App Engine Application Settings](https://console.cloud.google.com/appengine/settings)
42+
1. Grant the service account permissions to access the datastore
43+
```
44+
gcloud projects add-iam-policy-binding $PROJECT_ID \
45+
--member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
46+
--role="roles/datastore.owner"
47+
```
3748
3849
## Configure the app for Google Chat
3950
@@ -76,7 +87,25 @@ To verify that the sample is running and responds with the correct data
7687
to incoming requests, run the following command from the terminal:
7788
7889
```
79-
curl -H 'Content-Type: application/json' --data '{"type": "MESSAGE", "configCompleteRedirectUrl": "https://www.example.com", "message": { "text": "header keyvalue", "thread": null }, "user": { "name": "users/123", "displayName": "me"}, "space": { "displayName": "space", "name": "spaces/-oMssgAAAAE"}}' http://127.0.0.1:8080/
90+
curl \
91+
-H 'Content-Type: application/json' \
92+
--data '{
93+
"type": "MESSAGE",
94+
"configCompleteRedirectUrl": "https://www.example.com",
95+
"message": {
96+
"text": "header keyvalue",
97+
"thread": null
98+
},
99+
"user": {
100+
"name": "users/123",
101+
"displayName": "me"
102+
},
103+
"space": {
104+
"displayName": "space",
105+
"name": "spaces/-oMssgAAAAE"
106+
}
107+
}' \
108+
http://127.0.0.1:8080/
80109
```
81110
82111
## Shut down the local environment

python/auth-app/app.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ runtime: python310
2121
env_variables:
2222
CLIENT_SECRET_PATH: "client_secret.json"
2323
SESSION_SECRET: "notasecret"
24+
25+
service_account: <SERVICE_ACCOUNT>

python/auth-app/auth.py

Lines changed: 129 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,22 @@
3232
from google.oauth2.credentials import Credentials
3333
from google_auth_oauthlib import flow
3434

35-
CLIENT_SECRET_PATH = os.environ.get('CLIENT_SECRET_PATH', 'client_secret.json')
36-
JWT_SECRET = os.environ.get('SESSION_SECRET', 'notasecret')
35+
CLIENT_SECRET_PATH = os.environ.get("CLIENT_SECRET_PATH", "client_secrets.json")
36+
JWT_SECRET = os.environ.get("SESSION_SECRET", "notasecret")
3737

38-
mod = flask.Blueprint('auth', __name__)
38+
mod = flask.Blueprint("auth", __name__)
3939

4040
# Scopes required to access the People API.
4141
PEOPLE_API_SCOPES = [
42-
'openid',
43-
'https://www.googleapis.com/auth/user.emails.read',
44-
'https://www.googleapis.com/auth/user.addresses.read',
45-
'https://www.googleapis.com/auth/userinfo.profile',
46-
'https://www.googleapis.com/auth/userinfo.email',
47-
'https://www.googleapis.com/auth/user.phonenumbers.read',
42+
"openid",
43+
"https://www.googleapis.com/auth/user.emails.read",
44+
"https://www.googleapis.com/auth/user.addresses.read",
45+
"https://www.googleapis.com/auth/userinfo.profile",
46+
"https://www.googleapis.com/auth/userinfo.email",
47+
"https://www.googleapis.com/auth/user.phonenumbers.read",
4848
]
4949

50+
5051
class Store:
5152
"""Manages storage in Google Cloud Datastore."""
5253

@@ -62,12 +63,15 @@ def get_user_credentials(self, user_name: str) -> Credentials | None:
6263
Returns:
6364
A Credentials object, or None if the user has not authorized the app.
6465
"""
65-
key = self.datastore_client.key('RefreshToken', user_name)
66-
entity = self.datastore_client.get(key)
67-
68-
if entity is None or 'credentials' not in entity:
66+
try:
67+
key = self.datastore_client.key("RefreshToken", user_name)
68+
entity = self.datastore_client.get(key)
69+
if entity is None or "credentials" not in entity:
70+
return None
71+
return Credentials(**entity["credentials"])
72+
except Exception as e:
73+
logging.exception("Error retrieving credentials: %s", e)
6974
return None
70-
return Credentials(**entity['credentials'])
7175

7276
def put_user_credentials(self, user_name: str, creds: Credentials) -> None:
7377
"""Stores OAuth2 credentials for a user.
@@ -76,35 +80,51 @@ def put_user_credentials(self, user_name: str, creds: Credentials) -> None:
7680
user_name (str): The identifier for the associated user.
7781
creds (Credentials): The OAuth2 credentials obtained for the user.
7882
"""
79-
key = self.datastore_client.key('RefreshToken', user_name)
80-
entity = datastore.Entity(key)
81-
entity.update({
82-
'credentials': {
83-
'token': creds.token,
84-
'refresh_token': creds.refresh_token,
85-
'token_uri': creds.token_uri,
86-
'client_id': creds.client_id,
87-
'client_secret': creds.client_secret,
88-
'scopes': creds.scopes,
89-
},
90-
'timestamp': time.time()
91-
})
92-
self.datastore_client.put(entity)
83+
try:
84+
key = self.datastore_client.key("RefreshToken", user_name)
85+
entity = datastore.Entity(key)
86+
entity.update(
87+
{
88+
"credentials": {
89+
"token": creds.token,
90+
"refresh_token": creds.refresh_token,
91+
"token_uri": creds.token_uri,
92+
"client_id": creds.client_id,
93+
"client_secret": creds.client_secret,
94+
"scopes": creds.scopes,
95+
},
96+
"timestamp": time.time(),
97+
}
98+
)
99+
self.datastore_client.put(entity)
100+
except Exception as e:
101+
logging.exception("Error storing credentials: %s", e)
93102

94103
def delete_user_credentials(self, user_name: str) -> None:
95104
"""Deleted stored OAuth2 credentials for a user.
96105
97106
Args:
98107
user_name (str): The identifier for the associated user.
99108
"""
100-
key = self.datastore_client.key('RefreshToken', user_name)
109+
try:
110+
key = self.datastore_client.key("RefreshToken", user_name)
111+
self.datastore_client.delete(key)
112+
except Exception as e:
113+
logging.exception("Error deleting credentials: %s", e)
114+
115+
key = self.datastore_client.key("RefreshToken", user_name)
101116
self.datastore_client.delete(key)
102117

103118

104119
def get_user_credentials(user_name: str) -> Credentials:
105120
"""Gets stored crednetials for a user, if it exists."""
106-
store = Store()
107-
return store.get_user_credentials(user_name)
121+
try:
122+
store = Store()
123+
return store.get_user_credentials(user_name)
124+
except Exception as e:
125+
logging.exception("Error getting credentials: %s", e)
126+
return None
127+
108128

109129
def get_config_url(event) -> Any:
110130
"""Gets the authorization URL to redirect the user to.
@@ -116,11 +136,14 @@ def get_config_url(event) -> Any:
116136
Returns:
117137
str: The authorization URL to direct the user to.
118138
"""
119-
payload = {
120-
'completion_url': event['configCompleteRedirectUrl']
121-
}
122-
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
123-
return flask.url_for('auth.start_auth', token=token, _external=True)
139+
try:
140+
payload = {"completion_url": event["configCompleteRedirectUrl"]}
141+
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
142+
return flask.url_for("auth.start_auth", token=token, _external=True)
143+
except Exception as e:
144+
logging.exception("Error getting config URL: %s", e)
145+
return None
146+
124147

125148
def logout(user_name: str) -> None:
126149
"""Logs out the user, removing their stored credentials and revoking the
@@ -129,66 +152,78 @@ def logout(user_name: str) -> None:
129152
Args:
130153
user_name (str): The identifier of the user.
131154
"""
132-
store = Store()
133-
user_credentials = store.get_user_credentials(user_name)
134-
if user_credentials is None:
135-
logging.info('Ignoring logout request for user %s', user_name)
136-
return
137-
logging.info('Logging out user %s', user_name)
138-
store.delete_user_credentials(user_name)
139-
request = requests.Request()
140-
request.post(
141-
'https://accounts.google.com/o/oauth2/revoke',
142-
params={'token': user_credentials.token},
143-
headers={'Content-Type': 'application/x-www-form-urlencoded'})
144-
145-
146-
@mod.route('/start')
155+
try:
156+
store = Store()
157+
user_credentials = store.get_user_credentials(user_name)
158+
if user_credentials is None:
159+
logging.info("Ignoring logout request for user %s", user_name)
160+
return
161+
logging.info("Logging out user %s", user_name)
162+
store.delete_user_credentials(user_name)
163+
request = requests.Request()
164+
request.post(
165+
"https://accounts.google.com/o/oauth2/revoke",
166+
params={"token": user_credentials.token},
167+
headers={"Content-Type": "application/x-www-form-urlencoded"},
168+
)
169+
except Exception as e:
170+
logging.exception("Error logging out user: %s", e)
171+
172+
173+
@mod.route("/start")
147174
def start_auth() -> flask.Response:
148175
"""Begins the oauth flow to authorize access to profile data."""
149-
token = flask.request.args['token']
150-
request = jwt.decode(token, JWT_SECRET, algorithm='HS256')
151-
152-
flask.session['completion_url'] = request['completion_url']
153-
oauth2_flow = flow.Flow.from_client_secrets_file(
154-
CLIENT_SECRET_PATH,
155-
scopes=PEOPLE_API_SCOPES,
156-
redirect_uri=flask.url_for('auth.on_oauth2_callback', _external=True))
157-
oauth2_url, state = oauth2_flow.authorization_url(
158-
access_type='offline',
159-
include_granted_scopes='true',
160-
prompt='consent')
161-
flask.session['state'] = state
162-
return flask.redirect(oauth2_url)
163-
164-
@mod.route('/callback')
176+
try:
177+
token = flask.request.args["token"]
178+
request = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
179+
180+
flask.session["completion_url"] = request["completion_url"]
181+
oauth2_flow = flow.Flow.from_client_secrets_file(
182+
CLIENT_SECRET_PATH,
183+
scopes=PEOPLE_API_SCOPES,
184+
redirect_uri=flask.url_for("auth.on_oauth2_callback", _external=True),
185+
)
186+
oauth2_url, state = oauth2_flow.authorization_url(
187+
access_type="offline", include_granted_scopes="true", prompt="consent"
188+
)
189+
flask.session["state"] = state
190+
return flask.redirect(oauth2_url)
191+
except Exception as e:
192+
logging.exception("Error starting auth: %s", e)
193+
return flask.abort(403)
194+
195+
196+
@mod.route("/callback")
165197
def on_oauth2_callback() -> flask.Response:
166198
"""Handles the OAuth callback."""
167-
saved_state = flask.session['state']
168-
state = flask.request.args['state']
169-
170-
if state != saved_state:
171-
logging.warn('Mismatched state in oauth response')
199+
try:
200+
saved_state = flask.session["state"]
201+
state = flask.request.args["state"]
202+
203+
if state != saved_state:
204+
logging.warn("Mismatched state in oauth response")
205+
return flask.abort(403)
206+
207+
redirect_uri = flask.url_for("auth.on_oauth2_callback", _external=True)
208+
oauth2_flow = flow.Flow.from_client_secrets_file(
209+
CLIENT_SECRET_PATH, scopes=PEOPLE_API_SCOPES, redirect_uri=redirect_uri
210+
)
211+
oauth2_flow.fetch_token(authorization_response=flask.request.url)
212+
creds = oauth2_flow.credentials
213+
214+
# Use the id_token to identify the chat user.
215+
request = requests.Request()
216+
id_info = id_token.verify_oauth2_token(creds.id_token, request, creds.client_id)
217+
218+
if id_info["iss"] != "https://accounts.google.com":
219+
flask.abort(403)
220+
221+
user_id = id_info["sub"]
222+
user_name = "users/{user_id}".format(user_id=user_id)
223+
store = Store()
224+
store.put_user_credentials(user_name, creds)
225+
completion_url = flask.session["completion_url"]
226+
return flask.redirect(completion_url)
227+
except Exception as e:
228+
logging.exception("Error completing auth: %s", e)
172229
return flask.abort(403)
173-
174-
redirect_uri = flask.url_for('auth.on_oauth2_callback', _external=True)
175-
oauth2_flow = flow.Flow.from_client_secrets_file(
176-
CLIENT_SECRET_PATH,
177-
scopes=PEOPLE_API_SCOPES,
178-
redirect_uri=redirect_uri)
179-
oauth2_flow.fetch_token(authorization_response=flask.request.url)
180-
creds = oauth2_flow.credentials
181-
182-
# Use the id_token to identify the chat user.
183-
request = requests.Request()
184-
id_info = id_token.verify_oauth2_token(creds.id_token, request, creds.client_id)
185-
186-
if id_info['iss'] != 'https://accounts.google.com':
187-
flask.abort(403)
188-
189-
user_id = id_info['sub']
190-
user_name = 'users/{user_id}'.format(user_id=user_id)
191-
store = Store()
192-
store.put_user_credentials(user_name, creds)
193-
completion_url = flask.session['completion_url']
194-
return flask.redirect(completion_url)

python/auth-app/install.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/bash
2+
3+
python3 -m venv .venv
4+
source .venv/bin/activate
5+
python3 -m pip install --upgrade -r requirements.txt

0 commit comments

Comments
 (0)