From 909a8c2ad4d06ad485173f40f278c503cd66a063 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 23 Apr 2026 23:14:34 -0700 Subject: [PATCH] feat: Add sample agent demonstrating 2LO, 3LO, and API Key auth via GcpAuthProvider PiperOrigin-RevId: 904823303 --- contributing/samples/gcp_auth/README.md | 113 ++++ contributing/samples/gcp_auth/agent.py | 166 +++++ .../samples/gcp_auth/client/index.html | 566 ++++++++++++++++++ contributing/samples/gcp_auth/client/main.py | 122 ++++ .../samples/gcp_auth/client/requirements.txt | 4 + 5 files changed, 971 insertions(+) create mode 100644 contributing/samples/gcp_auth/README.md create mode 100644 contributing/samples/gcp_auth/agent.py create mode 100644 contributing/samples/gcp_auth/client/index.html create mode 100644 contributing/samples/gcp_auth/client/main.py create mode 100644 contributing/samples/gcp_auth/client/requirements.txt diff --git a/contributing/samples/gcp_auth/README.md b/contributing/samples/gcp_auth/README.md new file mode 100644 index 00000000..0bdcea16 --- /dev/null +++ b/contributing/samples/gcp_auth/README.md @@ -0,0 +1,113 @@ +# GCP Auth Sample + +Demonstrates the use of Agent Identity auth manager with an agent that queries +Spotify and Google Maps using auth providers. + +Use `adk web` to run API key and 2-legged oauth flows, while use the included +custom agent web client to run 3-legged oauth flows. + +## Setup + +### 1. Activate environment + +```bash +cd adk-python +python3 -m venv .venv +source .venv/bin/activate +``` + +### 2. Install dependencies + +```bash +pip install "google-adk[agent-identity]" +``` + +### 3. Authenticate your environment + +```bash +gcloud auth application-default login +export GOOGLE_CLOUD_PROJECT="YOUR_GOOGLE_CLOUD_PROJECT" +gcloud auth application-default set-quota-project $GOOGLE_CLOUD_PROJECT +``` + +### 4. Create auth providers + +Refer to the [public documentation](https://cloud.google.com/iam/docs/manage-auth-providers) to create the following Agent Identity auth providers. + +> **Note:** +> The identity running the agent (via Application Default Credentials) must have +> the necessary [permissions](https://docs.cloud.google.com/iam/docs/roles-permissions/iamconnectors#iamconnectors.user) +> to retrieve credentials from these connectors. Ensure your account has the +> necessary role to access these resources. + +```bash +export GOOGLE_CLOUD_LOCATION="YOUR_GOOGLE_CLOUD_LOCATION" +export MAPS_API_AUTH_PROVIDER_ID="YOUR_MAPS_API_AUTH_PROVIDER_ID" +export SPOTIFY_2LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_2LO_AUTH_PROVIDER_ID" +export SPOTIFY_3LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_3LO_AUTH_PROVIDER_ID" + +gcloud alpha agent-identity connectors create $MAPS_API_AUTH_PROVIDER_ID \ + --project=$GOOGLE_CLOUD_PROJECT \ + --location=$GOOGLE_CLOUD_LOCATION \ + --api-key=YOUR_API_KEY + +gcloud alpha agent-identity connectors create $SPOTIFY_2LO_AUTH_PROVIDER_ID \ + --project=$GOOGLE_CLOUD_PROJECT \ + --location=$GOOGLE_CLOUD_LOCATION \ + --two-legged-oauth-client-id=OAUTH_CLIENT_ID \ + --two-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \ + --two-legged-oauth-token-endpoint=OAUTH_TOKEN_ENDPOINT + +gcloud alpha agent-identity connectors create $SPOTIFY_3LO_AUTH_PROVIDER_ID \ + --project=$GOOGLE_CLOUD_PROJECT \ + --location=$GOOGLE_CLOUD_LOCATION \ + --three-legged-oauth-client-id=OAUTH_CLIENT_ID \ + --three-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \ + --three-legged-oauth-authorization-url=AUTHORIZATION_URL \ + --three-legged-oauth-token-url=TOKEN_URL \ + --allowed-scopes=ALLOWED_SCOPES +``` + +### 5. Test API key and 2LO auth provider using ADK web client + +```bash +adk web contributing/samples +``` + +- On the ADK web UI, select the agent named `gcp_auth` from the dropdown. +- Sample queries to try: + - API key (Google Maps tool): "What is the current weather in New York?" + - 2LO key (Spotify tool): "Tell me about the song: Waving Flag" + +### 6. Test 3LO auth provider using custom web client + +> **Note:** If the agent backend is running on a different port or host other +> than `localhost:8000`, please set the `AGENT_BACKEND_URL` environment variable +> before starting the client (e.g., +> `export AGENT_BACKEND_URL="http://localhost:9000"`). + +- In a separate shell, activate environment + +```bash +cd adk-python +python3 -m venv .venv +source .venv/bin/activate +``` + +- Navigate to the client directory and install dependencies + +```bash +cd contributing/samples/gcp_auth/client +pip install -r requirements.txt +``` + +- Start the client application + +```bash +uvicorn main:app --port 8080 --reload +``` + +- Open `http://localhost:8080`. (**Note:** You must use `localhost` and not `127.0.0.1`, as the OAuth redirect URL specifically requires it.) +- On the login screen, enter an arbitrary user ID (e.g. test_user123). +- Sample queries to try: + - 3LO key (Spotify tool): "What are my private Spotify playlists?" diff --git a/contributing/samples/gcp_auth/agent.py b/contributing/samples/gcp_auth/agent.py new file mode 100644 index 00000000..9d346870 --- /dev/null +++ b/contributing/samples/gcp_auth/agent.py @@ -0,0 +1,166 @@ +# Copyright 2026 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. + +from __future__ import annotations + +import os + +from google.adk.agents import Agent +from google.adk.apps import App +from google.adk.auth.auth_credential import AuthCredential +from google.adk.auth.auth_tool import AuthConfig +from google.adk.auth.credential_manager import CredentialManager +from google.adk.integrations.agent_identity import GcpAuthProvider +from google.adk.integrations.agent_identity import GcpAuthProviderScheme +from google.adk.tools.authenticated_function_tool import AuthenticatedFunctionTool +from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams +from google.adk.tools.mcp_tool.mcp_toolset import McpToolset +import httpx + +PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT") +LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION") +MAPS_API_AUTH_PROVIDER_ID = os.environ.get("MAPS_API_AUTH_PROVIDER_ID") +SPOTIFY_2LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_2LO_AUTH_PROVIDER_ID") +SPOTIFY_3LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_3LO_AUTH_PROVIDER_ID") + +MAPS_API_AUTH_PROVIDER = f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/{MAPS_API_AUTH_PROVIDER_ID}" +SPOTIFY_2LO_AUTH_PROVIDER = f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/{SPOTIFY_2LO_AUTH_PROVIDER_ID}" +SPOTIFY_3LO_AUTH_PROVIDER = f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/{SPOTIFY_3LO_AUTH_PROVIDER_ID}" + +MAPS_MCP_ENDPOINT = "https://mapstools.googleapis.com/mcp" +CONTINUE_URI = "http://localhost:8080/commit" +MODEL = "gemini-2.5-flash" + + +async def spotify_search_track( + credential: AuthCredential, query: str +) -> str | list: + """Searches for a track on Spotify and returns its details.""" + headers = {} + if http := credential.http: + if http.scheme and http.credentials and (token := http.credentials.token): + headers["Authorization"] = f"{http.scheme.title()} {token}" + if http.additional_headers: + headers.update(http.additional_headers) + + if not headers: + return "Error: No authentication token available." + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.spotify.com/v1/search", + headers=headers, + params={"q": query, "type": "track", "limit": 1}, + ) + + if response.status_code != 200: + return f"Error from Spotify API: {response.status_code} - {response.text}" + + data = response.json() + items = data.get("tracks", {}).get("items", []) + + if not items: + return f"No track found for query '{query}'." + + return items + + +async def spotify_get_playlists(credential: AuthCredential) -> str | list: + """Fetches the current user's private playlists on Spotify.""" + headers = {} + if http := credential.http: + if http.scheme and http.credentials and (token := http.credentials.token): + headers["Authorization"] = f"{http.scheme.title()} {token}" + if http.additional_headers: + headers.update(http.additional_headers) + + if not headers: + return "Error: No authentication token available." + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.spotify.com/v1/me/playlists", + headers=headers, + params={"limit": 10}, + ) + + if response.status_code != 200: + return f"Error from Spotify API: {response.status_code} - {response.text}" + + data = response.json() + items = data.get("items", []) + + if not items: + return "No playlists found for the current user." + + # Extract useful information + return [ + { + "name": item.get("name"), + "public": item.get("public"), + "total_tracks": item.get("tracks", {}).get("total"), + } + for item in items + if item + ] + + +spotify_auth_config_2lo = AuthConfig( + auth_scheme=GcpAuthProviderScheme(name=SPOTIFY_2LO_AUTH_PROVIDER) +) +spotify_search_track_tool = AuthenticatedFunctionTool( + func=spotify_search_track, + auth_config=spotify_auth_config_2lo, +) + +spotify_auth_config_3lo = AuthConfig( + auth_scheme=GcpAuthProviderScheme( + name=SPOTIFY_3LO_AUTH_PROVIDER, + scopes=["playlist-read-private"], + continue_uri=CONTINUE_URI, + ) +) +spotify_get_playlist_tool = AuthenticatedFunctionTool( + func=spotify_get_playlists, + auth_config=spotify_auth_config_3lo, +) + +maps_tools = McpToolset( + connection_params=StreamableHTTPConnectionParams(url=MAPS_MCP_ENDPOINT), + auth_scheme=GcpAuthProviderScheme(name=MAPS_API_AUTH_PROVIDER), + errlog=None, # Required for agent freezing (pickling) +) + +CredentialManager.register_auth_provider(GcpAuthProvider()) + +root_agent = Agent( + name="gcp_auth_agent", + model=MODEL, + instruction=( + "You are a Spotify and Google Maps assistant. Use your tools to " + "search for track details, fetch the user's private playlists, " + "and look up locations. Keep responses concise, friendly, and " + "emoji-filled!" + ), + tools=[ + spotify_search_track_tool, + spotify_get_playlist_tool, + maps_tools, + ], +) + +app = App( + name="gcp_auth", + root_agent=root_agent, +) diff --git a/contributing/samples/gcp_auth/client/index.html b/contributing/samples/gcp_auth/client/index.html new file mode 100644 index 00000000..5e3c5b2b --- /dev/null +++ b/contributing/samples/gcp_auth/client/index.html @@ -0,0 +1,566 @@ + + + + Gcp Auth demo + + + + + + + + + + + + +
+
+

