Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Python User Authorization App sample #346

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions python/user-auth-app/.gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# This file specifies files that are *not* uploaded to Google Cloud
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
README.md
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
1 change: 1 addition & 0 deletions python/user-auth-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
client_secrets.json
124 changes: 124 additions & 0 deletions python/user-auth-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Google Chat User Authorization App

This sample demonstrates how to create a Google Chat app that requests
authorization from the user to make calls to Chat API on their behalf. The first
time the user interacts with the app, it requests offline OAuth tokens for the
user and saves them to a Firestore database. If the user interacts with the app
again, the saved tokens are used so the app can call Chat API on behalf of the
user without asking for authorization again. Once saved, the OAuth tokens could
even be used to call Chat API without the user being present.

This app is built using Python on Google App Engine (Standard Environment) and
leverages Google's OAuth2 for authorization and Firestore for data storage.

**Key Features:**

* **User Authorization:** Securely requests user consent to call Chat API with
their credentials.
* **Chat API Integration:** Calls Chat API to post messages on behalf of the
user.
* **Google Chat Integration:** Responds to DMs or @mentions in Google Chat. If
necessary, request configuration to start an OAuth authorization flow.
* **App Engine Deployment:** Provides step-by-step instructions for deploying
to App Engine.
* **Cloud Firestore:** Stores user tokens in a Firestore database.

## Prerequisites

