ec2b666284
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
267 lines
7.4 KiB
Python
267 lines
7.4 KiB
Python
# 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)
|