Welcome

+

+ Please enter your User ID to continue. +

+ + +
+
+ + + + + + + + + diff --git a/contributing/samples/gcp_auth/client/main.py b/contributing/samples/gcp_auth/client/main.py new file mode 100644 index 00000000..9d2deabd --- /dev/null +++ b/contributing/samples/gcp_auth/client/main.py @@ -0,0 +1,122 @@ +# Copyright 2026 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. + +from __future__ import annotations + +import json +import logging +import os +import sys + +from fastapi import FastAPI +from fastapi import Request +from fastapi.responses import HTMLResponse +from fastapi.responses import StreamingResponse +import httpx + +logging.basicConfig( + level=logging.INFO, stream=sys.stdout, format="%(levelname)s: %(message)s" +) +logger = logging.getLogger("google_adk." + __name__) + +app = FastAPI() + +AGENT_URL = os.environ.get("AGENT_BACKEND_URL", "http://localhost:8000") + + +@app.get("/") +def ui(): + with open("index.html", "r") as f: + return HTMLResponse(content=f.read()) + + +@app.post("/chat") +async def chat(request: Request): + data = await request.json() + message = data.get("message") + function_response = data.get("function_response") + + app_name = "gcp_auth" + user_id = data.get("user_id", "test_user") + session_id = data.get("session_id", "default_session_id") + + payload = { + "appName": app_name, + "userId": user_id, + "sessionId": session_id, + "streaming": True, + } + + if message: + payload["newMessage"] = { + "role": "user", + "parts": [{"text": message}], + } + elif function_response: + payload["newMessage"] = { + "role": "user", + "parts": [{"functionResponse": function_response}], + } + + # Ensure the session exists before we try to continue it via /run_sse + async def proxy_stream(): + async with httpx.AsyncClient(timeout=120.0) as client: + # Attempt to create the session (ignoring if it already exists or fails quietly) + await client.post( + f"{AGENT_URL}/apps/{app_name}/users/{user_id}/sessions/{session_id}" + ) + + async with client.stream( + "POST", f"{AGENT_URL}/run_sse", json=payload + ) as r: + if r.status_code != 200: + err = await r.aread() + yield f"data: {json.dumps({'error': err.decode()})}\n\n" + return + + async for line in r.aiter_lines(): + if line: + yield f"data: {line}\n\n" if line.startswith("{") else f"{line}\n\n" + + return StreamingResponse(proxy_stream(), media_type="text/event-stream") + + +@app.api_route("/commit", methods=["GET"]) +async def commit(request: Request): + connector = request.query_params.get("connector_name") + payload = { + "userId": request.cookies.get("user_id"), + "userIdValidationState": request.query_params.get( + "user_id_validation_state" + ), + "consentNonce": request.cookies.get("consent_nonce"), + } + + url = f"https://iamconnectorcredentials.googleapis.com/v1alpha/{connector}/credentials:finalize" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + except httpx.HTTPError as e: + err_text = e.response.text if hasattr(e, "response") else str(e) + status = e.response.status_code if hasattr(e, "response") else 500 + logger.error(f"Commit failed: {err_text}") + return HTMLResponse(err_text, status_code=status) + + return HTMLResponse(""" + +

Success. You can close this window.

+ """) diff --git a/contributing/samples/gcp_auth/client/requirements.txt b/contributing/samples/gcp_auth/client/requirements.txt new file mode 100644 index 00000000..5339a4b1 --- /dev/null +++ b/contributing/samples/gcp_auth/client/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +httpx +google-auth