diff --git a/.gitignore b/.gitignore index 03cc951..470da54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/.env_oauth +client/.env_oauth /infra/terraform.tfvars /.idea/.gitignore /infra/.terraform.lock.hcl diff --git a/README.md b/README.md index d5b5d14..ae4ad8e 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,60 @@ The file structure is as follows: ![screenshot](images/terraform_tfvars.png) +## Register server on Entra ID + +Before deploying our server we need to create an app registration on Entra ID. + +### Create a new app registration + +Navigate to the **App registrations** blade and click on **New registration** button in the top left tab + +![screenshot](images/azuread_app_registrations.png) + +Choose a suitable name. Here I have chosen the name "Hvalfangst Server" as the registration will be utilized by an API we are to deploy to Azure Web Apps in the coming sections. The client which is to interact with our server resource will **NOT** be deployed. It will merely run locally. The fact that +both the newly deployed server and the not-to-be-deployed local client are both Python APIs (using the FastAPI framework) may seem confusing, but this is just for demonstration purposes. We do not need to set up a redirect URI for our server as it merely validates token received in the authentication header of the client request +and invoke services if the request has the necessary scopes. We will set the redirect URL for our client in later sections. + + +![screenshot](images/azure_entra_id_register_hvalfangst_server_api.png) + +Once the app registration has been created, store the application and tenant id for later use. We will make use of these when setting up the CI/CD pipeline - which deploys the server API to Azure Web Apps. + +![screenshot](images/hvalfangst_server_api_app_registration.png) + + +### Create Scope + +We will now proceed to create scopes. Scopes are in essence fully customizable access right labels, meaning that you may are free to pick any name. It is, however common to conform to the following pattern: **{RESOURCE}.{ACCESS_LEVEL}**. +Say that you have implemented a CRUD API in the domain of wines. Since the domain is wine, the prefix would naturally be **Wines**. Access levels **could** be **READ**, **WRITE** and **DELETE**. +For instance, the scope **Wines.Read** grants you access to **read** wines - which in the API translates to the right to perform any **HTTP GET** requests, which commonly would be actions such as listing metadata of all wines or to get information about a specific wine. + +Click on the **Add a scope** button under the **Expose an API** section, which is accessible from the **Expose an API** blade under **Manage**. + +![screenshot](images/hvalfangst_server_api_expose_api.png) + +Set the scope name to **Heroes.Read**. Clients with this scope may list and view heroes. As for consent, choose **Admins only**. +For the remainder of fields you are free to choose whatever describes the scope. + +![screenshot](images/hvalfangst_server_api_add_scope.png) + +Repeat the above for scopes **Heroes.Write** and **Heroes.Delete**. + +![screenshot](images/hvalfangst_server_api_all_scopes.png) + +It goes without saying that the chosen scopes are just simple examples. Feel free to adapt as you see fit. It is important to mention that the newly created scopes +are absolutely junk in and of itself. You **must** reference the scopes names exactly as defined in your [server code](server/security/auth.py) for it to have any effect. +That is, you must implement logic in your endpoints which verifies the signature associated with the token derived from the auth header, ensure that the +audience is the client id of the server app registration and that the scopes included in the decoded claims matches that of what is required for that specific endpoint. +In order to [create heroes](server/services/hero_service.py) one must have the scope **Heroes.Create** as specified in the [router](server/routers/heroes.py). + ## Set up CI/CD via Deployment Center -Now that we have our new Web App resource up and running on Azure, we may proceed to set up our means of deploying our code to the -aforementioned Web App. We will do so by connecting our Web App to our GitHub repository. Azure Web Apps has the ability +Now that we have provisioned necessary infrastructure and created an app registration for the server, we may proceed to create the pipeline used to deploy our code to Azure Web Apps. +We will do so by integrating our Web App to our GitHub repository. Azure Web Apps has the ability to create a fully fledged CI/CD pipeline in the form of a GitHub Action Workflows script, which it commits on our behalf. As part of this pipeline a managed identify -will be created in Azure in order to authenticate requests. Secrets will be automatically created and referenced in the CI/CD script by Azure. +will be created in Azure in order to authenticate requests. Secrets will be automatically created and referenced in the CI/CD script by Azure. Once the +pipeline script has been created, we must adapt it slightly for it to work. More on this later. Click on the **Deployment Center** section under the **Deployment** blade. Choose GitHub as source and set the appropriate organization, repository and branch. For authentication keep it as is (user-assigned identity). Click on the **Save** button in the top left corner. @@ -56,7 +104,17 @@ For the CI/CD workflow script to actually work, we have to make some adjustments which are located in their own directories. The autogenerated script assumes that the files are located in the root folder, which is not the case here. Thus, we need to change the script to reference files located under the server directory, as we are to deploy our server. -The final pipeline definition should look like [this](.github/workflows/main_hvalfangstlinuxwebapp.yml). +We are storing configuration values for our API in a class named [AzureConfig](server/config/config.py). Notice how the values for fields **TENANT_ID** +and **SERVER_CLIENT_ID** are retrieved from the runtime environment - which means that these environment variables must be set somehow. When running the +API locally for sake of testing one should **NOT** hardcode the associated values due to the risk of accidentally committing to SCM. Instead, you should +either set the environment values on your system or retrieve them from an .env file, which, naturally, **HAS** to be added your .gitignore. + +Proceed to add two new GitHub Action secrets. These should be your tenant ID and the client ID associated with your newly created **Hvalfangst Server API** app registration. + +![screenshot](images/github_actions_hvalfangst_secrets.png) + +We now need to modify our GitHub Actions Workflow script to set the environment variables in our Azure Web App itself. We do so by the use of the az CLI +command **az webapp config appsettings set** where the associated values are retrieved from our repository secrets we set above. ## Deploy API @@ -72,45 +130,83 @@ Navigate to the **Deployment Center** section of your Azure Web App. A new deplo ![screenshot](images/deployment_center_post_action.png) +Click on the **Environment variables** section of your Web App to ensure that the App setting environment variables **HVALFANGST_TENANT_ID** and **HVALFANGST_SERVER_CLIENT_ID** +have been set. The environment variable **SCM_DO_BUILD_DURING_DEPLOYMENT** was set by our [Terraform script](infra/terraform.tf) when creating the Azure Web App. It instructs our container to +build the virtual environment based on our [requirements](server/requirements.txt) file on deploy as opposed to utilizing some pre-built virtual environment that has been transmitted. + +![screenshot](images/hvalfangstlinuxwebapp_environment_variables.png) + Now that we know that it deployed successfully it is finally time to access the API. Click on URI associated with **Default Domain** ![screenshot](images/overview_default_domain.png) -You will be prompted with the following default page, which indicates that the API is up and running. +You will be prompted with the following index page, which indicates that the API is up and running. ![screenshot](images/firefox_api_home.png) +The index page is available for all users and as such is not protected by any token validation logic. What is protected by token validation logic is our [heroes route](server/routers/heroes.py). +This route exposes 4 endpoints: "POST /heroes/", "GET /heroes/", "GET /heroes{hero_id}" and "DELETE /heroes/{hero_id}". +Notice how one in each endpoint always start by awaiting a function called [authorize](server/security/auth.py), passing in a token and a scope. +The scope names referenced in aforementioned function call are exactly what was defined earlier. Hence, my little +rant about scopes in and of itself being useless unless there is logic in place to actually enforce +required scopes. We will utilize our [local client](client/main.py) to make HTTP calls to the server we deployed in previous sections. But first we must register it on Entra ID +and assign it the appropriate permissions so that the scopes contained in tokens received from the authorization server matches that of protected in the server code. -## Register API on Azure AD -Now that we have deployed our API to Azure Web Apps, we need to register it on Microsoft Entra ID. +## Register client on Azure Entra ID ### Create a new app registration -Navigate to the **App registrations** blade and click on **New registration** button in the top left tab +In order for our client to be abl -![screenshot](images/azuread_app_registrations.png) +![screenshot](images/hvalfangst_api_client_app_reg.png) -![screenshot](images/azure_entra_id_register_hvalfangst_server_api.png) +![screenshot](images/hvalfangst_client.png) -![screenshot](images/hvalfangst_server_api_app_registration.png) +### Create Secret +![screenshot](images/hvalfangst_client_new_secret.png) -### Expose API +![screenshot](images/hvalfangst_client_add_secret.png) +![screenshot](images/hvalfangst_client_secrets.png) -![screenshot](images/hvalfangst_server_api_expose_api.png) +### Add Redirect URL +![screenshot](images/hvalfangst_client_authentication.png) -![screenshot](images/hvalfangst_server_api_add_scope.png) +![screenshot](images/hvalfangst_client_api_configure_web.png) -![screenshot](images/hvalfangst_server_api_all_scopes.png) +### Add API permissions + +![screenshot](images/hvalfangst_client_api_permissions.png) + +![screenshot](images/hvalfangst_client_request_permission_graph.png) + +![screenshot](images/hvalfangst_client_api_permissions_graph_openid.png) + +![screenshot](images/hvalfangst_client_api_permissions_hvalfangst_search.png) + + +![screenshot](images/hvalfangst_client_api_permissions_hvalfangst_server_heroes_read.png) + +![screenshot](images/hvalfangst_client_all_permissions_added.png) + +![screenshot](images/hvalfangst_client_grant_admin_consent_prompt.png) + +![screenshot](images/hvalfangst_client_permissions_granted_admin_consent_for.png) +### Create .env file +For the local client to work, one must create a file named ".env_oauth", which is to hold client and tenant id, secret and callback URI. This information +may be retrieved from our Client App registration. If you forgot to copy the client secret to your clipboard you may create a new one and use that instead. +The fields will be mapped to our [OAuthSettings](client/config/oauth.py) on startup and used when making calls to the authorization server in order to obtain tokens. +The final file should look as follows: +![screenshot](images/env_oauth.png) -## Running API +## Running local Client API ```bash -python -m uvicorn app.main:app --reload +sh client/run_client.sh ``` diff --git a/client/__init__.py b/client/__init__.py deleted file mode 100644 index d4282d5..0000000 --- a/client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# client/__init__.py \ No newline at end of file diff --git a/client/config/__init__.py b/client/config/__init__.py index 28a8448..c2e68ca 100644 --- a/client/config/__init__.py +++ b/client/config/__init__.py @@ -1,5 +1,3 @@ -# client/config/__init__.py - from .oauth import oauth_settings __all__ = ["oauth_settings"] \ No newline at end of file diff --git a/client/config/oauth.py b/client/config/oauth.py index 6b2aa43..93e4b4d 100644 --- a/client/config/oauth.py +++ b/client/config/oauth.py @@ -1,17 +1,15 @@ -# client/config/oauth.py - from dotenv import load_dotenv from fastapi import HTTPException from pydantic_settings import BaseSettings -from client import logger +from client.logger import logger load_dotenv() + class OAuthSettings(BaseSettings): AZURE_CLIENT_ID: str AZURE_CLIENT_SECRET: str AZURE_TENANT_ID: str - API_SCOPE: str REDIRECT_URI: str class Config: @@ -21,21 +19,21 @@ class Config: def initialize_oauth_settings(): try: # Create an instance of OAuthSettings - internal_oauth_settings = OAuthSettings() + settings = OAuthSettings() # Check if the required OAuth fields are set - if not internal_oauth_settings.AZURE_CLIENT_ID or not internal_oauth_settings.AZURE_CLIENT_SECRET or not internal_oauth_settings.AZURE_TENANT_ID or not internal_oauth_settings.API_SCOPE: - logger.logger.error("One or more required OAuth environment variables are missing.") + if not settings.AZURE_CLIENT_ID or not settings.AZURE_CLIENT_SECRET or not settings.AZURE_TENANT_ID: + logger.error("One or more required OAuth environment variables are missing.") raise HTTPException(status_code=500, detail="Configuration error: Required OAuth environment variables are missing.") - logger.logger.info("OAuth settings loaded successfully.") - return internal_oauth_settings + logger.info("OAuth settings loaded successfully.") + return settings except FileNotFoundError: - logger.logger.critical(".env file not found.") + logger.critical(".env file not found.") raise HTTPException(status_code=500, detail="Configuration error: .env file not found.") except Exception as e: - logger.logger.critical(f"Error loading OAuth settings: {e}") + logger.critical(f"Error loading OAuth settings: {e}") raise HTTPException(status_code=500, detail="Configuration error: An error occurred while loading OAuth settings.") diff --git a/client/logger.py b/client/logger.py deleted file mode 100644 index af3be1b..0000000 --- a/client/logger.py +++ /dev/null @@ -1,12 +0,0 @@ -# client/logger.py - -import logging - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) - -# Create a logger object that can be imported across the application -logger = logging.getLogger(__name__) diff --git a/client/logger/__init__.py b/client/logger/__init__.py new file mode 100644 index 0000000..e6e8305 --- /dev/null +++ b/client/logger/__init__.py @@ -0,0 +1,3 @@ +from .logger import logger + +__all__ = ["logger"] \ No newline at end of file diff --git a/client/logger/logger.py b/client/logger/logger.py new file mode 100644 index 0000000..f0868ea --- /dev/null +++ b/client/logger/logger.py @@ -0,0 +1,8 @@ +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger("logger") diff --git a/client/main.py b/client/main.py index f8b77ba..f76f9f4 100644 --- a/client/main.py +++ b/client/main.py @@ -4,8 +4,8 @@ from client.routers import auth, heroes app = FastAPI( - title="Hero API", - description="An API to manage heroes secure by OAuth 2.0 auth code flow", + title="Hvalfangst Client", + description="Client accessing our server deployed on Azure Web Apps secured by OAuth 2.0 authorization code flow with OIDC", version="1.0.0" ) diff --git a/client/models/__init__.py b/client/models/__init__.py index 2138be4..9207ed8 100644 --- a/client/models/__init__.py +++ b/client/models/__init__.py @@ -1,5 +1,3 @@ -# client/models/__init__.py +from .hero import Hero -from .dnd_hero import DnDHero, AbilityScores, SkillProficiencies, Equipment, Spell - -__all__ = ["DnDHero", "AbilityScores", "SkillProficiencies", "Equipment", "Spell"] \ No newline at end of file +__all__ = ["Hero"] \ No newline at end of file diff --git a/client/models/ability_scores.py b/client/models/ability_scores.py deleted file mode 100644 index 1fbadc7..0000000 --- a/client/models/ability_scores.py +++ /dev/null @@ -1,12 +0,0 @@ -# client/models/ability_scores.py - -from pydantic import BaseModel - - -class AbilityScores(BaseModel): - strength: int - dexterity: int - constitution: int - intelligence: int - wisdom: int - charisma: int \ No newline at end of file diff --git a/client/models/dnd_hero.py b/client/models/dnd_hero.py deleted file mode 100644 index 5c376f7..0000000 --- a/client/models/dnd_hero.py +++ /dev/null @@ -1,34 +0,0 @@ -# client/models/dnd_hero.py - -from pydantic import BaseModel -from typing import List, Optional -from client.models.ability_scores import AbilityScores -from client.models.equipment import Equipment -from client.models.skill_proficiencies import SkillProficiencies -from client.models.spell import Spell - - -class DnDHero(BaseModel): - id: str - name: str - race: str - class_: str # Avoids conflict with the Python `class` keyword - level: int - background: Optional[str] = None - alignment: Optional[str] = None - - # Nested fields - ability_scores: AbilityScores - skill_proficiencies: SkillProficiencies - equipment: Equipment - spells: Optional[List[Spell]] = None # Optional, only for spellcasters - - hit_points: int - armor_class: int - speed: int - - # Additional optional features - personality_traits: Optional[str] = None - ideals: Optional[str] = None - bonds: Optional[str] = None - flaws: Optional[str] = None diff --git a/client/models/equipment.py b/client/models/equipment.py deleted file mode 100644 index 3da3006..0000000 --- a/client/models/equipment.py +++ /dev/null @@ -1,10 +0,0 @@ -# client/models/equipment.py - -from typing import Optional, List -from pydantic import BaseModel - - -class Equipment(BaseModel): - weapon: Optional[str] = None - armor: Optional[str] = None - items: List[str] = [] diff --git a/client/models/hero.py b/client/models/hero.py new file mode 100644 index 0000000..456af01 --- /dev/null +++ b/client/models/hero.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import BaseModel + + +class Hero(BaseModel): + id: str + name: str + race: str + class_: str # Avoids conflict with the Python `class` keyword + level: int + background: Optional[str] = None + alignment: Optional[str] = None + hit_points: int + armor_class: int + speed: int + personality_traits: Optional[str] = None + ideals: Optional[str] = None + bonds: Optional[str] = None + flaws: Optional[str] = None diff --git a/client/models/skill_proficiencies.py b/client/models/skill_proficiencies.py deleted file mode 100644 index 7abf828..0000000 --- a/client/models/skill_proficiencies.py +++ /dev/null @@ -1,24 +0,0 @@ -# client/models/skill_proficiencies.py - -from pydantic import BaseModel - - -class SkillProficiencies(BaseModel): - acrobatics: bool = False - animal_handling: bool = False - arcana: bool = False - athletics: bool = False - deception: bool = False - history: bool = False - insight: bool = False - intimidation: bool = False - investigation: bool = False - medicine: bool = False - nature: bool = False - perception: bool = False - performance: bool = False - persuasion: bool = False - religion: bool = False - sleight_of_hand: bool = False - stealth: bool = False - survival: bool = False diff --git a/client/models/spell.py b/client/models/spell.py deleted file mode 100644 index e4bcf16..0000000 --- a/client/models/spell.py +++ /dev/null @@ -1,13 +0,0 @@ -# client/models/spell.py - -from typing import List -from pydantic import BaseModel - - -class Spell(BaseModel): - name: str - level: int - casting_time: str - range: str - components: List[str] - duration: str diff --git a/client/requirements.txt b/client/requirements.txt index 7622f92..fb0c01a 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -1,9 +1,9 @@ -fastapi==0.115.2 # FastAPI framework for building APIs -uvicorn==0.32.0 # ASGI server for running FastAPI apps -pydantic==2.9.2 # Data validation and parsing for FastAPI models +fastapi==0.115.2 +uvicorn==0.32.0 +pydantic==2.9.2 config~=0.5.1 dotenv~=0.0.5 python-dotenv==1.0.1 httpx==0.27.2 -jwt==1.3.1 +pyjwt==2.9.0 pydantic_settings==2.6.0 \ No newline at end of file diff --git a/client/routers/__init__.py b/client/routers/__init__.py index 37c93c6..3bae1ad 100644 --- a/client/routers/__init__.py +++ b/client/routers/__init__.py @@ -1,3 +1 @@ -# client/routers/__init__.py - __all__ = ["heroes", "auth"] diff --git a/client/routers/auth.py b/client/routers/auth.py index b1d85ce..6625a2d 100644 --- a/client/routers/auth.py +++ b/client/routers/auth.py @@ -1,5 +1,3 @@ -# client/routers/auth.py - from http.client import HTTPException from fastapi import APIRouter, HTTPException, Request from client.logger import logger @@ -8,7 +6,6 @@ router = APIRouter() -# Example usage in the callback route @router.get("/callback") async def auth_callback(request: Request): """Callback handler for OpenID Connect flow.""" @@ -24,9 +21,8 @@ async def auth_callback(request: Request): # Call the OpenID Connect handler function try: logger.info("Initiating OpenID Connect flow handling") - result = await handle_openid_connect_flow(code) + await handle_openid_connect_flow(code) logger.info("OpenID Connect flow completed successfully") - return result except Exception as e: logger.error(f"An error occurred during OpenID Connect flow: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error during OpenID Connect flow") \ No newline at end of file + raise HTTPException(status_code=500, detail="Internal server error during OpenID Connect flow") diff --git a/client/routers/heroes.py b/client/routers/heroes.py index a0b26ad..98676f3 100644 --- a/client/routers/heroes.py +++ b/client/routers/heroes.py @@ -1,53 +1,66 @@ -# client/routers/heroes.py - +import os from http.client import HTTPException from typing import List -from fastapi import APIRouter -from fastapi import HTTPException +import httpx +from fastapi import APIRouter, HTTPException -from client.models.dnd_hero import DnDHero -from client.services.auth_service import verify_scope -from client.services.hero_service import HeroService +from client.logger import logger +from client.models import Hero +from client.services.token_storage import get_stored_token # Import get_stored_token function router = APIRouter() -hero_service = HeroService() + +# Set values based on environment variable or hard-coded URL +BACKEND_API_BASE_URL = os.getenv("HVALFANGST_API_URL", "https://hvalfangstlinuxwebapp.azurewebsites.net/api") + + +# Helper function to make HTTP requests to the backend API +async def request_backend(method: str, endpoint: str, json=None): + url = f"{BACKEND_API_BASE_URL}{endpoint}" + + # Retrieve the access token from token storage + token_data = get_stored_token() + headers = {"Authorization": f"Bearer {token_data}"} if token_data else {} + + # Log the request details + logger.info(f"Preparing {method} request to URL: {url}") + logger.info(f"Headers: {headers}") + logger.info(f"Payload: {json}") + + try: + async with httpx.AsyncClient() as client: + response = await client.request(method, url, json=json, headers=headers) + response.raise_for_status() + logger.info(f"Request to {url} completed successfully with status code {response.status_code}") + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error occurred for {method} request to {url}: {e.response.status_code} - {e.response.text}") + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + raise HTTPException(status_code=500, detail="An unexpected error occurred") # POST: Create a new Hero -@router.post("/heroes/", response_model=DnDHero) -async def create_hero(hero: DnDHero): - return await hero_service.create_hero(hero) +@router.post("/heroes/", response_model=Hero) +async def create_hero(hero: Hero): + return await request_backend("POST", "/heroes/", json=hero.dict()) # GET: Retrieve a hero by ID -@router.get("/heroes/{hero_id}", response_model=DnDHero) +@router.get("/heroes/{hero_id}", response_model=Hero) async def read_hero(hero_id: str): - hero = await hero_service.get_hero(hero_id) - if hero: - return hero - else: - raise HTTPException(status_code=404, detail="Hero not found") + return await request_backend("GET", f"/heroes/{hero_id}") # GET: Retrieve all heroes -@router.get("/heroes/", response_model=List[DnDHero]) +@router.get("/heroes/", response_model=List[Hero]) async def read_heroes(): - await verify_scope(["Heroes.Read"]) - return await hero_service.list_heroes() + return await request_backend("GET", "/heroes/") # DELETE: Delete a hero by ID @router.delete("/heroes/{hero_id}", response_model=dict) async def delete_hero(hero_id: str): - success = await hero_service.delete_hero(hero_id) - if success: - return {"message": f"Hero with id '{hero_id}' deleted successfully"} - else: - raise HTTPException(status_code=404, detail="Hero not found") - - -# GET: Custom query to retrieve heroes with Fireball spell and AC < 20 -@router.get("/heroes-fireball-low-ac", response_model=List[DnDHero]) -async def get_fireball_heroes_with_low_ac(): - return await hero_service.query_heroes_fireball_low_ac() + return await request_backend("DELETE", f"/heroes/{hero_id}") diff --git a/client/run_client.sh b/client/run_client.sh new file mode 100644 index 0000000..84a4ef1 --- /dev/null +++ b/client/run_client.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Check if virtual environment exists, if not, create it +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python -m venv venv +fi + +# Activate the virtual environment (use Scripts for Windows) +echo "Activating virtual environment..." +if [[ "$OSTYPE" == "msys" ]]; then + # For Windows + source venv/Scripts/activate +else + # For Linux/Mac + source venv/bin/activate +fi + +# Upgrade pip to the latest version +echo "Upgrading pip..." +pip install --upgrade pip + +# Install dependencies from requirements.txt +echo "Installing dependencies from requirements.txt..." +pip install -r client/requirements.txt + +# Run the FastAPI application using uvicorn +echo "Starting FastAPI application..." +python -m uvicorn client.main:app --reload \ No newline at end of file diff --git a/client/services/__init__.py b/client/services/__init__.py index 4600164..e69de29 100644 --- a/client/services/__init__.py +++ b/client/services/__init__.py @@ -1 +0,0 @@ -# client/services/__init__.py \ No newline at end of file diff --git a/client/services/auth_service.py b/client/services/auth_service.py index 6a48d5f..0ecd2b2 100644 --- a/client/services/auth_service.py +++ b/client/services/auth_service.py @@ -1,31 +1,19 @@ -# client/services/auth_service.py - -# Set up constants for OAuth2 import webbrowser from typing import List from urllib.parse import urlencode - import httpx import jwt from fastapi import HTTPException from fastapi.security import OAuth2AuthorizationCodeBearer - from client.config import oauth_settings from client.logger import logger -from client.services.token_storage import DECODED_TOKEN +from client.services.token_storage import store_token AUTHORITY = f"https://login.microsoftonline.com/{oauth_settings.AZURE_TENANT_ID}" AUTH_URL = f"{AUTHORITY}/oauth2/v2.0/authorize" TOKEN_URL = f"{AUTHORITY}/oauth2/v2.0/token" JWKS_URL = f"https://login.microsoftonline.com/{oauth_settings.AZURE_TENANT_ID}/discovery/v2.0/keys" -# Role hierarchy mapping: which roles can fulfill which scopes -ROLE_HIERARCHY = { - 'Admin': ['Heroes.Read', 'Heroes.Create', 'Admin'], - 'Heroes.Create': ['Heroes.Read', 'Heroes.Create'], - 'Heroes.Read': ['Heroes.Read'] -} - # OAuth2AuthorizationCodeBearer scheme oauth2_scheme = OAuth2AuthorizationCodeBearer( authorizationUrl=AUTH_URL, @@ -45,7 +33,7 @@ login_url = f"https://login.microsoftonline.com/{oauth_settings.AZURE_TENANT_ID}/oauth2/v2.0/authorize?{urlencode(query_params)} " # Open the login URL in the default web browser -webbrowser.open_new_tab(login_url) # This opens the login URL in a new browser tab +webbrowser.open_new_tab(login_url) async def handle_openid_connect_flow(code: str): @@ -56,7 +44,11 @@ async def handle_openid_connect_flow(code: str): # Exchange the authorization code for access and ID tokens try: logger.info("Attempting to request access token") - token = await get_access_token(code) # Function that exchanges code for token + + # Exchange code for token + token = await get_access_token(code) + + # Attempt to fetch id and access token from the results logger.info("Attempting to fetch id_token from token") id_token = token.get("id_token") logger.info("Attempting to fetch access_token from token") @@ -65,72 +57,18 @@ async def handle_openid_connect_flow(code: str): if not id_token: raise HTTPException(status_code=400, detail="ID token not found in response") - # Decode the ID token without verifying the signature first + # Decode id and access tokens - signature verification will be done on the server decoded_id_token = jwt.decode(id_token, options={"verify_signature": False}, algorithms=["RS256"]) - print("Decoded ID Token:", decoded_id_token) - - # Decode the access token without verifying the signature first decoded_access_token = jwt.decode(access_token, options={"verify_signature": False}, algorithms=["RS256"]) + print("Decoded ID Token:", decoded_id_token) print("Decoded access Token:", decoded_access_token) - # Verify the ID token signature and its claims - # verified_token = await verify_id_token(id_token) - # print("Verified ID Token:", verified_token) - - return { - "access_token": decoded_access_token, - "id_token": decoded_id_token - } + # Set the access token variable in our token storage class (used in subsequent HTTP calls to our server) + store_token(access_token) except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}") -async def verify_id_token(id_token: str): - """ - Verify the ID token using the JWKS from the Microsoft Identity platform. - """ - logger.info("Starting ID token verification.") - - try: - # Fetch the JWKS (JSON Web Key Set) asynchronously from the provider - logger.info("Fetching JWKS from %s", JWKS_URL) - async with httpx.AsyncClient() as client: - response = await client.get(JWKS_URL) - response.raise_for_status() # Raise an exception for HTTP errors - jwks = response.json() - logger.info("Successfully fetched JWKS: %s", jwks) # Log the JWKS data (consider redacting sensitive info) - - # Get the public key ID from the token header - logger.info("Extracting 'kid' from the token header.") - kid = jwt.get_unverified_header(id_token)["kid"] - logger.info("Public key ID (kid): %s", kid) - - # Find the corresponding public key in the JWKS - logger.info("Searching for matching key in JWKS.") - rsa_key = next(key for key in jwks["keys"] if key["kid"] == kid) - logger.info("Found matching key for kid: %s", kid) - - # Use the RSA public key to verify the token's signature and validate claims - logger.info("Verifying the ID token signature and validating claims.") - verified_token = jwt.decode(id_token, rsa_key, algorithms=["RS256"], audience=oauth_settings.AZURE_CLIENT_ID) - logger.info("ID token verified successfully.") - - return verified_token - - except jwt.ExpiredSignatureError: - logger.error("ID token has expired.") - raise HTTPException(status_code=403, detail="ID token has expired.") - except jwt.JWTClaimsError as claims_error: - logger.error("Invalid claims in ID token: %s", claims_error) - raise HTTPException(status_code=403, detail="Invalid claims in ID token.") - except httpx.HTTPStatusError as http_error: - logger.error("HTTP error while fetching JWKS: %s", http_error) - raise HTTPException(status_code=403, detail="Could not validate credentials.") - except Exception as e: - logger.error("An unexpected error occurred during ID token verification: %s", str(e)) - raise HTTPException(status_code=403, detail="Could not validate credentials.") - - async def get_access_token(code: str): """Exchange authorization code for an access token.""" @@ -138,7 +76,6 @@ async def get_access_token(code: str): async with httpx.AsyncClient() as client: try: - # Make the POST request to the token URL response = await client.post( TOKEN_URL, data={ @@ -151,15 +88,11 @@ async def get_access_token(code: str): }, ) - # Log the status of the token request response logger.info(f"Token endpoint responded with status code: {response.status_code}") - # Parse the response JSON response_data = response.json() - logger.info(f"Response data: {response_data}") - # Check if the response contains an error if response.status_code != 200: logger.error(f"Failed to exchange code for token. Error: {response_data}") raise HTTPException(status_code=response.status_code, detail=response_data) @@ -167,12 +100,10 @@ async def get_access_token(code: str): return response_data except Exception as e: - # Log any exceptions that occur during the process logger.exception(f"An error occurred during token exchange: {str(e)}") raise HTTPException(status_code=500, detail="An error occurred during the token exchange process") -# Function to check if the token contains the required scopes, based on role hierarchy def has_required_scope(token_scopes: List[str], required_scopes: List[str]) -> bool: """Check if any of the token's scopes fulfill the required scopes based on the role hierarchy.""" logger.debug(f"Checking scopes: Token scopes: {token_scopes}, Required scopes: {required_scopes}") @@ -183,7 +114,7 @@ def has_required_scope(token_scopes: List[str], required_scopes: List[str]) -> b # Check if the token scope can fulfill the required scope using the role hierarchy for required_scope in required_scopes: - if required_scope in ROLE_HIERARCHY.get(token_scope, []): + if required_scope in token_scope: logger.info( f"Scope match: Token scope '{token_scope}' grants access to required scope '{required_scope}' " f"based on the role hierarchy.") @@ -196,7 +127,6 @@ def has_required_scope(token_scopes: List[str], required_scopes: List[str]) -> b return False -# Dependency to validate that the user has the required scope async def verify_scope(required_scopes: List[str]): logger.info("Starting scope verification") diff --git a/client/services/hero_service.py b/client/services/hero_service.py deleted file mode 100644 index c1a381e..0000000 --- a/client/services/hero_service.py +++ /dev/null @@ -1,56 +0,0 @@ -# client/services/hero_service.py - -from typing import List, Optional -from client.models.dnd_hero import DnDHero -import uuid -import asyncio -from client.logger import logger - - -class HeroService: - def __init__(self): - # In-memory structure to store heroes - self.heroes_db: List[DnDHero] = [] - # Lock to handle concurrent access - self.lock = asyncio.Lock() - - async def create_hero(self, hero: DnDHero) -> DnDHero: - async with self.lock: - hero.id = str(uuid.uuid4()) - self.heroes_db.append(hero) - logger.info(f"Hero '{hero.name}' created with ID: {hero.id}") - return hero - - async def get_hero(self, hero_id: str) -> Optional[DnDHero]: - async with self.lock: - hero = next((h for h in self.heroes_db if h.id == hero_id), None) - if hero: - logger.info(f"Hero '{hero_id}' retrieved.") - else: - logger.warning(f"Hero '{hero_id}' not found.") - return hero - - async def list_heroes(self) -> List[DnDHero]: - async with self.lock: - logger.info(f"Listing all heroes. Total count: {len(self.heroes_db)}") - return self.heroes_db.copy() - - async def delete_hero(self, hero_id: str) -> bool: - async with self.lock: - hero = next((h for h in self.heroes_db if h.id == hero_id), None) - if hero: - self.heroes_db.remove(hero) - logger.info(f"Hero '{hero_id}' deleted.") - return True - else: - logger.warning(f"Hero '{hero_id}' not found for deletion.") - return False - - async def query_heroes_fireball_low_ac(self) -> List[DnDHero]: - async with self.lock: - results = [ - hero for hero in self.heroes_db - if "Fireball" in hero.spells and hero.armor_class < 20 - ] - logger.info(f"Found {len(results)} heroes with Fireball and AC < 20.") - return results diff --git a/client/services/token_storage.py b/client/services/token_storage.py index 6139278..b56da68 100644 --- a/client/services/token_storage.py +++ b/client/services/token_storage.py @@ -1,18 +1,14 @@ -# client/services/token_storage.py - -# Global variable to hold the token and claims from client.logger import logger -DECODED_TOKEN = None +# Global variable to hold the access token ACCESS_TOKEN = None -def store_token(access_token: str, decoded_token: dict): - global DECODED_TOKEN, ACCESS_TOKEN +def store_token(access_token: str): + global ACCESS_TOKEN ACCESS_TOKEN = access_token - DECODED_TOKEN = decoded_token - logger.info("Access token and claims successfully stored") + logger.info(f"Access token successfully stored: {ACCESS_TOKEN}") def get_stored_token(): - return {"access_token": ACCESS_TOKEN, "claims": DECODED_TOKEN} + return ACCESS_TOKEN diff --git a/images/add_secret.png b/images/add_secret.png deleted file mode 100644 index 53bab31..0000000 Binary files a/images/add_secret.png and /dev/null differ diff --git a/images/api_permissions.png b/images/api_permissions.png deleted file mode 100644 index 9074766..0000000 Binary files a/images/api_permissions.png and /dev/null differ diff --git a/images/application_id_uri.png b/images/application_id_uri.png deleted file mode 100644 index 5de0cf1..0000000 Binary files a/images/application_id_uri.png and /dev/null differ diff --git a/images/azure_entra_id_register_hvalfangst_server_api.png b/images/azure_entra_id_register_hvalfangst_server_api.png index 4bdd53e..d1004b7 100644 Binary files a/images/azure_entra_id_register_hvalfangst_server_api.png and b/images/azure_entra_id_register_hvalfangst_server_api.png differ diff --git a/images/azuread_hvalfangst_app_registration.png b/images/azuread_hvalfangst_app_registration.png deleted file mode 100644 index 77331d2..0000000 Binary files a/images/azuread_hvalfangst_app_registration.png and /dev/null differ diff --git a/images/azuread_new_app_registration.png b/images/azuread_new_app_registration.png deleted file mode 100644 index eb64746..0000000 Binary files a/images/azuread_new_app_registration.png and /dev/null differ diff --git a/images/callback_endpoint.png b/images/callback_endpoint.png deleted file mode 100644 index 8cf4b51..0000000 Binary files a/images/callback_endpoint.png and /dev/null differ diff --git a/images/client_secret.png b/images/client_secret.png deleted file mode 100644 index f2ac667..0000000 Binary files a/images/client_secret.png and /dev/null differ diff --git a/images/expose_api.png b/images/expose_api.png deleted file mode 100644 index bec170f..0000000 Binary files a/images/expose_api.png and /dev/null differ diff --git a/images/get_access_token.png b/images/get_access_token.png deleted file mode 100644 index 7591e6d..0000000 Binary files a/images/get_access_token.png and /dev/null differ diff --git a/images/github_actions_hvalfangst_secrets.png b/images/github_actions_hvalfangst_secrets.png new file mode 100644 index 0000000..7dab66a Binary files /dev/null and b/images/github_actions_hvalfangst_secrets.png differ diff --git a/images/hvalfangst_api_client_app_reg.png b/images/hvalfangst_api_client_app_reg.png new file mode 100644 index 0000000..c8819ee Binary files /dev/null and b/images/hvalfangst_api_client_app_reg.png differ diff --git a/images/hvalfangst_api_secret.png b/images/hvalfangst_api_secret.png deleted file mode 100644 index 91499c5..0000000 Binary files a/images/hvalfangst_api_secret.png and /dev/null differ diff --git a/images/hvalfangst_client.png b/images/hvalfangst_client.png new file mode 100644 index 0000000..07033b8 Binary files /dev/null and b/images/hvalfangst_client.png differ diff --git a/images/hvalfangst_client_add_secret.png b/images/hvalfangst_client_add_secret.png new file mode 100644 index 0000000..559f7eb Binary files /dev/null and b/images/hvalfangst_client_add_secret.png differ diff --git a/images/hvalfangst_client_all_permissions_added.png b/images/hvalfangst_client_all_permissions_added.png new file mode 100644 index 0000000..9eb981e Binary files /dev/null and b/images/hvalfangst_client_all_permissions_added.png differ diff --git a/images/hvalfangst_client_api_configure_web.png b/images/hvalfangst_client_api_configure_web.png new file mode 100644 index 0000000..8ffec38 Binary files /dev/null and b/images/hvalfangst_client_api_configure_web.png differ diff --git a/images/hvalfangst_client_api_permissions.png b/images/hvalfangst_client_api_permissions.png new file mode 100644 index 0000000..bb9eb2e Binary files /dev/null and b/images/hvalfangst_client_api_permissions.png differ diff --git a/images/hvalfangst_client_api_permissions_graph_openid.png b/images/hvalfangst_client_api_permissions_graph_openid.png new file mode 100644 index 0000000..8234837 Binary files /dev/null and b/images/hvalfangst_client_api_permissions_graph_openid.png differ diff --git a/images/hvalfangst_client_api_permissions_hvalfangst_search.png b/images/hvalfangst_client_api_permissions_hvalfangst_search.png new file mode 100644 index 0000000..b966e14 Binary files /dev/null and b/images/hvalfangst_client_api_permissions_hvalfangst_search.png differ diff --git a/images/hvalfangst_client_api_permissions_hvalfangst_server_heroes_read.png b/images/hvalfangst_client_api_permissions_hvalfangst_server_heroes_read.png new file mode 100644 index 0000000..3d70343 Binary files /dev/null and b/images/hvalfangst_client_api_permissions_hvalfangst_server_heroes_read.png differ diff --git a/images/hvalfangst_client_authentication.png b/images/hvalfangst_client_authentication.png new file mode 100644 index 0000000..0c34e70 Binary files /dev/null and b/images/hvalfangst_client_authentication.png differ diff --git a/images/hvalfangst_client_configure_web.png b/images/hvalfangst_client_configure_web.png new file mode 100644 index 0000000..27282f9 Binary files /dev/null and b/images/hvalfangst_client_configure_web.png differ diff --git a/images/hvalfangst_client_grant_admin_consent_prompt.png b/images/hvalfangst_client_grant_admin_consent_prompt.png new file mode 100644 index 0000000..a4a2a68 Binary files /dev/null and b/images/hvalfangst_client_grant_admin_consent_prompt.png differ diff --git a/images/hvalfangst_client_new_secret.png b/images/hvalfangst_client_new_secret.png new file mode 100644 index 0000000..0f621a4 Binary files /dev/null and b/images/hvalfangst_client_new_secret.png differ diff --git a/images/hvalfangst_client_permissions_granted_admin_consent_for.png b/images/hvalfangst_client_permissions_granted_admin_consent_for.png new file mode 100644 index 0000000..6e7a115 Binary files /dev/null and b/images/hvalfangst_client_permissions_granted_admin_consent_for.png differ diff --git a/images/hvalfangst_client_request_permission_graph.png b/images/hvalfangst_client_request_permission_graph.png new file mode 100644 index 0000000..528d107 Binary files /dev/null and b/images/hvalfangst_client_request_permission_graph.png differ diff --git a/images/hvalfangst_client_secrets.png b/images/hvalfangst_client_secrets.png new file mode 100644 index 0000000..695d7a2 Binary files /dev/null and b/images/hvalfangst_client_secrets.png differ diff --git a/images/hvalfangst_server_api_add_scope.png b/images/hvalfangst_server_api_add_scope.png index 02bdfbd..661025d 100644 Binary files a/images/hvalfangst_server_api_add_scope.png and b/images/hvalfangst_server_api_add_scope.png differ diff --git a/images/hvalfangst_server_api_app_registration.png b/images/hvalfangst_server_api_app_registration.png index c7493bc..f3e6f8f 100644 Binary files a/images/hvalfangst_server_api_app_registration.png and b/images/hvalfangst_server_api_app_registration.png differ diff --git a/images/hvalfangst_server_api_expose_api.png b/images/hvalfangst_server_api_expose_api.png index 1238a60..359c534 100644 Binary files a/images/hvalfangst_server_api_expose_api.png and b/images/hvalfangst_server_api_expose_api.png differ diff --git a/images/hvalfangstlinuxwebapp_environment_variables.png b/images/hvalfangstlinuxwebapp_environment_variables.png new file mode 100644 index 0000000..a5e8349 Binary files /dev/null and b/images/hvalfangstlinuxwebapp_environment_variables.png differ diff --git a/images/publish_settings.png b/images/publish_settings.png deleted file mode 100644 index 35a191a..0000000 Binary files a/images/publish_settings.png and /dev/null differ diff --git a/server/main.py b/server/main.py index aa929af..01957cb 100644 --- a/server/main.py +++ b/server/main.py @@ -4,8 +4,8 @@ from routers import heroes app = FastAPI( - title="Hero API", - description="An API to manage heroes secure by OAuth 2.0 auth code flow", + title="Hvalfangst Resource Server", + description="Resource server API protected by Oauth 2.0 scopes", version="1.0.0" ) diff --git a/server/models/hero.py b/server/models/hero.py index da7d012..456af01 100644 --- a/server/models/hero.py +++ b/server/models/hero.py @@ -10,13 +10,9 @@ class Hero(BaseModel): level: int background: Optional[str] = None alignment: Optional[str] = None - - # Core combat stats hit_points: int armor_class: int speed: int - - # Optional personality fields personality_traits: Optional[str] = None ideals: Optional[str] = None bonds: Optional[str] = None diff --git a/server/requirements.txt b/server/requirements.txt index 9630e7f..ca1097f 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,7 +1,7 @@ -fastapi==0.115.4 # FastAPI framework for building APIs -uvicorn==0.32.0 # ASGI server for FastAPI -pydantic==2.9.2 # Data validation and parsing for FastAPI models -gunicorn==23.0.0 # HTTP server compatible with FastAPI +fastapi==0.115.4 +uvicorn==0.32.0 +pydantic==2.9.2 +gunicorn==23.0.0 httpx==0.27.2.0 authlib==1.3.2.0 cryptography==43.0.3 diff --git a/server/local_run.sh b/server/run_server.sh similarity index 100% rename from server/local_run.sh rename to server/run_server.sh