* **Python 3:** [Download](https://www.python.org/downloads/)
* **Google Cloud SDK:** [Install](https://cloud.google.com/sdk/docs/install)
* **Google Cloud Project:** [Create](https://console.cloud.google.com/projectcreate)

## Deployment Steps

1. **Enable APIs:**

* Enable the Cloud Firestore and Google Chat APIs using the
[console](https://console.cloud.google.com/apis/enableflow?apiid=firestore.googleapis.com,chat.googleapis.com)
or gcloud:

```bash
gcloud services enable firestore.googleapis.com chat.googleapis.com
```

1. **Initiate Deployment to App Engine:**

* Go to [App Engine](https://console.cloud.google.com/appengine) and
initialize an application.

* Deploy the User Authorization app to App Engine:

```bash
gcloud app deploy
```

1. **Create and Use OAuth Client ID:**

* Get the app hostname:

```bash
gcloud app describe | grep defaultHostname
```

* In your Google Cloud project, go to
[APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials).
* Click `Create Credentials > OAuth client ID`.
* Select `Web application` as the application type.
* Add `<hostname from the previous step>/oauth2` to `Authorized redirect URIs`.
* Download the JSON file and rename it to `client_secrets.json` in your
project directory.
* Redeploy the app with the file `client_secrets.json`:

```bash
gcloud app deploy
```

1. **Create a Firestore Database:**

* Create a Firestore database in native mode named `auth-data` using the
[console](https://console.cloud.google.com/firestore) or gcloud:

```bash
gcloud firestore databases create \
--database=auth-data \
--location=REGION \
--type=firestore-native
```

Replace `REGION` with a
[Firestore location](https://cloud.google.com/firestore/docs/locations#types)
such as `nam5` or `eur3`.

## Create the Google Chat app

* Go to
[Google Chat API](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat)
and click `Configuration`.
* In **App name**, enter `User Auth App`.
* In **Avatar URL**, enter `https://developers.google.com/chat/images/quickstart-app-avatar.png`.
* In **Description**, enter `Quickstart app`.
* Under Functionality, select **Receive 1:1 messages** and
**Join spaces and group conversations**.
* Under **Connection settings**, select **HTTP endpoint URL** and enter your App
Engine app's URL (obtained in the previous deployment steps).
* In **Authentication Audience**, select **HTTP endpoint URL**.
* Under **Visibility**, select **Make this Google Chat app available to specific
people and groups in your domain** and enter your email address.
* Click **Save**.

The Chat app is ready to receive and respond to messages on Chat.

## Interact with the App

* Add the app to a Google Chat space.
* @mention the app.
* Follow the authorization link to grant the app access to your account.
* Once authorization is complete, the app will post a message to the space using
your credentials.
* If you @mention the app again, it will post a new message to the space with
your credentials using the saved tokens, without asking for authorization again.

## Related Topics

* [Authenticate and authorize as a Google Chat user](https://developers.google.com/workspace/chat/authenticate-authorize-chat-user)
* [Receive and respond to user interactions](https://developers.google.com/workspace/chat/receive-respond-interactions)
15 changes: 15 additions & 0 deletions python/user-auth-app/app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

runtime: python312
38 changes: 38 additions & 0 deletions python/user-auth-app/firestore_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Copyright 2025 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Functions to handle database operations."""

from google.cloud import firestore

# The prefix used by the Google Chat API in the User resource name.
USERS_PREFIX = "users/"

# The name of the users collection in the database.
USERS_COLLECTION = "users"

# Initialize the Firestore database using Application Default Credentials.
db = firestore.Client(database="auth-data")

def store_token(user_name: str, access_token: str, refresh_token: str):
"""Saves the user's OAuth2 tokens to storage."""
doc_ref = db.collection(USERS_COLLECTION).document(user_name.replace(USERS_PREFIX, ""))
doc_ref.set({ "accessToken": access_token, "refreshToken": refresh_token })

def get_token(user_name: str) -> dict | None:
"""Fetches the user's OAuth2 tokens from storage."""
doc = db.collection(USERS_COLLECTION).document(user_name.replace(USERS_PREFIX, "")).get()
if doc.exists:
return doc.to_dict()
return None
64 changes: 64 additions & 0 deletions python/user-auth-app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2025 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""The main script for the project, which starts a Flask app
to listen to HTTP requests from Chat events and the OAuth flow callback."""

import logging
import os
import flask
from werkzeug.middleware.proxy_fix import ProxyFix
from request_verifier import verify_google_chat_request
from oauth_flow import oauth2callback
from user_auth_post import post_with_user_credentials

logging.basicConfig(
level=logging.INFO,
style="{",
format="[{levelname:.1}{asctime} {filename}:{lineno}] {message}"
)

app = flask.Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app)

@app.route("/", methods=["GET"])
def on_get() -> dict:
"""App route that handles unsupported GET requests."""
return "Hello! This endpoint is meant to be called from Google Chat."

@app.route("/", methods=["POST"])
def on_event() -> dict:
"""App route that responds to interaction events from Google Chat."""
if not verify_google_chat_request(flask.request):
return "Hello! This endpoint is meant to be called from Google Chat."
if event := flask.request.get_json(silent=True):
if event["message"]:
# Post a message back to the same Chat space using user credentials.
return flask.jsonify(post_with_user_credentials(event))
# Ignore events that don't contain a message.
return flask.jsonify({})
return "Error: Unknown action"

@app.route("/oauth2", methods=["GET"])
def on_oauth2():
"""App route that handles callback requests from the OAuth2 authorization flow.
The handler exhanges the code received from the OAuth2 server with a set of
credentials, stores the authentication and refresh tokens in the database,
and redirects the request to the config complete URL provided in the request.
"""
return oauth2callback(flask.request.url)

if __name__ == "__main__":
PORT=os.getenv("PORT", "8080")
app.run(port=PORT)
118 changes: 118 additions & 0 deletions python/user-auth-app/oauth_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Copyright 2025 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Functions to handle the OAuth authentication flow."""

import json
import logging
from urllib.parse import parse_qs, urlparse

import flask
import google_auth_oauthlib.flow
from google.auth.transport import requests
from google.oauth2 import id_token
from google.oauth2.credentials import Credentials
from firestore_service import store_token

# This variable specifies the name of a file that contains the OAuth 2.0
# information for this application, including its client_id and client_secret.
CLIENT_SECRETS_FILE = "client_secrets.json"

# Application OAuth credentials.
KEYS = json.load(open(CLIENT_SECRETS_FILE, encoding="UTF-8"))["web"]

# Define the app's authorization scopes.
# Note: 'openid' is required to that Google Auth will return a JWT with the
# user id, which we can use to validate that the user who granted consent is
# the same who requested it (to avoid identity theft).
SCOPES = ["openid", "https://www.googleapis.com/auth/chat.messages.create"]

def generate_auth_url(user_name: str, config_complete_redirect_url: str) -> str:
"""Generates the URL to start the OAuth2 authorization flow."""
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES)
flow.redirect_uri = KEYS["redirect_uris"][0]
# Generate URL for request to Google's OAuth 2.0 server.
auth_url, _ = flow.authorization_url(
# Enable offline access so that you can refresh an access token without
# re-prompting the user for permission.
access_type="offline",
# Optional, enable incremental authorization. Recommended as a best practice.
include_granted_scopes="true",
state=json.dumps({
"userName": user_name,
"configCompleteRedirectUrl": config_complete_redirect_url
})
)
return auth_url

def create_credentials(access_token: str, refresh_token: str) -> Credentials:
"""Returns the Credentials to authenticate using the user tokens."""
return Credentials(
token = access_token,
refresh_token = refresh_token,
token_uri = KEYS["token_uri"],
client_id = KEYS["client_id"],
client_secret = KEYS["client_secret"],
scopes = SCOPES
)

def oauth2callback(url: str):
"""Handles an OAuth2 callback request.
If the authorization was succesful, it exchanges the received code with the
access and refresh tokens and saves them into Firestore to be used when
calling the Chat API. Then, it redirects the response to the
configCompleteRedirectUrl specified in the authorization URL.
If the authorization fails, it just prints an error message to the response.
"""
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES)
flow.redirect_uri = KEYS["redirect_uris"][0]

# Fetch state from url
parsed = urlparse(url)
qs = parse_qs(parsed.query)
if "error" in qs:
# An error response e.g. error=access_denied.
logging.warning("Error: %s", qs["error"][0])
return "Error: " + qs["error"][0]

# Use the authorization server's response to fetch the OAuth 2.0 tokens.
if "code" not in qs:
logging.warning("Error: invalid query code.")
return "Error: invalid query code."
code = qs["code"][0]
flow.fetch_token(code=code)
credentials = flow.credentials
token = id_token.verify_oauth2_token(
credentials.id_token, requests.Request(), KEYS["client_id"])
user_name = "users/" + token["sub"]

# Save tokens to the database so the app can use them to make API calls.
store_token(user_name, credentials.token, credentials.refresh_token)

# Validate that the user who granted consent is the same who requested it.
if "state" not in qs:
logging.warning("Error: invalid query state.")
return "Error: invalid query state."
state = json.loads(qs["state"][0])
if user_name != state["userName"]:
logging.warning("Error: token user does not correspond to request user.")
return """Error: the user who granted consent does not correspond to
the user who initiated the request. Please start the configuration
again and use the same account you're using in Google Chat."""

# Redirect to the URL that tells Google Chat that the configuration is
# completed.
return flask.redirect(state["configCompleteRedirectUrl"])
Loading
Loading