Skip to content

Commit 0c55584

Browse files
committed
Implemented client and updated README
1 parent efdd681 commit 0c55584

File tree

62 files changed

+292
-346
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+292
-346
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/.env_oauth
1+
client/.env_oauth
22
/infra/terraform.tfvars
33
/.idea/.gitignore
44
/infra/.terraform.lock.hcl

README.md

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,60 @@ The file structure is as follows:
2828
![screenshot](images/terraform_tfvars.png)
2929

3030

31+
## Register server on Entra ID
32+
33+
Before deploying our server we need to create an app registration on Entra ID.
34+
35+
### Create a new app registration
36+
37+
Log into your Azure account on the web. Search for **Microsoft Entra ID**. Navigate to the **App registrations** blade and click on **New registration** button in the top left tab.
38+
39+
![screenshot](images/azuread_app_registrations.png)
40+
41+
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
42+
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
43+
and invoke services if the request has the necessary scopes. We will set the redirect URL for our client in later sections.
44+
45+
46+
![screenshot](images/azure_entra_id_register_hvalfangst_server_api.png)
47+
48+
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.
49+
50+
![screenshot](images/hvalfangst_server_api_app_registration.png)
51+
52+
53+
### Create Scope
54+
55+
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}**.
56+
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**.
57+
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.
58+
59+
Click on the **Add a scope** button under the **Expose an API** section, which is accessible from the **Expose an API** blade under **Manage**.
60+
61+
![screenshot](images/hvalfangst_server_api_expose_api.png)
62+
63+
Set the scope name to **Heroes.Read**. Clients with this scope may list and view heroes. As for consent, choose **Admins only**.
64+
For the remainder of fields you are free to choose whatever describes the scope.
65+
66+
![screenshot](images/hvalfangst_server_api_add_scope.png)
67+
68+
Repeat the above for scopes **Heroes.Write** and **Heroes.Delete**.
69+
70+
![screenshot](images/hvalfangst_server_api_all_scopes.png)
71+
72+
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
73+
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.
74+
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
75+
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.
76+
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).
77+
3178
## Set up CI/CD via Deployment Center
3279

33-
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
34-
aforementioned Web App. We will do so by connecting our Web App to our GitHub repository. Azure Web Apps has the ability
80+
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.
81+
We will do so by integrating our Web App to our GitHub repository. Azure Web Apps has the ability
3582
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
36-
will be created in Azure in order to authenticate requests. Secrets will be automatically created and referenced in the CI/CD script by Azure.
83+
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
84+
pipeline script has been created, we must adapt it slightly for it to work. More on this later.
3785

3886
Click on the **Deployment Center** section under the **Deployment** blade. Choose GitHub as source and set the appropriate organization, repository and branch.
3987
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
56104
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.
57105
Thus, we need to change the script to reference files located under the server directory, as we are to deploy our server.
58106

59-
The final pipeline definition should look like [this](.github/workflows/main_hvalfangstlinuxwebapp.yml).
107+
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**
108+
and **SERVER_CLIENT_ID** are retrieved from the runtime environment - which means that these environment variables must be set somehow. When running the
109+
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
110+
either set the environment values on your system or retrieve them from an .env file, which, naturally, **HAS** to be added your .gitignore.
111+
112+
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.
113+
114+
![screenshot](images/github_actions_hvalfangst_secrets.png)
115+
116+
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
117+
command **az webapp config appsettings set** where the associated values are retrieved from our repository secrets we set above.
60118

61119
## Deploy API
62120

@@ -72,45 +130,115 @@ Navigate to the **Deployment Center** section of your Azure Web App. A new deplo
72130

73131
![screenshot](images/deployment_center_post_action.png)
74132

133+
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**
134+
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
135+
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.
136+
137+
![screenshot](images/hvalfangstlinuxwebapp_environment_variables.png)
138+
75139
Now that we know that it deployed successfully it is finally time to access the API. Click on URI associated with **Default Domain**
76140

77141
![screenshot](images/overview_default_domain.png)
78142

79-
You will be prompted with the following default page, which indicates that the API is up and running.
143+
You will be prompted with the following index page, which indicates that the API is up and running.
80144

81145
![screenshot](images/firefox_api_home.png)
82146

