Skip to content

Commit 74a1ff7

Browse files
feat: Add Python User Authorization App sample (#346)
* feat: Add Python User Authorization App sample * Update main.py to correctly mention it's a Flask app Co-authored-by: Vinay Vyas <[email protected]> --------- Co-authored-by: Vinay Vyas <[email protected]>
1 parent cf5ab5c commit 74a1ff7

File tree

10 files changed

+510
-0
lines changed

10 files changed

+510
-0
lines changed

python/user-auth-app/.gcloudignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file specifies files that are *not* uploaded to Google Cloud
2+
# using gcloud. It follows the same syntax as .gitignore, with the addition of
3+
# "#!include" directives (which insert the entries of the given .gitignore-style
4+
# file at that point).
5+
#
6+
# For more information, run:
7+
# $ gcloud topic gcloudignore
8+
#
9+
.gcloudignore
10+
README.md
11+
# If you would like to upload your .git directory, .gitignore file or files
12+
# from your .gitignore file, remove the corresponding line
13+
# below:
14+
.git
15+
.gitignore

python/user-auth-app/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
client_secrets.json

python/user-auth-app/README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Google Chat User Authorization App
2+
3+
This sample demonstrates how to create a Google Chat app that requests
4+
authorization from the user to make calls to Chat API on their behalf. The first
5+
time the user interacts with the app, it requests offline OAuth tokens for the
6+
user and saves them to a Firestore database. If the user interacts with the app
7+
again, the saved tokens are used so the app can call Chat API on behalf of the
8+
user without asking for authorization again. Once saved, the OAuth tokens could
9+
even be used to call Chat API without the user being present.
10+
11+
This app is built using Python on Google App Engine (Standard Environment) and
12+
leverages Google's OAuth2 for authorization and Firestore for data storage.
13+
14+
**Key Features:**
15+
16+
* **User Authorization:** Securely requests user consent to call Chat API with
17+
their credentials.
18+
* **Chat API Integration:** Calls Chat API to post messages on behalf of the
19+
user.
20+
* **Google Chat Integration:** Responds to DMs or @mentions in Google Chat. If
21+
necessary, request configuration to start an OAuth authorization flow.
22+
* **App Engine Deployment:** Provides step-by-step instructions for deploying
23+
to App Engine.
24+
* **Cloud Firestore:** Stores user tokens in a Firestore database.
25+
26+
## Prerequisites
27+
28+
* **Python 3:** [Download](https://www.python.org/downloads/)
29+
* **Google Cloud SDK:** [Install](https://cloud.google.com/sdk/docs/install)
30+
* **Google Cloud Project:** [Create](https://console.cloud.google.com/projectcreate)
31+
32+
## Deployment Steps
33+
34+
1. **Enable APIs:**
35+
36+
* Enable the Cloud Firestore and Google Chat APIs using the
37+
[console](https://console.cloud.google.com/apis/enableflow?apiid=firestore.googleapis.com,chat.googleapis.com)
38+
or gcloud:
39+
40+
```bash
41+
gcloud services enable firestore.googleapis.com chat.googleapis.com
42+
```
43+
44+
1. **Initiate Deployment to App Engine:**
45+
46+
* Go to [App Engine](https://console.cloud.google.com/appengine) and
47+
initialize an application.
48+
49+
* Deploy the User Authorization app to App Engine:
50+
51+
```bash
52+
gcloud app deploy
53+
```
54+
55+
1. **Create and Use OAuth Client ID:**
56+
57+
* Get the app hostname:
58+
59+
```bash
60+
gcloud app describe | grep defaultHostname
61+
```
62+
63+
* In your Google Cloud project, go to
64+
[APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials).
65+
* Click `Create Credentials > OAuth client ID`.
66+
* Select `Web application` as the application type.
67+
* Add `<hostname from the previous step>/oauth2` to `Authorized redirect URIs`.
68+
* Download the JSON file and rename it to `client_secrets.json` in your
69+
project directory.
70+
* Redeploy the app with the file `client_secrets.json`:
71+
72+
```bash
73+
gcloud app deploy
74+
```
75+
76+
1. **Create a Firestore Database:**
77+
78+
* Create a Firestore database in native mode named `auth-data` using the
79+
[console](https://console.cloud.google.com/firestore) or gcloud:
80+
81+
```bash
82+
gcloud firestore databases create \
83+
--database=auth-data \
84+
--location=REGION \
85+
--type=firestore-native
86+
```
87+
88+
Replace `REGION` with a
89+
[Firestore location](https://cloud.google.com/firestore/docs/locations#types)
90+
such as `nam5` or `eur3`.
91+
92+
## Create the Google Chat app
93+
94+
* Go to
95+
[Google Chat API](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat)
96+
and click `Configuration`.
97+
* In **App name**, enter `User Auth App`.
98+
* In **Avatar URL**, enter `https://developers.google.com/chat/images/quickstart-app-avatar.png`.
99+
* In **Description**, enter `Quickstart app`.
100+
* Under Functionality, select **Receive 1:1 messages** and
101+
**Join spaces and group conversations**.
102+
* Under **Connection settings**, select **HTTP endpoint URL** and enter your App
103+
Engine app's URL (obtained in the previous deployment steps).
104+
* In **Authentication Audience**, select **HTTP endpoint URL**.
105+
* Under **Visibility**, select **Make this Google Chat app available to specific
106+
people and groups in your domain** and enter your email address.
107+
* Click **Save**.
108+
109+
The Chat app is ready to receive and respond to messages on Chat.
110+
111+
## Interact with the App
112+
113+
* Add the app to a Google Chat space.
114+
* @mention the app.
115+
* Follow the authorization link to grant the app access to your account.
116+
* Once authorization is complete, the app will post a message to the space using
117+
your credentials.
118+
* If you @mention the app again, it will post a new message to the space with
119+
your credentials using the saved tokens, without asking for authorization again.
120+
121+
## Related Topics
122+
123+
* [Authenticate and authorize as a Google Chat user](https://developers.google.com/workspace/chat/authenticate-authorize-chat-user)
124+
* [Receive and respond to user interactions](https://developers.google.com/workspace/chat/receive-respond-interactions)

python/user-auth-app/app.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2025 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+
runtime: python312
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2025 Google LLC. All Rights Reserved.
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+
"""Functions to handle database operations."""
16+
17+
from google.cloud import firestore
18+
19+
# The prefix used by the Google Chat API in the User resource name.
20+
USERS_PREFIX = "users/"
21+
22+
# The name of the users collection in the database.
23+
USERS_COLLECTION = "users"
24+
25+
# Initialize the Firestore database using Application Default Credentials.
26+
db = firestore.Client(database="auth-data")
27+
28+
def store_token(user_name: str, access_token: str, refresh_token: str):
29+
"""Saves the user's OAuth2 tokens to storage."""
30+
doc_ref = db.collection(USERS_COLLECTION).document(user_name.replace(USERS_PREFIX, ""))
31+
doc_ref.set({ "accessToken": access_token, "refreshToken": refresh_token })
32+
33+
def get_token(user_name: str) -> dict | None:
34+
"""Fetches the user's OAuth2 tokens from storage."""
35+
doc = db.collection(USERS_COLLECTION).document(user_name.replace(USERS_PREFIX, "")).get()
36+
if doc.exists:
37+
return doc.to_dict()
38+
return None

python/user-auth-app/main.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2025 Google LLC. All Rights Reserved.
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+
"""The main script for the project, which starts a Flask app
16+
to listen to HTTP requests from Chat events and the OAuth flow callback."""
17+
18+
import logging
19+
import os
20+
import flask
21+
from werkzeug.middleware.proxy_fix import ProxyFix
22+
from request_verifier import verify_google_chat_request
23+
from oauth_flow import oauth2callback
24+
from user_auth_post import post_with_user_credentials
25+
26+
logging.basicConfig(
27+
level=logging.INFO,
28+
style="{",
29+
format="[{levelname:.1}{asctime} {filename}:{lineno}] {message}"
30+
)
31+
32+
app = flask.Flask(__name__)
33+
app.wsgi_app = ProxyFix(app.wsgi_app)
34+
35+
@app.route("/", methods=["GET"])
36+
def on_get() -> dict:
37+
"""App route that handles unsupported GET requests."""
38+
return "Hello! This endpoint is meant to be called from Google Chat."
39+
40+
@app.route("/", methods=["POST"])
41+
def on_event() -> dict:
42+
"""App route that responds to interaction events from Google Chat."""
43+
if not verify_google_chat_request(flask.request):
44+
return "Hello! This endpoint is meant to be called from Google Chat."
45+
if event := flask.request.get_json(silent=True):
46+
if event["message"]:
47+
# Post a message back to the same Chat space using user credentials.
48+
return flask.jsonify(post_with_user_credentials(event))
49+
# Ignore events that don't contain a message.
50+
return flask.jsonify({})
51+
return "Error: Unknown action"
52+
53+
@app.route("/oauth2", methods=["GET"])
54+
def on_oauth2():
55+
"""App route that handles callback requests from the OAuth2 authorization flow.
56+
The handler exhanges the code received from the OAuth2 server with a set of
57+
credentials, stores the authentication and refresh tokens in the database,
58+
and redirects the request to the config complete URL provided in the request.
59+
"""
60+
return oauth2callback(flask.request.url)
61+
62+
if __name__ == "__main__":
63+
PORT=os.getenv("PORT", "8080")
64+
app.run(port=PORT)

python/user-auth-app/oauth_flow.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright 2025 Google LLC. All Rights Reserved.
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+
"""Functions to handle the OAuth authentication flow."""
16+
17+
import json
18+
import logging
19+
from urllib.parse import parse_qs, urlparse
20+
21+
import flask
22+
import google_auth_oauthlib.flow
23+
from google.auth.transport import requests
24+
from google.oauth2 import id_token
25+
from google.oauth2.credentials import Credentials
26+
from firestore_service import store_token
27+
28+
# This variable specifies the name of a file that contains the OAuth 2.0
29+
# information for this application, including its client_id and client_secret.
30+
CLIENT_SECRETS_FILE = "client_secrets.json"
31+
32+
# Application OAuth credentials.
33+
KEYS = json.load(open(CLIENT_SECRETS_FILE, encoding="UTF-8"))["web"]
34+
35+
# Define the app's authorization scopes.
36+
# Note: 'openid' is required to that Google Auth will return a JWT with the
37+
# user id, which we can use to validate that the user who granted consent is
38+
# the same who requested it (to avoid identity theft).
39+
SCOPES = ["openid", "https://www.googleapis.com/auth/chat.messages.create"]
40+
41+
def generate_auth_url(user_name: str, config_complete_redirect_url: str) -> str:
42+
"""Generates the URL to start the OAuth2 authorization flow."""
43+
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
44+
CLIENT_SECRETS_FILE, scopes=SCOPES)
45+
flow.redirect_uri = KEYS["redirect_uris"][0]
46+
# Generate URL for request to Google's OAuth 2.0 server.
47+
auth_url, _ = flow.authorization_url(
48+
# Enable offline access so that you can refresh an access token without
49+
# re-prompting the user for permission.
50+
access_type="offline",
51+
# Optional, enable incremental authorization. Recommended as a best practice.
52+
include_granted_scopes="true",
53+
state=json.dumps({
54+
"userName": user_name,
55+
"configCompleteRedirectUrl": config_complete_redirect_url
56+
})
57+
)
58+
return auth_url
59+
60+
def create_credentials(access_token: str, refresh_token: str) -> Credentials:
61+
"""Returns the Credentials to authenticate using the user tokens."""
62+
return Credentials(
63+
token = access_token,
64+
refresh_token = refresh_token,
65+
token_uri = KEYS["token_uri"],
66+
client_id = KEYS["client_id"],
67+
client_secret = KEYS["client_secret"],
68+
scopes = SCOPES
69+
)
70+
71+
def oauth2callback(url: str):
72+
"""Handles an OAuth2 callback request.
73+
If the authorization was succesful, it exchanges the received code with the
74+
access and refresh tokens and saves them into Firestore to be used when
75+
calling the Chat API. Then, it redirects the response to the
76+
configCompleteRedirectUrl specified in the authorization URL.
77+
If the authorization fails, it just prints an error message to the response.
78+
"""
79+
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
80+
CLIENT_SECRETS_FILE, scopes=SCOPES)
81+
flow.redirect_uri = KEYS["redirect_uris"][0]
82+
83+
# Fetch state from url
84+
parsed = urlparse(url)
85+
qs = parse_qs(parsed.query)
86+
if "error" in qs:
87+
# An error response e.g. error=access_denied.
88+
logging.warning("Error: %s", qs["error"][0])
89+
return "Error: " + qs["error"][0]
90+
91+
# Use the authorization server's response to fetch the OAuth 2.0 tokens.
92+
if "code" not in qs:
93+
logging.warning("Error: invalid query code.")
94+
return "Error: invalid query code."
95+
code = qs["code"][0]
96+
flow.fetch_token(code=code)
97+
credentials = flow.credentials
98+
token = id_token.verify_oauth2_token(
99+
credentials.id_token, requests.Request(), KEYS["client_id"])
100+
user_name = "users/" + token["sub"]
101+
102+
# Save tokens to the database so the app can use them to make API calls.
103+
store_token(user_name, credentials.token, credentials.refresh_token)
104+
105+
# Validate that the user who granted consent is the same who requested it.
106+
if "state" not in qs:
107+
logging.warning("Error: invalid query state.")
108+
return "Error: invalid query state."
109+
state = json.loads(qs["state"][0])
110+
if user_name != state["userName"]:
111+
logging.warning("Error: token user does not correspond to request user.")
112+
return """Error: the user who granted consent does not correspond to
113+
the user who initiated the request. Please start the configuration
114+
again and use the same account you're using in Google Chat."""
115+
116+
# Redirect to the URL that tells Google Chat that the configuration is
117+
# completed.
118+
return flask.redirect(state["configCompleteRedirectUrl"])

0 commit comments

Comments
 (0)