diff --git a/python/user-auth-app/.gcloudignore b/python/user-auth-app/.gcloudignore new file mode 100644 index 00000000..c2bb40e9 --- /dev/null +++ b/python/user-auth-app/.gcloudignore @@ -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 diff --git a/python/user-auth-app/.gitignore b/python/user-auth-app/.gitignore new file mode 100644 index 00000000..0a80c172 --- /dev/null +++ b/python/user-auth-app/.gitignore @@ -0,0 +1 @@ +client_secrets.json diff --git a/python/user-auth-app/README.md b/python/user-auth-app/README.md new file mode 100644 index 00000000..fa0be453 --- /dev/null +++ b/python/user-auth-app/README.md @@ -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 `/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) diff --git a/python/user-auth-app/app.yaml b/python/user-auth-app/app.yaml new file mode 100644 index 00000000..1f3798a4 --- /dev/null +++ b/python/user-auth-app/app.yaml @@ -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 diff --git a/python/user-auth-app/firestore_service.py b/python/user-auth-app/firestore_service.py new file mode 100644 index 00000000..add5dd0c --- /dev/null +++ b/python/user-auth-app/firestore_service.py @@ -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 diff --git a/python/user-auth-app/main.py b/python/user-auth-app/main.py new file mode 100644 index 00000000..3b0c4c7e --- /dev/null +++ b/python/user-auth-app/main.py @@ -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 an Express 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) diff --git a/python/user-auth-app/oauth_flow.py b/python/user-auth-app/oauth_flow.py new file mode 100644 index 00000000..8aee1069 --- /dev/null +++ b/python/user-auth-app/oauth_flow.py @@ -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"]) diff --git a/python/user-auth-app/request_verifier.py b/python/user-auth-app/request_verifier.py new file mode 100644 index 00000000..0bdf88d4 --- /dev/null +++ b/python/user-auth-app/request_verifier.py @@ -0,0 +1,37 @@ +# 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. + +"""Utility to verify that an HTTP request was sent by Google Chat.""" + +import flask +from google.auth.transport import requests +from google.oauth2 import id_token + +# Bearer Tokens received by apps will always specify this issuer. +CHAT_ISSUER = 'chat@system.gserviceaccount.com' + +def verify_google_chat_request(request: flask.Request) -> bool: + """Verifies that an HTTP request was sent by Google Chat.""" + try: + # Extract the signed token sent by Google Chat from the request. + authorization = request.headers.get('Authorization') + bearer_token = authorization[len("Bearer "):] + # The ID token audience should correspond to the server URl. + audience = request.base_url + # Verify valid token, signed by CHAT_ISSUER, intended for a third party. + token = id_token.verify_oauth2_token( + bearer_token, requests.Request(), audience) + return token["email"] == CHAT_ISSUER + except Exception: + return False diff --git a/python/user-auth-app/requirements.txt b/python/user-auth-app/requirements.txt new file mode 100644 index 00000000..80240488 --- /dev/null +++ b/python/user-auth-app/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.1.0 +protobuf==5.29.2 +google_auth_oauthlib==1.2.1 +google-apps-chat==0.2.0 +google-cloud-firestore==2.19.0 diff --git a/python/user-auth-app/user_auth_post.py b/python/user-auth-app/user_auth_post.py new file mode 100644 index 00000000..1c2a274c --- /dev/null +++ b/python/user-auth-app/user_auth_post.py @@ -0,0 +1,93 @@ +# 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. + +"""Function to post a message to a Google Chat space using the credentials of +the calling user.""" + +from google.api_core.exceptions import Unauthenticated +from google.apps import chat_v1 as google_chat +from firestore_service import get_token +from oauth_flow import create_credentials, generate_auth_url, SCOPES + +def post_with_user_credentials(event: dict) -> dict: + """Posts a message to a Google Chat space by calling the Chat API with user + credentials. + The message is posted to the same space as the received event. + If the user has not authorized the app to use their credentials yet, instead + of posting the message, this functions returns a configuration request to + start the OAuth authorization flow. + """ + message = event["message"] + space_name = event["space"]["name"] + user_name = event["user"]["name"] + display_name = event["user"]["displayName"] + + # Try to obtain an existing OAuth2 token from storage. + tokens = get_token(user_name) + + if tokens is None: + # App doesn't have tokens for the user yet. + # Request configuration to obtain OAuth2 tokens. + return get_config_request(event) + + # Authenticate with the user's OAuth2 tokens. + credentials = create_credentials( + tokens["accessToken"], tokens["refreshToken"]) + + # Create the Chat API client with user credentials. + chat_client = google_chat.ChatServiceClient( + credentials = credentials, + client_options = { + "scopes" : SCOPES + } + ) + + # Initialize request arguments + request = google_chat.CreateMessageRequest( + # The space to create the message in. + parent = space_name, + # Creates the message as a reply to the thread specified by thread.name. + # If it fails, the message starts a new thread instead. + message_reply_option = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", + # The message to create. + message = { + "text": display_name + " said: " + message["text"], + "thread": { + "name": message["thread"]["name"] + } + } + ) + + try: + # Call Chat API. + chat_client.create_message(request) + except Unauthenticated: + # This error probably happened because the user revoked the authorization. + # So, let's request configuration again. + return get_config_request(event) + + return {} + +def get_config_request(event): + """Returns an action response that tells Chat to request configuration for + the app. The configuration will be tied to the user who sent the event.""" + auth_url = generate_auth_url( + event["user"]["name"], + event["configCompleteRedirectUrl"]) + return { + "actionResponse": { + "type": 'REQUEST_CONFIG', + "url": auth_url + } + }