chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
1. A hotel booking application backend
|
||||
1. A hotel assistant ADK agent (accessing the application using OpenAPI Tools)
|
||||
|
||||
### Details
|
||||
|
||||
You can read about the [Auth Code grant / flow type](https://developer.okta.com/blog/2018/04/10/oauth-authorization-code-grant-type) in detail. But for the purpose of this demo, following steps take place
|
||||
|
||||
1. The user asks the agent to find hotels in "New York".
|
||||
1. Agent realizes (based on LLM response) that it needs to call a tool and that the tool needs authentication.
|
||||
1. Agent redirects the user to the IDP's login page with callback / redirect URL back to ADK UI.
|
||||
1. The user enters credentials (`john.doe` and `password123`) and accepts the consent.
|
||||
1. The IDP sends the auth_code back to the redirect URL (from 3).
|
||||
1. ADK then exchanges this auth_code for an access token.
|
||||
1. ADK does the API call to get details on hotels and hands over that response to LLM, LLM formats the response.
|
||||
1. ADK sends a response back to the User.
|
||||
|
||||
### Setting up and running
|
||||
|
||||
1. Clone this repository
|
||||
1. 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
|
||||
```
|
||||
|
||||
<details>
|
||||
|
||||
<summary><b>Screenshots</b></summary>
|
||||
Generating JWKS -
|
||||
|
||||

|
||||
|
||||
Updated `jwks.json` (notice the key is added in the existing array)
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
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.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# 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 . import agent
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
|
||||
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 ...
|
||||
)
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
google-adk==2.2.0
|
||||
@@ -0,0 +1,6 @@
|
||||
# General Agent Configuration
|
||||
GOOGLE_GENAI_USE_ENTERPRISE=False
|
||||
GOOGLE_API_KEY=NOT_SET
|
||||
GOOGLE_MODEL=gemini-flash-latest
|
||||
OAUTH_CLIENT_ID=abc123
|
||||
OAUTH_CLIENT_SECRET=secret123
|
||||
+3
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 746 KiB |
+263
@@ -0,0 +1,263 @@
|
||||
# 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.
|
||||
|
||||
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}"
|
||||
@@ -0,0 +1,266 @@
|
||||
# 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 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
|
||||
# ideally 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)
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,569 @@
|
||||
# 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.
|
||||
|
||||
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 request'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"invalid 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)
|
||||
@@ -0,0 +1,14 @@
|
||||
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=""
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"keys": [
|
||||
"Replace with JWKS from jwkset.com/generate"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IDP Admin Portal</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
.card {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
pre {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card p-4">
|
||||
<h1 class="text-center mb-4">IDP Administration Portal</h1>
|
||||
<div id="alert-container"></div>
|
||||
|
||||
<!-- OpenID Configuration Section -->
|
||||
<section class="mb-5">
|
||||
<h2>OpenID Configuration</h2>
|
||||
<form id="configForm">
|
||||
<div class="mb-3">
|
||||
<label for="issuer" class="form-label">Issuer</label>
|
||||
<input type="text" class="form-control" id="issuer" value="{{ openid_config.issuer }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="authorization_endpoint" class="form-label">Authorization Endpoint</label>
|
||||
<input type="text" class="form-control" id="authorization_endpoint" value="{{ openid_config.authorization_endpoint }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="token_endpoint" class="form-label">Token Endpoint</label>
|
||||
<input type="text" class="form-control" id="token_endpoint" value="{{ openid_config.token_endpoint }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="jwks_uri" class="form-label">JWKS URI</label>
|
||||
<input type="text" class="form-control" id="jwks_uri" value="{{ openid_config.jwks_uri }}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update Configuration</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Registries Section -->
|
||||
<div class="row mt-4">
|
||||
<!-- User Registry -->
|
||||
<div class="col-md-6">
|
||||
<h2>User Registry</h2>
|
||||
<pre><code id="userRegistry">{{ user_registry }}</code></pre>
|
||||
<h4 class="mt-4">Add New User</h4>
|
||||
<form id="addUserForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="sub" class="form-label">Subject (sub)</label>
|
||||
<input type="text" class="form-control" id="sub" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="profile" class="form-label">Profile</label>
|
||||
<input type="text" class="form-control" id="profile">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Add User</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Client Registry -->
|
||||
<div class="col-md-6">
|
||||
<h2>Client Registry</h2>
|
||||
<pre><code id="clientRegistry">{{ client_registry }}</code></pre>
|
||||
<h4 class="mt-4">Add New Client</h4>
|
||||
<form id="addClientForm">
|
||||
<div class="mb-3">
|
||||
<label for="client_id" class="form-label">Client ID</label>
|
||||
<input type="text" class="form-control" id="client_id" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="client_name" class="form-label">Client Name</label>
|
||||
<input type="text" class="form-control" id="client_name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="client_secret" class="form-label">Client Secret</label>
|
||||
<input type="text" class="form-control" id="client_secret">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="redirect_uri" class="form-label">Redirect URIs (space-separated)</label>
|
||||
<input type="text" class="form-control" id="redirect_uri">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="allowed_scopes" class="form-label">Allowed Scopes (space-separated)</label>
|
||||
<input type="text" class="form-control" id="allowed_scopes">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="response_types" class="form-label">Response Types (space-separated)</label>
|
||||
<input type="text" class="form-control" id="response_types">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="grant_types" class="form-label">Grant Types (space-separated)</label>
|
||||
<input type="text" class="form-control" id="grant_types">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Add Client</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const alertContainer = document.getElementById('alert-container');
|
||||
|
||||
function showAlert(message, type = 'success') {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = [
|
||||
`<div class="alert alert-${type} alert-dismissible" role="alert">`,
|
||||
` <div>${message}</div>`,
|
||||
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
|
||||
'</div>'
|
||||
].join('');
|
||||
alertContainer.append(wrapper);
|
||||
}
|
||||
|
||||
// Update Config
|
||||
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
issuer: document.getElementById('issuer').value,
|
||||
authorization_endpoint: document.getElementById('authorization_endpoint').value,
|
||||
token_endpoint: document.getElementById('token_endpoint').value,
|
||||
jwks_uri: document.getElementById('jwks_uri').value
|
||||
};
|
||||
|
||||
const response = await fetch('/admin/update-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await response.json();
|
||||
showAlert(result.message, result.success ? 'success' : 'danger');
|
||||
});
|
||||
|
||||
// Add User
|
||||
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
sub: document.getElementById('sub').value,
|
||||
email: document.getElementById('email').value,
|
||||
profile: document.getElementById('profile').value,
|
||||
};
|
||||
|
||||
const response = await fetch('/admin/add-user', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await response.json();
|
||||
showAlert(result.message, result.success ? 'success' : 'danger');
|
||||
if (result.success) location.reload();
|
||||
});
|
||||
|
||||
// Add Client
|
||||
document.getElementById('addClientForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
client_id: document.getElementById('client_id').value,
|
||||
client_name: document.getElementById('client_name').value,
|
||||
client_secret: document.getElementById('client_secret').value,
|
||||
redirect_uri: document.getElementById('redirect_uri').value,
|
||||
allowed_scopes: document.getElementById('allowed_scopes').value,
|
||||
response_types: document.getElementById('response_types').value,
|
||||
grant_types: document.getElementById('grant_types').value,
|
||||
};
|
||||
|
||||
const response = await fetch('/admin/add-client', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await response.json();
|
||||
showAlert(result.message, result.success ? 'success' : 'danger');
|
||||
if (result.success) location.reload();
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Consent</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.consent-container {
|
||||
max-width: 600px;
|
||||
margin-top: 50px;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
background-color: #fff;
|
||||
}
|
||||
.scope-list li {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container d-flex justify-content-center">
|
||||
<div class="consent-container">
|
||||
<h2 class="text-center mb-4">Authorize Application</h2>
|
||||
<p class="lead">The application <strong>{{ auth_request.get('client_name', 'Unknown Client') }}</strong> is requesting access to your data. Do you want to grant it?</p>
|
||||
|
||||
<h4 class="mt-4">Requested Scopes:</h4>
|
||||
<ul class="list-unstyled scope-list">
|
||||
{% for scope in auth_request.get('scope', '').split() %}
|
||||
<li>- {{ scope }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<form method="post" action="/consent" class="mt-4">
|
||||
<input type="hidden" name="consent" value="true">
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success">Allow Access</button>
|
||||
<a href="/consent?consent=false" class="btn btn-outline-danger">Deny Access</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<h1 class="text-center mb-4">Log in</h1>
|
||||
<p class="text-center text-muted">to continue to {{ client_name }}</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('authorize') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
google-adk==2.2.0
|
||||
Flask==3.1.3
|
||||
flask-cors==6.0.1
|
||||
python-dotenv==1.2.2
|
||||
PyJWT[crypto]==2.10.1
|
||||
requests==2.33.0
|
||||
Reference in New Issue
Block a user