147+
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).
148+
This route exposes 4 endpoints: "POST /heroes/", "GET /heroes/", "GET /heroes{hero_id}" and "DELETE /heroes/{hero_id}".
149+
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.
150+
The scope names referenced in aforementioned function call are exactly what was defined earlier. Hence, my little
151+
rant about scopes in and of itself being useless unless there is logic in place to actually enforce
152+
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
153+
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.
154+
83155

84-
## Register API on Azure AD
156+
## Register client on Azure Entra ID
157+
158+
Now that have our server deployed and configured, it is time to talk about the client. As mentioned before, the client is **NOT** deployed to Azure - it is merely a local API which may be spun up via our [run_client](client/run_client.sh) shell script.
85159

86-
Now that we have deployed our API to Azure Web Apps, we need to register it on Microsoft Entra ID.
87160

88161
### Create a new app registration
89162

90-
Navigate to the **App registrations** blade and click on **New registration** button in the top left tab
163+
As usual, one must create a new app registration akin to what was done with the server.
91164

92-
![screenshot](images/azuread_app_registrations.png)
165+
![screenshot](images/hvalfangst_api_client_app_reg.png)
93166

94-
![screenshot](images/azure_entra_id_register_hvalfangst_server_api.png)
167+
Again, take note of the **Client ID**.
95168

96-
![screenshot](images/hvalfangst_server_api_app_registration.png)
169+
![screenshot](images/hvalfangst_client.png)
97170

171+
### Create Secret
98172

99-
### Expose API
173+
Recall earlier when we mentioned that the server does not need secrets? Well, the client does. It is called **Client Secret** for a reason (naturally).
100174

175+
Head over to the **Certificates & secrets** blade. Click on the **New client secret** button.
101176

102-
![screenshot](images/hvalfangst_server_api_expose_api.png)
177+
![screenshot](images/hvalfangst_client_new_secret.png)
103178

179+
Pick a suitable name.
104180

105-
![screenshot](images/hvalfangst_server_api_add_scope.png)
181+
![screenshot](images/hvalfangst_client_add_secret.png)
106182

107-
![screenshot](images/hvalfangst_server_api_all_scopes.png)
183+
Note down the secret (clipboard or otherwise).
184+
185+
![screenshot](images/hvalfangst_client_secrets.png)
186+
187+
### Add Redirect URL
188+
189+
Now it's time for the confusing part... as Bane would say - **FOR YOU**.
190+
191+
![screenshot](images/hvalfangst_client_authentication.png)
192+
193+
![screenshot](images/hvalfangst_client_api_configure_web.png)
194+
195+
When the client application starts, it opens a browser window and directs the user to Azure's authorization endpoint.
196+
This prompts the user to log in with their Microsoft account and to authorize specific permissions, which in our case are **openid**, profile, email and User.Read.
197+
These are mandatory permissions for the Open ID Connect flow to work.
198+
If the user successfully logs in and consents, Azure redirects the user to a pre-configured URL known as the redirect, or callback URI.
199+
When the user authorizes the application, Azure needs a secure location to send the authorization code. The redirect URI is this location.
200+
The client application must "listen" at this URI to receive the code, which is necessary for exchanging it for tokens.
201+
In practical terms, this is quite simple as one merely has to expose an HTTP endpoint in the API. In our case we have chosen the redirect URI of **http://localhost:8000/auth/callback**,
202+
because our client application runs locally on port 8000, the name of our router is called auth and our designated endpoint to handle the logic is named **callback**.
203+
As is evident from peeking at our [callback endpoint](client/routers/auth.py), it extracts a value from the query parameter named **code**.
204+
It then proceeds to call the OIDC flow handler function named [handle_openid_connect_flow](client/services/auth_service.py) with the aforementioned code retrieved from the authorization server in the callback endpoint.
205+
The OIDC function calls yet another function named **get_access_token**, which attempt to exchange our authorization code for an actual access token by issuing a POST request to the token endpoint exposed by MS Entra ID.
206+
As may be observed, the request body must be populated with our client id & secret, the authorization code and the grant type. Access and ID tokens will be fetched from the response on success and stored in our
207+
[token_storage](client/services/token_storage.py) class so that we may utilize the token in the endpoint associated with the [heroes router](client/routers/heroes.py).
208+
It goes without saying that this is just a silly example on how to easily store the tokens in-memory. The MSAL library has their own, proper, caching implementation baked in.
209+
210+
### Add API permissions
211+
212+
213+
214+
![screenshot](images/hvalfangst_client_api_permissions.png)
215+
216+
![screenshot](images/hvalfangst_client_request_permission_graph.png)
217+
218+
![screenshot](images/hvalfangst_client_api_permissions_graph_openid.png)
219+
220+
![screenshot](images/hvalfangst_client_api_permissions_hvalfangst_search.png)
221+
222+
223+
![screenshot](images/hvalfangst_client_api_permissions_hvalfangst_server_heroes_read.png)
224+
225+
![screenshot](images/hvalfangst_client_all_permissions_added.png)
226+
227+
![screenshot](images/hvalfangst_client_grant_admin_consent_prompt.png)
228+
229+
![screenshot](images/hvalfangst_client_permissions_granted_admin_consent_for.png)
108230

