diff --git a/contributing/samples/authn-adk-all-in-one/README.md b/contributing/samples/authn-adk-all-in-one/README.md new file mode 100644 index 0000000000..fa5db4e772 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/README.md @@ -0,0 +1,152 @@ +## ADK Authentication Demo (All in one - Agent, IDP and The app) + +This folder contains everything you need to run the ADK's `auth-code` + grant type authentication demo completely locally + +Here's the high level diagram. + +![alt](doc_images/adk-auth-all-in-one.svg) + +### Introduction +More often than not the agents use some kind of system identity + (especially for OpenAPI and MCP tools). + But obviously this is insecure in that multiple end users + are using the same identity with permissions to access ALL users' data on the + backend. + +ADK provides various [authentication mechanisms](https://google.github.io/adk-docs/tools/authentication/) to solve this. + +However to properly test it you need various components. +We provide everything that is needed so that you can test and run + ADK authentication demo locally. + +This folder comes with - + +1. An IDP +2. A hotel booking application backend +3. A hotel assistant ADK agent (accessing the application using OpenAPI Tools) + +### Details + +You can read about the Auth Code grant / flow type in detail [here](https://developer.okta.com/blog/2018/04/10/oauth-authorization-code-grant-type). But for the purpose of this demo, following steps take place + +1. The user asks the agent to find hotels in "New York". +2. Agent realizes (based on LLM response) that it needs to call a tool and that the tool needs authentication. +3. Agent redirects the user to the IDP's login page with callback / redirect URL back to ADK UI. +4. The user enters credentials (`john.doe` and `password123`) and accepts the consent. +5. The IDP sends the auth_code back to the redirect URL (from 3). +6. ADK then exchanges this auth_code for an access token. +7. ADK does the API call to get details on hotels and hands over that response to LLM, LLM formats the response. +8. ADK sends a response back to the User. + +### Setting up and running + +1. Clone this repository +2. Carry out following steps and create and activate the environment +```bash +# Go to the cloned directory +cd adk-python +# Navigate to the all in one authentication sample +cd contributing/samples/authn-adk-all-in-one/ + +python3 -m venv .venv + +. .venv/bin/activate + +pip install -r requirements.txt + +``` +3. Configure and Start the IDP. Our IDP needs a private key to sign the tokens and a JWKS with public key component to verify them. Steps are provided for that (please check the screenshots below) + +🪧 **NOTE:** +It is recommended that you execute the key pair creation and public + key extraction commands (1-3 and 5 below) on Google cloud shell. + +```bash +cd idp + +# Create .env file by copying the existing one. +cp sample.env .env +cp sample.jwks.json jwks.json + + +# Carry out following steps +# 1. Generate a key pair, When asked about passphrase please press enter (empty passphrase) +ssh-keygen -t rsa -b 2048 -m PEM -f private_key.pem + +# 2. Extract the public key +openssl rsa -in private_key.pem -pubout > pubkey.pub + +# 3. Generate the jwks.json content using https://jwkset.com/generate and this public key (choose key algorithm RS256 and Key use Signature) (Please check the screenshot) +# 4. Update the jwks.json with the key jwks key created in 3 (please check the screenshot) +# 5. Update the env file with the private key +cat private_key.pem | tr -d "\n" +# 6. Carefully copy output of the command above into the .env file to update the value of PRIVATE_KEY +# 7. save jwks.json and .env + +# Start the IDP +python app.py +``` +
+ +Screenshots +Generating JWKS - + +![alt](doc_images/jwksgen.png) + +Updated `jwks.json` (notice the key is added in the existing array) + +![alt](doc_images/jwks_updated.png) + +
+ +4. In a separate shell - Start the backend API (Hotel Booking Application) +```bash +# Go to the cloned directory +cd adk-python +# Navigate to the all in one authentication sample +cd contributing/samples/authn-adk-all-in-one/ + +# Activate Env for this shell +. .venv/bin/activate + +cd hotel_booker_app/ + +# Start the hotel booker application +python main.py + +``` + +5. In a separate shell - Start the ADK agent +```bash +# Go to the cloned directory +cd adk-python +# Navigate to the all in one authentication sample +cd contributing/samples/authn-adk-all-in-one/ + +# Activate Env for this shell +. .venv/bin/activate + +cd adk_agents/ + +cp sample.env .env + +# ⚠️ Make sure to update the API KEY (GOOGLE_API_KEY) in .env file + +# Run the agent +adk web + +``` +6. Access the agent on http://localhost:8000 + +🪧 **NOTE:** + +After first time authentication, +it might take some time for the agent to respond, +subsequent responses are significantly faster. + +### Conclusion + +You can exercise the ADK Authentication +without any external components using this demo. + diff --git a/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/__init__.py b/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/__init__.py new file mode 100644 index 0000000000..c48963cdc7 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/__init__.py @@ -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. + +from . import agent diff --git a/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/agent.py b/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/agent.py new file mode 100644 index 0000000000..db956ea454 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/agent.py @@ -0,0 +1,65 @@ +# 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. + +import logging +import os + +from google.adk.tools.openapi_tool.auth.auth_helpers import openid_url_to_scheme_credential +from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset + +credential_dict = { + "client_id": os.environ.get("OAUTH_CLIENT_ID"), + "client_secret": os.environ.get("OAUTH_CLIENT_SECRET"), +} +auth_scheme, auth_credential = openid_url_to_scheme_credential( + openid_url="http://localhost:5000/.well-known/openid-configuration", + credential_dict=credential_dict, + scopes=[], +) + + +# Open API spec +file_path = "./agent_openapi_tools/openapi.yaml" +file_content = None + +try: + with open(file_path, "r") as file: + file_content = file.read() +except FileNotFoundError: + # so that the execution does not continue when the file is not found. + raise FileNotFoundError(f"Error: The API Spec '{file_path}' was not found.") + + +# Example with a JSON string +openapi_spec_yaml = file_content # Your OpenAPI YAML string +openapi_toolset = OpenAPIToolset( + spec_str=openapi_spec_yaml, + spec_str_type="yaml", + auth_scheme=auth_scheme, + auth_credential=auth_credential, +) + +from google.adk.agents import LlmAgent + +root_agent = LlmAgent( + name="hotel_agent", + instruction=( + "Help user find and book hotels, fetch their bookings using the tools" + " provided." + ), + description="Hotel Booking Agent", + model=os.environ.get("GOOGLE_MODEL"), + tools=[openapi_toolset], # Pass the toolset + # ... other agent config ... +) diff --git a/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/openapi.yaml b/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/openapi.yaml new file mode 100644 index 0000000000..8adda49623 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/adk_agents/agent_openapi_tools/openapi.yaml @@ -0,0 +1,229 @@ +openapi: 3.0.0 +info: + title: Hotel Booker API + description: A simple API for managing hotel bookings, with a custom client credentials authentication flow. + version: 1.0.0 +servers: + - url: http://127.0.0.1:8081 +paths: + /hotels: + get: + summary: Get available hotels + description: Retrieves a list of available hotels, optionally filtered by location. + security: + - BearerAuth: [] + parameters: + - in: query + name: location + schema: + type: string + description: The city to filter hotels by (e.g., 'New York'). + responses: + '200': + description: Successfully retrieved hotels. + content: + application/json: + schema: + type: object + properties: + error: + type: boolean + example: false + data: + type: array + items: + $ref: '#/components/schemas/Hotel' + message: + type: string + example: "Successfully retrieved hotels." + '401': + description: Unauthorized. Invalid or expired token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /book: + post: + summary: Book a room + description: Books a room in a specified hotel. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookingRequest' + responses: + '200': + description: Booking successful. + content: + application/json: + schema: + type: object + properties: + error: + type: boolean + example: false + data: + type: object + properties: + booking_id: + type: string + example: "HB-1" + message: + type: string + example: "Booking successful!" + '400': + description: Bad request. Missing information or invalid booking details. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. Invalid or expired token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /booking_details: + get: + summary: Get booking details + description: Retrieves details for a specific booking by ID or guest name. + security: + - BearerAuth: [] + parameters: + - in: query + name: booking_id + schema: + type: string + description: The custom booking ID (e.g., 'HB-1'). + - in: query + name: guest_name + schema: + type: string + description: The name of the guest to search for (partial and case-insensitive). + responses: + '200': + description: Booking details retrieved successfully. + content: + application/json: + schema: + type: object + properties: + error: + type: boolean + example: false + data: + type: object + properties: + custom_booking_id: + type: string + example: "HB-1" + hotel_name: + type: string + example: "Grand Hyatt" + hotel_location: + type: string + example: "New York" + guest_name: + type: string + example: "John Doe" + check_in_date: + type: string + example: "2025-10-01" + check_out_date: + type: string + example: "2025-10-05" + num_rooms: + type: integer + example: 1 + total_price: + type: number + format: float + example: 1000.0 + message: + type: string + example: "Booking details retrieved successfully." + '400': + description: Bad request. Missing parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. Invalid or expired token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Booking not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: CustomAuthToken + schemas: + ErrorResponse: + type: object + properties: + error: + type: boolean + example: true + data: + type: object + nullable: true + message: + type: string + example: "Invalid access token." + Hotel: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: "Grand Hyatt" + location: + type: string + example: "New York" + available_rooms: + type: integer + example: 10 + price_per_night: + type: number + format: float + example: 250.0 + BookingRequest: + type: object + properties: + hotel_id: + type: integer + example: 1 + guest_name: + type: string + example: "John Doe" + check_in_date: + type: string + format: date + example: "2025-10-01" + check_out_date: + type: string + format: date + example: "2025-10-05" + num_rooms: + type: integer + example: 1 + required: + - hotel_id + - guest_name + - check_in_date + - check_out_date + - num_rooms \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/adk_agents/requirements.txt b/contributing/samples/authn-adk-all-in-one/adk_agents/requirements.txt new file mode 100644 index 0000000000..f490d72da0 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/adk_agents/requirements.txt @@ -0,0 +1 @@ +google-adk==1.12 diff --git a/contributing/samples/authn-adk-all-in-one/adk_agents/sample.env b/contributing/samples/authn-adk-all-in-one/adk_agents/sample.env new file mode 100644 index 0000000000..e448864ea1 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/adk_agents/sample.env @@ -0,0 +1,6 @@ +# General Agent Configuration +GOOGLE_GENAI_USE_VERTEXAI=False +GOOGLE_API_KEY=NOT_SET +GOOGLE_MODEL=gemini-2.5-flash +OAUTH_CLIENT_ID=abc123 +OAUTH_CLIENT_SECRET=secret123 \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/doc_images/adk-auth-all-in-one.svg b/contributing/samples/authn-adk-all-in-one/doc_images/adk-auth-all-in-one.svg new file mode 100644 index 0000000000..37bfec651f --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/doc_images/adk-auth-all-in-one.svg @@ -0,0 +1,3 @@ + + +
IDP
IDP
Hotel Agent
Hotel Agent
Hotel Booker APP / API
Hotel Booker APP / A...
User
User
1
1
2
2
3
3
4
4
5
5
Text is not SVG - cannot display
\ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/doc_images/jwks_updated.png b/contributing/samples/authn-adk-all-in-one/doc_images/jwks_updated.png new file mode 100644 index 0000000000..cc376ea19c Binary files /dev/null and b/contributing/samples/authn-adk-all-in-one/doc_images/jwks_updated.png differ diff --git a/contributing/samples/authn-adk-all-in-one/doc_images/jwksgen.png b/contributing/samples/authn-adk-all-in-one/doc_images/jwksgen.png new file mode 100644 index 0000000000..b7f553e240 Binary files /dev/null and b/contributing/samples/authn-adk-all-in-one/doc_images/jwksgen.png differ diff --git a/contributing/samples/authn-adk-all-in-one/hotel_booker_app/hotelbooker_core.py b/contributing/samples/authn-adk-all-in-one/hotel_booker_app/hotelbooker_core.py new file mode 100644 index 0000000000..3f6916034f --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/hotel_booker_app/hotelbooker_core.py @@ -0,0 +1,263 @@ +# 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. + +import datetime +import logging +import sqlite3 + + +class HotelBooker: + """ + Core business logic for hotel booking, independent of any web framework. + """ + + def __init__(self, db_name="data.db"): + self.db_name = db_name + self._initialize_db() + + def _get_db_connection(self): + """Helper to get a new, independent database connection.""" + conn = sqlite3.connect(self.db_name) + conn.row_factory = sqlite3.Row + return conn + + def _initialize_db(self): + """ + Drops, creates, and populates the database tables with sample data. + """ + conn = None + try: + conn = self._get_db_connection() + cursor = conn.cursor() + + cursor.execute("DROP TABLE IF EXISTS bookings") + cursor.execute("DROP TABLE IF EXISTS hotels") + conn.commit() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS hotels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + location TEXT NOT NULL, + total_rooms INTEGER NOT NULL, + available_rooms INTEGER NOT NULL, + price_per_night REAL NOT NULL + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS bookings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + custom_booking_id TEXT UNIQUE, + hotel_id INTEGER NOT NULL, + guest_name TEXT NOT NULL, + check_in_date TEXT NOT NULL, + check_out_date TEXT NOT NULL, + num_rooms INTEGER NOT NULL, + total_price REAL NOT NULL, + FOREIGN KEY (hotel_id) REFERENCES hotels(id) + ) + """) + + conn.commit() + + sample_hotels = [ + ("Grand Hyatt", "New York", 200, 150, 250.00), + ("The Plaza Hotel", "New York", 150, 100, 350.00), + ("Hilton Chicago", "Chicago", 300, 250, 180.00), + ("Marriott Marquis", "San Francisco", 250, 200, 220.00), + ] + cursor.executemany( + """ + INSERT INTO hotels (name, location, total_rooms, available_rooms, price_per_night) + VALUES (?, ?, ?, ?, ?) + """, + sample_hotels, + ) + conn.commit() + + initial_bookings_data = [ + (1, "Alice Smith", "2025-08-10", "2025-08-15", 1, 1250.00), + (3, "Bob Johnson", "2025-09-01", "2025-09-03", 2, 720.00), + ] + for booking_data in initial_bookings_data: + cursor.execute( + """ + INSERT INTO bookings (hotel_id, guest_name, check_in_date, check_out_date, num_rooms, total_price) + VALUES (?, ?, ?, ?, ?, ?) + """, + booking_data, + ) + booking_id_int = cursor.lastrowid + custom_id = f"HB-{booking_id_int}" + cursor.execute( + "UPDATE bookings SET custom_booking_id = ? WHERE id = ?", + (custom_id, booking_id_int), + ) + conn.commit() + except sqlite3.Error as e: + if conn: + conn.rollback() + finally: + if conn: + conn.close() + + def is_token_valid(self, conn, token): + """Checks if a given token is valid and not expired.""" + logging.info("not implemented") + return True + + def get_available_hotels(self, cursor, location=None): + """Retrieves a list of available hotels, optionally filtered by location.""" + query = ( + "SELECT id, name, location, available_rooms, price_per_night FROM" + " hotels WHERE available_rooms > 0" + ) + params = [] + if location: + query += " AND location LIKE ?" + params.append(f"%{location}%") + try: + cursor.execute(query, params) + rows = cursor.fetchall() + return [dict(row) for row in rows], None + except sqlite3.Error as e: + return None, f"Error getting available hotels: {e}" + + def book_a_room( + self, conn, hotel_id, guest_name, check_in_date, check_out_date, num_rooms + ): + """Books a room in a specified hotel.""" + cursor = conn.cursor() + try: + cursor.execute( + "SELECT available_rooms, price_per_night FROM hotels WHERE id = ?", + (hotel_id,), + ) + hotel_info = cursor.fetchone() + + if not hotel_info: + return None, f"Hotel with ID {hotel_id} not found." + + available_rooms, price_per_night = ( + hotel_info["available_rooms"], + hotel_info["price_per_night"], + ) + if available_rooms < num_rooms: + return ( + None, + ( + f"Not enough rooms available at hotel ID {hotel_id}. Available:" + f" {available_rooms}, Requested: {num_rooms}" + ), + ) + + try: + check_in_dt = datetime.datetime.strptime(check_in_date, "%Y-%m-%d") + check_out_dt = datetime.datetime.strptime(check_out_date, "%Y-%m-%d") + except ValueError: + return None, "Invalid date format. Please use YYYY-MM-DD." + + num_nights = (check_out_dt - check_in_dt).days + if num_nights <= 0: + return None, "Check-out date must be after check-in date." + + total_price = num_rooms * price_per_night * num_nights + + cursor.execute( + "UPDATE hotels SET available_rooms = ? WHERE id = ?", + (available_rooms - num_rooms, hotel_id), + ) + + cursor.execute( + """ + INSERT INTO bookings (hotel_id, guest_name, check_in_date, check_out_date, num_rooms, total_price) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + hotel_id, + guest_name, + check_in_date, + check_out_date, + num_rooms, + total_price, + ), + ) + + booking_id_int = cursor.lastrowid + custom_booking_id = f"HB-{booking_id_int}" + + cursor.execute( + "UPDATE bookings SET custom_booking_id = ? WHERE id = ?", + (custom_booking_id, booking_id_int), + ) + + conn.commit() + return custom_booking_id, None + except sqlite3.Error as e: + conn.rollback() + return None, f"Error booking room: {e}" + + def get_booking_details(self, cursor, booking_id=None, guest_name=None): + """Retrieves details for a specific booking.""" + query = """ + SELECT + b.custom_booking_id, + h.name AS hotel_name, + h.location AS hotel_location, + b.guest_name, + b.check_in_date, + b.check_out_date, + b.num_rooms, + b.total_price + FROM + bookings b + JOIN + hotels h ON b.hotel_id = h.id + """ + params = [] + result_type = "single" + + if booking_id: + query += " WHERE b.custom_booking_id = ?" + params.append(booking_id) + elif guest_name: + query += " WHERE LOWER(b.guest_name) LIKE LOWER(?)" + params.append(f"%{guest_name}%") + result_type = "list" + else: + return ( + None, + ( + "Please provide either a booking ID or a guest name to retrieve" + " booking details." + ), + ) + + try: + cursor.execute(query, params) + rows = cursor.fetchall() + + if not rows: + return ( + None, + ( + f"No booking found for the given criteria (ID: {booking_id}," + f" Name: {guest_name})." + ), + ) + + bookings = [dict(row) for row in rows] + return bookings if result_type == "list" else bookings[0], None + except sqlite3.Error as e: + return None, f"Error getting booking details: {e}" diff --git a/contributing/samples/authn-adk-all-in-one/hotel_booker_app/main.py b/contributing/samples/authn-adk-all-in-one/hotel_booker_app/main.py new file mode 100644 index 0000000000..bd4c771140 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/hotel_booker_app/main.py @@ -0,0 +1,266 @@ +# 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. + +from functools import wraps +import os +import sqlite3 + +from dotenv import load_dotenv +from flask import Flask +from flask import g +from flask import jsonify +from flask import request +from hotelbooker_core import HotelBooker +import jwt +import requests + +# Load environment variables from .env file +load_dotenv() + +app = Flask(__name__) +# Instantiate the core logic class +hotel_booker = HotelBooker() +app.config["DATABASE"] = hotel_booker.db_name + +OIDC_CONFIG_URL = os.environ.get( + "OIDC_CONFIG_URL", "http://localhost:5000/.well-known/openid-configuration" +) + +# Cache for OIDC discovery and JWKS +oidc_config = None +jwks = None + + +def get_oidc_config(): + """Fetches and caches the OIDC configuration.""" + global oidc_config + if oidc_config is None: + try: + response = requests.get(OIDC_CONFIG_URL) + response.raise_for_status() + oidc_config = response.json() + except requests.exceptions.RequestException as e: + return None, f"Error fetching OIDC config: {e}" + return oidc_config, None + + +def get_jwks(): + """Fetches and caches the JSON Web Key Set (JWKS).""" + global jwks + if jwks is None: + config, error = get_oidc_config() + if error: + return None, error + jwks_uri = config.get("jwks_uri") + if not jwks_uri: + return None, "jwks_uri not found in OIDC configuration." + try: + response = requests.get(jwks_uri) + response.raise_for_status() + jwks = response.json() + except requests.exceptions.RequestException as e: + return None, f"Error fetching JWKS: {e}" + return jwks, None + + +def get_db(): + """Manages a per-request database connection.""" + if "db" not in g: + g.db = sqlite3.connect(app.config["DATABASE"]) + g.db.row_factory = sqlite3.Row + return g.db + + +@app.teardown_appcontext +def close_db(exception): + db = g.pop("db", None) + if db is not None: + db.close() + + +def is_token_valid(token: str): + """ + Validates a JWT token using the public key from the OIDC jwks_uri. + """ + if not token: + return False, "Token is empty." + + jwks_data, error = get_jwks() + if error: + return False, f"Failed to get JWKS: {error}" + + try: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if not kid: + return False, "Token header missing 'kid'." + + key = next( + (k for k in jwks_data.get("keys", []) if k.get("kid") == kid), None + ) + if not key: + return False, "No matching key found in JWKS." + + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key) + + # The decoding happens just so that we are able to + # check if there were any exception decoding the token + # which indicate it being not valid. + # Also you could have verify_aud and verify_iss as False + # But when they are true issuer and audience are needed in the jwt.decode call + # they are checked against the values from the token + # idealy token validation should also check whether the API being called is part of + # audience so for example localhost:8081/api should cover localhost:8081/api/hotels + # but should not cover localhost:8000/admin + # so this middleware (decorator - is_token_valid, can check the request url and do that check, but we are + # skipping that as the audience will always be localhost:8081) + decoded_token = jwt.decode( + token, + key=public_key, + issuer="http://localhost:5000", + audience="http://localhost:8081", + algorithms=[header["alg"]], + options={"verify_exp": True, "verify_aud": True, "verify_iss": True}, + ) + return True, "Token is valid." + except jwt.ExpiredSignatureError: + return False, "Token has expired." + except jwt.InvalidAudienceError: + return False, "Invalid audience." + except jwt.InvalidIssuerError: + return False, "Invalid issuer." + except jwt.InvalidTokenError as e: + return False, f"Invalid token: {e}" + except Exception as e: + return False, f"An unexpected error occurred during token validation: {e}" + + +# Decorator to check for a valid access token on protected routes +def token_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return { + "error": True, + "data": None, + "message": "Missing or invalid Authorization header.", + }, 401 + + token = auth_header.split(" ")[1] + is_valid, message = is_token_valid(token) + + if not is_valid: + return {"error": True, "data": None, "message": message}, 401 + + return f(*args, **kwargs) + + return decorated_function + + +@app.route("/hotels", methods=["GET"]) +@token_required +def get_hotels(): + location = request.args.get("location") + hotels, error_message = hotel_booker.get_available_hotels( + get_db().cursor(), location + ) + + if hotels is not None: + return ( + jsonify({ + "error": False, + "data": hotels, + "message": "Successfully retrieved hotels.", + }), + 200, + ) + else: + return jsonify({"error": True, "data": None, "message": error_message}), 500 + + +@app.route("/book", methods=["POST"]) +@token_required +def book_room(): + conn = get_db() + data = request.json + hotel_id = data.get("hotel_id") + guest_name = data.get("guest_name") + check_in_date = data.get("check_in_date") + check_out_date = data.get("check_out_date") + num_rooms = data.get("num_rooms") + + if not all([hotel_id, guest_name, check_in_date, check_out_date, num_rooms]): + return ( + jsonify({ + "error": True, + "data": None, + "message": "Missing required booking information.", + }), + 400, + ) + + booking_id, error_message = hotel_booker.book_a_room( + conn, hotel_id, guest_name, check_in_date, check_out_date, num_rooms + ) + + if booking_id: + return ( + jsonify({ + "error": False, + "data": {"booking_id": booking_id}, + "message": "Booking successful!", + }), + 200, + ) + else: + return jsonify({"error": True, "data": None, "message": error_message}), 400 + + +@app.route("/booking_details", methods=["GET"]) +@token_required +def get_details(): + conn = get_db() + booking_id = request.args.get("booking_id") + guest_name = request.args.get("guest_name") + + if not booking_id and not guest_name: + return ( + jsonify({ + "error": True, + "data": None, + "message": "Please provide either a booking ID or a guest name.", + }), + 400, + ) + + details, error_message = hotel_booker.get_booking_details( + get_db().cursor(), booking_id=booking_id, guest_name=guest_name + ) + + if details: + return ( + jsonify({ + "error": False, + "data": details, + "message": "Booking details retrieved successfully.", + }), + 200, + ) + else: + return jsonify({"error": True, "data": None, "message": error_message}), 404 + + +if __name__ == "__main__": + app.run(debug=True, port=8081) diff --git a/contributing/samples/authn-adk-all-in-one/hotel_booker_app/openapi.yaml b/contributing/samples/authn-adk-all-in-one/hotel_booker_app/openapi.yaml new file mode 100644 index 0000000000..8adda49623 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/hotel_booker_app/openapi.yaml @@ -0,0 +1,229 @@ +openapi: 3.0.0 +info: + title: Hotel Booker API + description: A simple API for managing hotel bookings, with a custom client credentials authentication flow. + version: 1.0.0 +servers: + - url: http://127.0.0.1:8081 +paths: + /hotels: + get: + summary: Get available hotels + description: Retrieves a list of available hotels, optionally filtered by location. + security: + - BearerAuth: [] + parameters: + - in: query + name: location + schema: + type: string + description: The city to filter hotels by (e.g., 'New York'). + responses: + '200': + description: Successfully retrieved hotels. + content: + application/json: + schema: + type: object + properties: + error: + type: boolean + example: false + data: + type: array + items: + $ref: '#/components/schemas/Hotel' + message: + type: string + example: "Successfully retrieved hotels." + '401': + description: Unauthorized. Invalid or expired token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /book: + post: + summary: Book a room + description: Books a room in a specified hotel. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookingRequest' + responses: + '200': + description: Booking successful. + content: + application/json: + schema: + type: object + properties: + error: + type: boolean + example: false + data: + type: object + properties: + booking_id: + type: string + example: "HB-1" + message: + type: string + example: "Booking successful!" + '400': + description: Bad request. Missing information or invalid booking details. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. Invalid or expired token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /booking_details: + get: + summary: Get booking details + description: Retrieves details for a specific booking by ID or guest name. + security: + - BearerAuth: [] + parameters: + - in: query + name: booking_id + schema: + type: string + description: The custom booking ID (e.g., 'HB-1'). + - in: query + name: guest_name + schema: + type: string + description: The name of the guest to search for (partial and case-insensitive). + responses: + '200': + description: Booking details retrieved successfully. + content: + application/json: + schema: + type: object + properties: + error: + type: boolean + example: false + data: + type: object + properties: + custom_booking_id: + type: string + example: "HB-1" + hotel_name: + type: string + example: "Grand Hyatt" + hotel_location: + type: string + example: "New York" + guest_name: + type: string + example: "John Doe" + check_in_date: + type: string + example: "2025-10-01" + check_out_date: + type: string + example: "2025-10-05" + num_rooms: + type: integer + example: 1 + total_price: + type: number + format: float + example: 1000.0 + message: + type: string + example: "Booking details retrieved successfully." + '400': + description: Bad request. Missing parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized. Invalid or expired token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Booking not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: CustomAuthToken + schemas: + ErrorResponse: + type: object + properties: + error: + type: boolean + example: true + data: + type: object + nullable: true + message: + type: string + example: "Invalid access token." + Hotel: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: "Grand Hyatt" + location: + type: string + example: "New York" + available_rooms: + type: integer + example: 10 + price_per_night: + type: number + format: float + example: 250.0 + BookingRequest: + type: object + properties: + hotel_id: + type: integer + example: 1 + guest_name: + type: string + example: "John Doe" + check_in_date: + type: string + format: date + example: "2025-10-01" + check_out_date: + type: string + format: date + example: "2025-10-05" + num_rooms: + type: integer + example: 1 + required: + - hotel_id + - guest_name + - check_in_date + - check_out_date + - num_rooms \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/idp/app.py b/contributing/samples/authn-adk-all-in-one/idp/app.py new file mode 100644 index 0000000000..86388efc1e --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/idp/app.py @@ -0,0 +1,569 @@ +# 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. + +import base64 +from datetime import datetime +from datetime import timedelta +from datetime import timezone +import hashlib +import json +import logging +import os +import time +from urllib.parse import urlencode +from urllib.parse import urlparse +from urllib.parse import urlunparse + +from dotenv import load_dotenv +from flask import Flask +from flask import jsonify +from flask import redirect +from flask import render_template +from flask import request +from flask import session +from flask_cors import CORS +import jwt + +logging.basicConfig(level=logging.DEBUG) + + +# Load environment variables from .env file +load_dotenv() + +app = Flask(__name__, template_folder="templates") +CORS(app) +app.secret_key = os.urandom(24) + +# Load JWKS and private key from files and environment variables +try: + with open("jwks.json", "r") as f: + JWKS = json.load(f) +except FileNotFoundError: + JWKS = None + logging.error( + "jwks.json not found. The server will not be able to generate JWTs." + ) + +PRIVATE_KEY = os.getenv("PRIVATE_KEY") +GENERATE_JWT = os.getenv("GENERATE_JWT", "true").lower() == "true" + +if GENERATE_JWT and not PRIVATE_KEY: + raise ValueError( + "PRIVATE_KEY environment variable must be set when GENERATE_JWT is true." + ) + +# A simple user registry for demonstration purposes +USER_REGISTRY = { + "john.doe": { + "password": "password123", + "sub": "john.doe", + "profile": "I am John Doe.", + "email": "john.doe@example.com", + }, + "jane.doe": { + "password": "password123", + "sub": "jane.doe", + "profile": "I am Jane Doe.", + "email": "jane.doe@example.com", + }, +} + +OPENID_CONFIG = { + "issuer": "http://localhost:5000", + "authorization_endpoint": "http://localhost:5000/authorize", + "token_endpoint": "http://localhost:5000/generate-token", + "jwks_uri": "http://localhost:5000/jwks.json", + "response_types_supported": ["code", "token", "id_token", "id_token token"], + "grant_types_supported": [ + "client_credentials", + "implicit", + "authorization_code", + ], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "scopes_supported": ["openid", "profile", "email", "api:read", "api:write"], + "id_token_signing_alg_values_supported": ["RS256"], + "subject_types_supported": ["public"], + "code_challenge_methods_supported": ["S256"], +} + +# A simple client registry +CLIENT_REGISTRY = { + "abc123": { + "client_secret": "secret123", + "allowed_scopes": [ + "api:read", + "api:write", + "openid", + "profile", + "email", + ], + "redirect_uri": [ + "http://localhost:8081/callback_implicit.html", + "http://localhost:8081/callback_authcode.html", + "http://localhost:8081/callback_pkce.html", + "http://localhost:8000/dev-ui/", + ], + "response_types": ["token", "id_token", "code"], + "grant_types": ["client_credentials", "implicit", "authorization_code"], + "client_name": "ADK Agent", + } +} + +# A simple "database" to store temporary authorization codes +AUTHORIZATION_CODES = {} + + +def generate_jwt(payload, key, alg="RS256"): + if not JWKS: + raise ValueError("JWKS not loaded, cannot generate JWT.") + + kid = JWKS["keys"][0]["kid"] + headers = {"kid": kid, "alg": alg} + + return jwt.encode(payload, key, algorithm=alg, headers=headers) + + +def create_access_token(client_id, scopes, user_sub=None): + if GENERATE_JWT: + payload = { + "iss": "http://localhost:5000", # who issued this token? + # aud - What client API is this token for? - please check comment in hotel booker is_token_valid + # ideally the reqeust's resource parameter (part of OAuth spec extension) + # Here is an example of such request inbound to this IDP + # GET http://localhost:5000/authorize? + # response_type=code& + # client_id=client123& + # redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fdev-ui& + # scope=openid%20profile%20api%3Aread& + # state=XYZ789& + # resource=http%3A%2F%2Flocalhost%3A8081%2Fapi + "aud": "http://localhost:8081", + "sub": user_sub if user_sub else client_id, + "exp": ( + datetime.now(timezone.utc).timestamp() + + timedelta(hours=1).total_seconds() + ), + "iat": datetime.now(timezone.utc).timestamp(), + "scope": " ".join(scopes), + } + return generate_jwt(payload, PRIVATE_KEY) + else: + return os.urandom(32).hex() + + +def create_id_token(client_id, user_data, scopes, nonce=None): + if not GENERATE_JWT: + return None + + payload = { + "iss": "http://localhost:5000", + "sub": user_data.get("sub"), + "aud": client_id, + "exp": ( + datetime.now(timezone.utc).timestamp() + + timedelta(hours=1).total_seconds() + ), + "iat": datetime.now(timezone.utc).timestamp(), + "auth_time": datetime.now(timezone.utc).timestamp(), + "email": user_data.get("email"), + "profile": user_data.get("profile"), + "scope": " ".join(scopes), + } + if nonce: + payload["nonce"] = nonce + return generate_jwt(payload, PRIVATE_KEY) + + +@app.route("/.well-known/openid-configuration") +def openid_configuration(): + return jsonify(OPENID_CONFIG) + + +@app.route("/jwks.json") +def jwks_endpoint(): + return jsonify(JWKS) + + +@app.route("/authorize", methods=["GET", "POST"]) +def authorize(): + if request.method == "GET": + client_id = request.args.get("client_id") + redirect_uri = request.args.get("redirect_uri") + client = CLIENT_REGISTRY.get(client_id) + + if not client or redirect_uri not in client.get("redirect_uri", []): + return "Invalid client or redirect URI", 400 + + auth_request = request.args.to_dict() + auth_request["client_name"] = client["client_name"] + session["auth_request"] = auth_request + return render_template("login.html", client_name=client["client_name"]) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + auth_request = session.get("auth_request") + user = USER_REGISTRY.get(username) + + if not user or user["password"] != password: + return render_template( + "login.html", + error="Invalid username or password", + client_name=auth_request["client_name"], + ) + + session["user"] = user + + return render_template("consent.html", auth_request=auth_request) + + +@app.route("/consent", methods=["POST"]) +def consent(): + auth_request = session.get("auth_request") + user = session.get("user") + + if not auth_request or not user: + return "Invalid session", 400 + + logging.debug(f"consent screen POST call auth_request => {auth_request}") + client_id = auth_request.get("client_id") + redirect_uri = auth_request.get("redirect_uri") + scopes = auth_request.get("scope", "").split(" ") + response_type = auth_request.get("response_type") + state = auth_request.get("state") + + if request.form.get("consent") == "true": + if response_type == "token id_token" or response_type == "id_token token": + access_token = create_access_token(client_id, scopes, user.get("sub")) + id_token = create_id_token(client_id, user, scopes) + + parsed = urlparse(redirect_uri) + fragment_params = { + "access_token": access_token, + "id_token": id_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": " ".join(scopes), + "state": state, + } + new_uri = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + parsed.query, + urlencode(fragment_params), + )) + + session.pop("auth_request", None) + session.pop("user", None) + return redirect(new_uri) + + elif response_type == "code": + auth_code = os.urandom(16).hex() + AUTHORIZATION_CODES[auth_code] = { + "client_id": client_id, + "user": user, + "scopes": scopes, + "redirect_uri": redirect_uri, + "expires_at": time.time() + 300, + "code_challenge": auth_request.get("code_challenge"), + "code_challenge_method": auth_request.get("code_challenge_method"), + } + + parsed = urlparse(redirect_uri) + query_params = {"code": auth_code, "state": state} + new_uri = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + urlencode(query_params), + parsed.fragment, + )) + + session.pop("auth_request", None) + session.pop("user", None) + return redirect(new_uri) + + # User denied consent or invalid response + parsed = urlparse(redirect_uri) + query_params = { + "error": "access_denied", + "error_description": "User denied access", + "state": state, + } + new_uri = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + urlencode(query_params), + parsed.fragment, + )) + return redirect(new_uri) + + +@app.route("/generate-token", methods=["POST"]) +def generate_token(): + auth_header = request.headers.get("Authorization") + client_id = None + client_secret = None + + # Client id and secret can come in body or in header (Authorization : Basic base64(client_id_value:client_secret_value)) + if auth_header and auth_header.startswith("Basic "): + try: + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode( + "utf-8" + ) + client_id, client_secret = decoded_credentials.split(":", 1) + except (IndexError, ValueError): + pass # Fallback to form data + + if not client_id or not client_secret: + client_id = request.form.get("client_id") + client_secret = request.form.get("client_secret") + + grant_type = request.form.get("grant_type") + + # logging.debug(f"Grant Type = {grant_type}") + # logging.debug(f"Request => {request.__dict__}") + + client = CLIENT_REGISTRY.get(client_id) + + if not client: + logging.error(f"invlid client {client_id}") + return ( + jsonify( + {"error": "invalid_client", "error_description": "Client not found"} + ), + 401, + ) + + if client["client_secret"] != client_secret: + logging.error(f"Client authentication failed") + return ( + jsonify({ + "error": "invalid_client", + "error_description": "Client authentication failed", + }), + 401, + ) + + if grant_type == "client_credentials": + scopes = request.form.get("scope", "").split(" ") + for scope in scopes: + if scope not in client["allowed_scopes"]: + logging.error(f"Invalid_scope") + return jsonify({"error": "invalid_scope"}), 400 + + access_token = create_access_token(client_id, scopes) + + return jsonify({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": " ".join(scopes), + }) + + elif grant_type == "authorization_code": + code = request.form.get("code") + redirect_uri = request.form.get("redirect_uri") + code_verifier = request.form.get("code_verifier") + + auth_code_data = AUTHORIZATION_CODES.pop(code, None) + + if not auth_code_data: + logging.error(f"Invalid or expired authorization code.") + return ( + jsonify({ + "error": "invalid_grant", + "error_description": "Invalid or expired authorization code.", + }), + 400, + ) + + if ( + auth_code_data["redirect_uri"] != redirect_uri + or auth_code_data["client_id"] != client_id + ): + logging.error(f"Redirect URI or client ID mismatch") + return ( + jsonify({ + "error": "invalid_grant", + "error_description": "Redirect URI or client ID mismatch", + }), + 400, + ) + + if time.time() > auth_code_data["expires_at"]: + logging.error(f"Authorization code has expired") + return ( + jsonify({ + "error": "invalid_grant", + "error_description": "Authorization code has expired", + }), + 400, + ) + + if "code_challenge" in auth_code_data and auth_code_data["code_challenge"]: + if not code_verifier: + logging.error(f"Code verifier is required for PKCE flow.") + return ( + jsonify({ + "error": "invalid_request", + "error_description": "Code verifier is required for PKCE flow.", + }), + 400, + ) + + computed_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode("utf-8")).digest() + ) + .decode("utf-8") + .replace("=", "") + ) + if computed_challenge != auth_code_data["code_challenge"]: + logging.error(f"PKCE code challenge mismatch.") + return ( + jsonify({ + "error": "invalid_grant", + "error_description": "PKCE code challenge mismatch.", + }), + 400, + ) + + # Create tokens based on the stored user data + user = auth_code_data["user"] + access_token = create_access_token( + client_id, auth_code_data["scopes"], user["sub"] + ) + id_token = create_id_token(client_id, user, auth_code_data["scopes"]) + + return jsonify({ + "access_token": access_token, + "id_token": id_token, + "token_type": "Bearer", + "expires_in": 3600, + "scope": " ".join(auth_code_data["scopes"]), + }) + logging.error(f"Unsupported_grant_type") + return jsonify({"error": "unsupported_grant_type"}), 400 + + +@app.route("/") +def index(): + return render_template("index.html") + + +# --- ADMIN ROUTES START --- +@app.route("/admin") +def admin_portal(): + return render_template( + "admin.html", + openid_config=OPENID_CONFIG, + user_registry=json.dumps(USER_REGISTRY), + client_registry=json.dumps(CLIENT_REGISTRY), + ) + + +@app.route("/admin/update-config", methods=["POST"]) +def admin_update_config(): + try: + data = request.json + OPENID_CONFIG["issuer"] = data.get("issuer", OPENID_CONFIG["issuer"]) + OPENID_CONFIG["authorization_endpoint"] = data.get( + "authorization_endpoint", OPENID_CONFIG["authorization_endpoint"] + ) + OPENID_CONFIG["jwks_uri"] = data.get("jwks_uri", OPENID_CONFIG["jwks_uri"]) + OPENID_CONFIG["token_endpoint"] = data.get( + "token_endpoint", OPENID_CONFIG["token_endpoint"] + ) + return jsonify( + {"success": True, "message": "OpenID configuration updated."} + ) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 400 + + +@app.route("/admin/add-user", methods=["POST"]) +def admin_add_user(): + try: + data = request.json + username = data.get("username") + password = data.get("password") + sub = data.get("sub") + profile = data.get("profile") + email = data.get("email") + + if not username or not password or not sub: + return ( + jsonify({ + "success": False, + "message": "Username, password, and sub are required.", + }), + 400, + ) + + USER_REGISTRY[username] = { + "password": password, + "sub": sub, + "profile": profile, + "email": email, + } + return jsonify({"success": True, "message": f"User '{username}' added."}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 400 + + +@app.route("/admin/add-client", methods=["POST"]) +def admin_add_client(): + try: + data = request.json + client_id = data.get("client_id") + client_secret = data.get("client_secret") + allowed_scopes = data.get("allowed_scopes", "").split() + redirect_uri = data.get("redirect_uri", "").split() + response_types = data.get("response_types", "").split() + grant_types = data.get("grant_types", "").split() + client_name = data.get("client_name") + + if not client_id or not client_name: + return ( + jsonify({ + "success": False, + "message": "Client ID and Client Name are required.", + }), + 400, + ) + + CLIENT_REGISTRY[client_id] = { + "client_secret": client_secret, + "allowed_scopes": allowed_scopes, + "redirect_uri": redirect_uri, + "response_types": response_types, + "grant_types": grant_types, + "client_name": client_name, + } + return jsonify({"success": True, "message": f"Client '{client_id}' added."}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 400 + + +# --- ADMIN ROUTES END --- + +if __name__ == "__main__": + app.run(port=5000) diff --git a/contributing/samples/authn-adk-all-in-one/idp/sample.env b/contributing/samples/authn-adk-all-in-one/idp/sample.env new file mode 100644 index 0000000000..825c230807 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/idp/sample.env @@ -0,0 +1,15 @@ +GENERATE_JWT=true + +# Steps - +# 1. ssh-keygen -t rsa -b 2048 -m PEM -f private_key.pem +# 2. When asked about passphrase please press enter (empty passphrase) +# 3. openssl rsa -in private_key.pem -pubout > pubkey.pub +# 4. Generate the jwks.json content using https://jwkset.com/generate and this public key (choose key algorithm RS256 and Key use Signature) +# 5. Update the jwks.json with the jwks key created in 4 + +# Add key from step 1 here +# make sure you add it in single line. You can use the following command to get a single line key +# cat private_key.pem | tr -d "\n" + +PRIVATE_KEY="" + diff --git a/contributing/samples/authn-adk-all-in-one/idp/sample.jwks.json b/contributing/samples/authn-adk-all-in-one/idp/sample.jwks.json new file mode 100644 index 0000000000..127a7b346b --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/idp/sample.jwks.json @@ -0,0 +1,5 @@ +{ + "keys": [ + "Replace with JWKS from jwkset.com/generate" + ] +} \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/idp/templates/admin.html b/contributing/samples/authn-adk-all-in-one/idp/templates/admin.html new file mode 100644 index 0000000000..e7b0fb5748 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/idp/templates/admin.html @@ -0,0 +1,210 @@ + + + + + + IDP Admin Portal + + + + +
+
+

