From c8fb5cfb2f09925b3bd37499f8357b09b149d8a1 Mon Sep 17 00:00:00 2001 From: Pierrick Voulet <6769971+PierrickVoulet@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:57:20 -0500 Subject: [PATCH] feat: consolidate auth-app code sample in Python (#341) Co-authored-by: pierrick --- python/auth-app/.gcloudignore | 8 - python/auth-app/README.md | 107 ++++--------- python/auth-app/app.yaml | 10 +- python/auth-app/auth.py | 229 ++++++++++------------------ python/auth-app/install.sh | 5 - python/auth-app/main.py | 151 ++++-------------- python/auth-app/templates/home.html | 13 -- 7 files changed, 151 insertions(+), 372 deletions(-) delete mode 100755 python/auth-app/install.sh delete mode 100644 python/auth-app/templates/home.html diff --git a/python/auth-app/.gcloudignore b/python/auth-app/.gcloudignore index a49a5976..68d91ff7 100644 --- a/python/auth-app/.gcloudignore +++ b/python/auth-app/.gcloudignore @@ -7,7 +7,6 @@ # $ gcloud topic gcloudignore # .gcloudignore - # If you would like to upload your .git directory, .gitignore file or files # from your .gitignore file, remove the corresponding line # below: @@ -16,12 +15,5 @@ # Python pycache: __pycache__/ - # Ignored by the build system /setup.cfg - -# VSCode temporary files -.history/ - -# Python virtual envs -python3.10/ diff --git a/python/auth-app/README.md b/python/auth-app/README.md index f5aea784..0a943080 100644 --- a/python/auth-app/README.md +++ b/python/auth-app/README.md @@ -14,44 +14,54 @@ This sample demonstrates how to create a Google Chat app that requests authoriza * **Python 3.7 or higher:** [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) -* **Basic familiarity with Google Cloud Console and command line** ## Deployment Steps 1. **Enable APIs:** - * Enable the Cloud Datastore API: [Enable Datastore API](https://console.cloud.google.com/flows/enableapi?apiid=datastore.googleapis.com) - * Enable the People API: [Enable People API](https://console.cloud.google.com/flows/enableapi?apiid=people.googleapis.com) - * Enable the Google Chat API: [Enable Chat API](https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com) - ```bash - gcloud services enable datastore.googleapis.com people.googleapis.com chat.googleapis.com - ``` + * Enable the Cloud Datastore, People, and Google Chat APIs using the + [console](https://console.cloud.google.com/apis/enableflow?apiid=datastore.googleapis.com,people.googleapis.com,chat.googleapis.com) + or gcloud: -2. **Create OAuth Client ID:** - * 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 `http://localhost:8080/auth/callback` to `Authorized redirect URIs` for local testing. - * Download the JSON file and rename it to `client_secrets.json` in your project directory. + ```bash + gcloud services enable datastore.googleapis.com people.googleapis.com chat.googleapis.com + ``` + +1. **Initiate Deployment to App Engine:** + + * Open `app.yaml` and replace `` with the email address of your App Engine + default service account (you can find this in the + [App Engine settings](https://console.cloud.google.com/appengine/settings) in Cloud Console). -3. **Deploy to App Engine:** - * Open `app.yaml` and replace `` with the email address of your App Engine default service account (you can find this in the [App Engine settings](https://console.cloud.google.com/appengine/settings) in Cloud Console). * Deploy the app: + ```bash gcloud app deploy ``` + +1. **Create and Use OAuth Client ID:** + * Get the app hostname: + ```bash gcloud app describe | grep defaultHostname ``` - * Update `client_secrets.json`: Replace `http://localhost:8080/auth/callback` in `Authorized redirect URIs` with `/auth/callback`. - * Redeploy the app: + + * 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 `/auth/callback` 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 ``` -4. **Grant Datastore Permissions:** +1. **Grant Datastore Permissions:** + * Grant the App Engine default service account permissions to access Datastore: + ```bash PROJECT_ID=$(gcloud config list --format='value(core.project)') SERVICE_ACCOUNT_EMAIL=$(gcloud app describe | grep serviceAccount | cut -d ':' -f 2) @@ -62,8 +72,11 @@ This sample demonstrates how to create a Google Chat app that requests authoriza ## Create the Google Chat app -* Go to [Google Chat API](https://developers.google.com/chat/api/guides/quickstart/apps-script) and click `Configuration`. -* Enter your App Engine app's URL (obtained in the previous deployment steps) as the **HTTP endpoint URL**. +* Go to + [Google Chat API](https://developers.google.com/chat/api/guides/quickstart/apps-script) + and click `Configuration`. +* Enter your App Engine app's URL (obtained in the previous deployment steps) + as the **HTTP endpoint URL**. * Complete the rest of the configuration as needed. ## Interact with the App @@ -73,57 +86,3 @@ This sample demonstrates how to create a Google Chat app that requests authoriza * Follow the authorization link to grant the app access to your profile. * Send messages to the app to see your profile information. * Type `logout` to deauthorize the app. - -## Run Locally - -1. **Set up Service Account:** - * Create a service account with the `Project > Editor` role. - * Download the service account key as a JSON file (`service-account.json`). - -2. **Set Environment Variable:** - ```bash - export GOOGLE_APPLICATION_CREDENTIALS=./service-account.json - ``` - -3. **Create Virtual Environment (Recommended):** - - ```bash - python3 -m venv env - source env/bin/activate - ``` - -4. **Install Dependencies:** - - ```bash - pip install -r requirements.txt - ``` - -5. **Run the App:** - - ```bash - python main.py - ``` - -6. **Test the App:** - -```bash -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/ -``` diff --git a/python/auth-app/app.yaml b/python/auth-app/app.yaml index 6f85dd49..47118645 100644 --- a/python/auth-app/app.yaml +++ b/python/auth-app/app.yaml @@ -4,7 +4,7 @@ # 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 +# 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, @@ -14,12 +14,14 @@ # This file specifies your Python application's runtime configuration. # See https://cloud.google.com/appengine/docs/managed-vms/python/runtime -# -runtime: python310 +runtime: python312 env_variables: - CLIENT_SECRET_PATH: "client_secret.json" + # A JSON formatted file containing the client ID, client secret, and other OAuth 2.0 parameters + CLIENT_SECRETS_PATH: "client_secrets.json" + # Arbitrary secret key used by the Flask app to cryptographically sign session cookies SESSION_SECRET: "notasecret" +# The email address of the App Engine default service account service_account: diff --git a/python/auth-app/auth.py b/python/auth-app/auth.py index 4d9711e9..c63267b4 100644 --- a/python/auth-app/auth.py +++ b/python/auth-app/auth.py @@ -22,108 +22,65 @@ import logging import os import time -from typing import Any, Union +import requests +from typing import Any import flask import jwt -from google.auth.transport import requests +from google.auth.transport import requests as auth_requests from google.cloud import datastore from google.oauth2 import id_token from google.oauth2.credentials import Credentials from google_auth_oauthlib import flow -CLIENT_SECRET_PATH = os.environ.get("CLIENT_SECRET_PATH", "client_secrets.json") -JWT_SECRET = os.environ.get("SESSION_SECRET", "notasecret") +CLIENT_SECRETS_PATH = os.environ.get("CLIENT_SECRETS_PATH", "client_secrets.json") +SESSION_SECRET = os.environ.get("SESSION_SECRET", "notasecret") mod = flask.Blueprint("auth", __name__) # Scopes required to access the People API. -PEOPLE_API_SCOPES = [ - "openid", - "https://www.googleapis.com/auth/user.emails.read", - "https://www.googleapis.com/auth/user.addresses.read", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/user.phonenumbers.read", -] +PEOPLE_API_SCOPES = ["https://www.googleapis.com/auth/userinfo.profile"] class Store: """Manages storage in Google Cloud Datastore.""" - def __init__(self) -> Store: self.datastore_client = datastore.Client() def get_user_credentials(self, user_name: str) -> Credentials | None: - """Retrieves stored OAuth2 credentials for a user. - - Args: - user_name (str): The identifier for the user. - - Returns: - A Credentials object, or None if the user has not authorized the app. - """ - try: - key = self.datastore_client.key("RefreshToken", user_name) - entity = self.datastore_client.get(key) - if entity is None or "credentials" not in entity: - return None - return Credentials(**entity["credentials"]) - except Exception as e: - logging.exception("Error retrieving credentials: %s", e) + """Retrieves stored OAuth2 credentials for a user.""" + key = self.datastore_client.key("RefreshToken", user_name) + entity = self.datastore_client.get(key) + if entity is None or "credentials" not in entity: return None + return Credentials(**entity["credentials"]) def put_user_credentials(self, user_name: str, creds: Credentials) -> None: - """Stores OAuth2 credentials for a user. - - Args: - user_name (str): The identifier for the associated user. - creds (Credentials): The OAuth2 credentials obtained for the user. - """ - try: - key = self.datastore_client.key("RefreshToken", user_name) - entity = datastore.Entity(key) - entity.update( - { - "credentials": { - "token": creds.token, - "refresh_token": creds.refresh_token, - "token_uri": creds.token_uri, - "client_id": creds.client_id, - "client_secret": creds.client_secret, - "scopes": creds.scopes, - }, - "timestamp": time.time(), - } - ) - self.datastore_client.put(entity) - except Exception as e: - logging.exception("Error storing credentials: %s", e) + """Stores OAuth2 credentials for a user.""" + key = self.datastore_client.key("RefreshToken", user_name) + entity = datastore.Entity(key) + entity.update({ + "credentials": { + "token": creds.token, + "refresh_token": creds.refresh_token, + "token_uri": creds.token_uri, + "client_id": creds.client_id, + "client_secret": creds.client_secret, + "scopes": creds.scopes, + }, + "timestamp": time.time(), + }) + self.datastore_client.put(entity) def delete_user_credentials(self, user_name: str) -> None: - """Deleted stored OAuth2 credentials for a user. - - Args: - user_name (str): The identifier for the associated user. - """ - try: - key = self.datastore_client.key("RefreshToken", user_name) - self.datastore_client.delete(key) - except Exception as e: - logging.exception("Error deleting credentials: %s", e) - + """Deleted stored OAuth2 credentials for a user.""" key = self.datastore_client.key("RefreshToken", user_name) self.datastore_client.delete(key) def get_user_credentials(user_name: str) -> Credentials: """Gets stored crednetials for a user, if it exists.""" - try: - store = Store() - return store.get_user_credentials(user_name) - except Exception as e: - logging.exception("Error getting credentials: %s", e) - return None + return Store().get_user_credentials(user_name) def get_config_url(event) -> Any: @@ -136,94 +93,70 @@ def get_config_url(event) -> Any: Returns: str: The authorization URL to direct the user to. """ - try: - payload = {"completion_url": event["configCompleteRedirectUrl"]} - token = jwt.encode(payload, JWT_SECRET, algorithm="HS256") - return flask.url_for("auth.start_auth", token=token, _external=True) - except Exception as e: - logging.exception("Error getting config URL: %s", e) - return None + payload = { "completion_url": event["configCompleteRedirectUrl"] } + token = jwt.encode(payload, SESSION_SECRET, algorithm="HS256") + return flask.url_for("auth.start_auth", token=token, _external=True) def logout(user_name: str) -> None: - """Logs out the user, removing their stored credentials and revoking the - grant - - Args: - user_name (str): The identifier of the user. - """ - try: - store = Store() - user_credentials = store.get_user_credentials(user_name) - if user_credentials is None: - logging.info("Ignoring logout request for user %s", user_name) - return - logging.info("Logging out user %s", user_name) - store.delete_user_credentials(user_name) - request = requests.Request() - request.post( - "https://accounts.google.com/o/oauth2/revoke", - params={"token": user_credentials.token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as e: - logging.exception("Error logging out user: %s", e) + """Remove stored credentials and revoke grant of user.""" + store = Store() + user_credentials = store.get_user_credentials(user_name) + if user_credentials is None: + logging.info("Ignoring logout request for user %s", user_name) + return + logging.info("Logging out user %s", user_name) + store.delete_user_credentials(user_name) + requests.post( + "https://oauth2.googleapis.com/revoke", + params={ "token": user_credentials.token }, + headers={ "Content-Type": "application/x-www-form-urlencoded" } + ) @mod.route("/start") def start_auth() -> flask.Response: """Begins the oauth flow to authorize access to profile data.""" - try: - token = flask.request.args["token"] - request = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) - - flask.session["completion_url"] = request["completion_url"] - oauth2_flow = flow.Flow.from_client_secrets_file( - CLIENT_SECRET_PATH, - scopes=PEOPLE_API_SCOPES, - redirect_uri=flask.url_for("auth.on_oauth2_callback", _external=True), - ) - oauth2_url, state = oauth2_flow.authorization_url( - access_type="offline", include_granted_scopes="true", prompt="consent" - ) - flask.session["state"] = state - return flask.redirect(oauth2_url) - except Exception as e: - logging.exception("Error starting auth: %s", e) - return flask.abort(403) + token = flask.request.args["token"] + request = jwt.decode(token, SESSION_SECRET, algorithms=["HS256"]) + flask.session["completion_url"] = request["completion_url"] + oauth2_flow = flow.Flow.from_client_secrets_file( + CLIENT_SECRETS_PATH, + scopes=PEOPLE_API_SCOPES, + redirect_uri=flask.url_for("auth.on_oauth2_callback", _external=True) + ) + oauth2_url, state = oauth2_flow.authorization_url( + access_type="offline", include_granted_scopes="false", prompt="consent" + ) + flask.session["state"] = state + return flask.redirect(oauth2_url) @mod.route("/callback") def on_oauth2_callback() -> flask.Response: """Handles the OAuth callback.""" - try: - saved_state = flask.session["state"] - state = flask.request.args["state"] - - if state != saved_state: - logging.warn("Mismatched state in oauth response") - return flask.abort(403) - - redirect_uri = flask.url_for("auth.on_oauth2_callback", _external=True) - oauth2_flow = flow.Flow.from_client_secrets_file( - CLIENT_SECRET_PATH, scopes=PEOPLE_API_SCOPES, redirect_uri=redirect_uri - ) - oauth2_flow.fetch_token(authorization_response=flask.request.url) - creds = oauth2_flow.credentials - - # Use the id_token to identify the chat user. - request = requests.Request() - id_info = id_token.verify_oauth2_token(creds.id_token, request, creds.client_id) - - if id_info["iss"] != "https://accounts.google.com": - flask.abort(403) - - user_id = id_info["sub"] - user_name = "users/{user_id}".format(user_id=user_id) - store = Store() - store.put_user_credentials(user_name, creds) - completion_url = flask.session["completion_url"] - return flask.redirect(completion_url) - except Exception as e: - logging.exception("Error completing auth: %s", e) + saved_state = flask.session["state"] + state = flask.request.args["state"] + if state != saved_state: + logging.warn("Mismatched state in oauth response") return flask.abort(403) + + redirect_uri = flask.url_for("auth.on_oauth2_callback", _external=True) + oauth2_flow = flow.Flow.from_client_secrets_file( + CLIENT_SECRETS_PATH, scopes=PEOPLE_API_SCOPES, redirect_uri=redirect_uri + ) + oauth2_flow.fetch_token(authorization_response=flask.request.url) + creds = oauth2_flow.credentials + + # Use the id_token to identify the chat user. + request = auth_requests.Request() + id_info = id_token.verify_oauth2_token(creds.id_token, request, creds.client_id) + if id_info["iss"] != "https://accounts.google.com": + return flask.abort(403) + + user_id = id_info["sub"] + user_name = "users/{user_id}".format(user_id=user_id) + store = Store() + store.put_user_credentials(user_name, creds) + completion_url = flask.session["completion_url"] + return flask.redirect(completion_url) diff --git a/python/auth-app/install.sh b/python/auth-app/install.sh deleted file mode 100755 index 3ca6b843..00000000 --- a/python/auth-app/install.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -python3 -m venv .venv -source .venv/bin/activate -python3 -m pip install --upgrade -r requirements.txt diff --git a/python/auth-app/main.py b/python/auth-app/main.py index e54b732a..621d0178 100644 --- a/python/auth-app/main.py +++ b/python/auth-app/main.py @@ -12,10 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Simple app that exercises Google OAuth and Google People API. +"""Basic app that relies on Google OAuth and Google People API. -This app runs on the App Engine Flexible Environment, and uses Google Cloud -Datastore for storing user credentials. +It runs in Python App Engine and uses Google Cloud Datastorefor storing user credentials. """ from __future__ import annotations @@ -24,12 +23,11 @@ from typing import Any import flask +import auth from google.oauth2.credentials import Credentials from googleapiclient import discovery from werkzeug.middleware.proxy_fix import ProxyFix -import auth - app = flask.Flask(__name__) app.register_blueprint(auth.mod, url_prefix="/auth") app.wsgi_app = ProxyFix(app.wsgi_app) @@ -40,16 +38,10 @@ logging.basicConfig( level=logging.INFO, style="{", - format="{levelname:.1}{asctime} {filename}:{lineno}] {message}", + format="{levelname:.1}{asctime} {filename}:{lineno}] {message}" ) -@app.route("/", methods=["GET"]) -def home(): - """Default home page""" - return flask.render_template("home.html") - - @app.route("/", methods=["POST"]) def on_event() -> Any | dict: """Handler for events from Google Chat.""" @@ -60,16 +52,11 @@ def on_event() -> Any | dict: else: return on_mention(event) if event["type"] == "ADDED_TO_SPACE": - return flask.jsonify( - { - "text": ( - "Thanks for adding me! " - "Try mentioning me with `@MyProfile` to see your profile." - ) - } - ) + return flask.jsonify({ "text": ( + "Thanks for adding me! " + "Try mentioning me with `@` to see your profile." + )}) return flask.jsonify({}) - return "Error: Unknown action" @@ -79,14 +66,10 @@ def on_mention(event: dict) -> dict: user_credentials = auth.get_user_credentials(user_name) if not user_credentials: logging.info("Requesting credentials for user %s", user_name) - return flask.jsonify( - { - "actionResponse": { - "type": "REQUEST_CONFIG", - "url": auth.get_config_url(event), - }, - } - ) + return flask.jsonify({ "actionResponse": { + "type": "REQUEST_CONFIG", + "url": auth.get_config_url(event) + }}) logging.info("Found existing auth credentials for user %s", user_name) return flask.jsonify(produce_profile_message(user_credentials)) @@ -98,113 +81,41 @@ def on_logout(event) -> dict: auth.logout(user_name) except Exception as e: logging.exception(e) - return flask.jsonify( - { - "text": "Failed to log out user %s: ```%s```" % (user_name, e), - } - ) + return flask.jsonify({ + "text": "Failed to log out user %s: ```%s```" % (user_name, e) + }) else: - return flask.jsonify( - { - "text": "Logged out.", - } - ) + return flask.jsonify({ "text": "Logged out." }) def produce_profile_message(creds: Credentials) -> dict: """Generate a message containing the users profile inforamtion.""" people_api = discovery.build("people", "v1", credentials=creds) try: - person = ( - people_api.people() - .get( - resourceName="people/me", - personFields=",".join( - [ - "names", - "addresses", - "emailAddresses", - "phoneNumbers", - "photos", - ] - ), - ) - .execute() - ) + person = (people_api.people().get( + resourceName="people/me", + personFields=",".join(["names", "photos"]) + ).execute()) except Exception as e: logging.exception(e) - return { - "text": "Failed to fetch profile info: ```%s```" % e, - } - card = {} + return { "text": "Failed to fetch profile info: ```%s```" % e } if person.get("names") and person.get("photos"): - card.update( - { - "header": { - "title": person["names"][0]["displayName"], - "imageUrl": person["photos"][0]["url"], - "imageStyle": "AVATAR", - }, - } - ) - widgets = [] - for email_address in person.get("emailAddresses", []): - widgets.append( - { - "keyValue": { - "icon": "EMAIL", - "content": email_address["value"], - } - } - ) - for phone_number in person.get("phoneNumbers", []): - widgets.append( - { - "keyValue": { - "icon": "PHONE", - "content": phone_number["value"], - } - } - ) - for address in person.get("addresses", []): - if "formattedValue" in address: - widgets.append( - { - "keyValue": { - "icon": "MAP_PIN", - "content": address["formattedValue"], - } - } - ) - if widgets: - card.update( - { - "sections": [ - { - "widgets": widgets, - } - ] - } - ) - if card: - return {"cards": [card]} - return { - "text": "Hmm, no profile information found", - } + return { "cards": [{ "header": { + "title": person["names"][0]["displayName"], + "imageUrl": person["photos"][0]["url"], + "imageStyle": "AVATAR" + }}]} + return { "text": "Hmm, no profile information found"} if __name__ == "__main__": - os.environ.update( - { - # Disable HTTPS check in oauthlib when testing locally. - "OAUTHLIB_INSECURE_TRANSPORT": "1", - } - ) - + os.environ.update({ + # Disable HTTPS check in oauthlib when testing locally. + "OAUTHLIB_INSECURE_TRANSPORT": "1", + }) if not os.environ["GOOGLE_APPLICATION_CREDENTIALS"]: raise Exception( "Set the environment variable GOOGLE_APPLICATION_CREDENTIALS with " "the path to your service account JSON file." ) - app.run(port=8080, debug=True) diff --git a/python/auth-app/templates/home.html b/python/auth-app/templates/home.html deleted file mode 100644 index 26a0a9f1..00000000 --- a/python/auth-app/templates/home.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - Home Page - - - -

Welcome to My Home Page

-

This is a simple home page.

- - -