231+
### Create .env file
109232

233+
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
234+
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.
235+
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.
110236

237+
The final file should look as follows:
238+
![screenshot](images/env_oauth.png)
111239

112240

113-
## Running API
241+
## Running local Client API
114242
```bash
115-
python -m uvicorn app.main:app --reload
243+
sh client/run_client.sh
116244
```

client/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

client/config/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# client/config/__init__.py
2-
31
from .oauth import oauth_settings
42

53
__all__ = ["oauth_settings"]

client/config/oauth.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
# client/config/oauth.py
2-
31
from dotenv import load_dotenv
42
from fastapi import HTTPException
53
from pydantic_settings import BaseSettings
6-
from client import logger
4+
from client.logger import logger
75

86
load_dotenv()
97

8+
109
class OAuthSettings(BaseSettings):
1110
AZURE_CLIENT_ID: str
1211
AZURE_CLIENT_SECRET: str
1312
AZURE_TENANT_ID: str
14-
API_SCOPE: str
1513
REDIRECT_URI: str
1614

1715
class Config:
@@ -21,21 +19,21 @@ class Config:
2119
def initialize_oauth_settings():
2220
try:
2321
# Create an instance of OAuthSettings
24-
internal_oauth_settings = OAuthSettings()
22+
settings = OAuthSettings()
2523

2624
# Check if the required OAuth fields are set
27-
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:
28-
logger.logger.error("One or more required OAuth environment variables are missing.")
25+
if not settings.AZURE_CLIENT_ID or not settings.AZURE_CLIENT_SECRET or not settings.AZURE_TENANT_ID:
26+
logger.error("One or more required OAuth environment variables are missing.")
2927
raise HTTPException(status_code=500,
3028
detail="Configuration error: Required OAuth environment variables are missing.")
3129

32-
logger.logger.info("OAuth settings loaded successfully.")
33-
return internal_oauth_settings
30+
logger.info("OAuth settings loaded successfully.")
31+
return settings
3432
except FileNotFoundError:
35-
logger.logger.critical(".env file not found.")
33+
logger.critical(".env file not found.")
3634
raise HTTPException(status_code=500, detail="Configuration error: .env file not found.")
3735
except Exception as e:
38-
logger.logger.critical(f"Error loading OAuth settings: {e}")
36+
logger.critical(f"Error loading OAuth settings: {e}")
3937
raise HTTPException(status_code=500,
4038
detail="Configuration error: An error occurred while loading OAuth settings.")
4139

client/logger.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

client/logger/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .logger import logger
2+
3+
__all__ = ["logger"]

client/logger/logger.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import logging
2+
3+
logging.basicConfig(
4+
level=logging.INFO,
5+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
6+
)
7+
8+
logger = logging.getLogger("logger")

client/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from client.routers import auth, heroes
55

66
app = FastAPI(
7-
title="Hero API",
8-
description="An API to manage heroes secure by OAuth 2.0 auth code flow",
7+
title="Hvalfangst Client",
8+
description="Client accessing our server deployed on Azure Web Apps secured by OAuth 2.0 authorization code flow with OIDC",
99
version="1.0.0"
1010
)
1111

client/models/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# client/models/__init__.py
1+
from .hero import Hero
22

3-
from .dnd_hero import DnDHero, AbilityScores, SkillProficiencies, Equipment, Spell
4-
5-
__all__ = ["DnDHero", "AbilityScores", "SkillProficiencies", "Equipment", "Spell"]
3+
__all__ = ["Hero"]

client/models/ability_scores.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

client/models/dnd_hero.py

Lines changed: 0 additions & 34 deletions
This file was deleted.

client/models/equipment.py

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)