IDP Administration Portal

+
+ + +
+

OpenID Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ + +
+ +
+

User Registry

+
{{ user_registry }}
+

Add New User

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Client Registry

+
{{ client_registry }}
+

Add New Client

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/idp/templates/consent.html b/contributing/samples/authn-adk-all-in-one/idp/templates/consent.html new file mode 100644 index 0000000000..5996353483 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/idp/templates/consent.html @@ -0,0 +1,51 @@ + + + + + + +Consent + + + + +
+ +
+ + + \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/idp/templates/login.html b/contributing/samples/authn-adk-all-in-one/idp/templates/login.html new file mode 100644 index 0000000000..c460ec41c1 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/idp/templates/login.html @@ -0,0 +1,49 @@ + + + + + + +Login + + + + +
+

Log in

+

to continue to {{ client_name }}

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+
+ + +
+ +
+
+ + + \ No newline at end of file diff --git a/contributing/samples/authn-adk-all-in-one/requirements.txt b/contributing/samples/authn-adk-all-in-one/requirements.txt new file mode 100644 index 0000000000..6cd3c4bb52 --- /dev/null +++ b/contributing/samples/authn-adk-all-in-one/requirements.txt @@ -0,0 +1,6 @@ +google-adk==1.12 +Flask==3.1.1 +flask-cors==6.0.1 +python-dotenv==1.1.1 +PyJWT[crypto]==2.10.1 +requests==2.32.4 \ No newline at end of file