
Navigating Software Security - A Practical Guide to Authentication and Authorization
Overview
In the current software landscape, the demand for robust authentication and authorization mechanisms is paramount. When dealing with intricate solutions involving APIs, Single Page Applications (SPAs), and Daemons, the prospect of implementing security can indeed appear daunting. Questions abound: Who takes charge of issuing ID tokens? Where do Roles find their definition? Is it feasible to achieve security without requiring user intervention?
This document stands as a beacon, offering a "learn by doing" approach to unravel the intricacies of implementing security within such diverse software architectures. Eschewing lengthy theoretical discussions, this guide takes a concise yet comprehensive route. Upon its completion, developers of varying disciplines will wield the knowledge necessary to forge ahead and craft such security-enhanced platforms with confidence.
Embark on this enlightening journey and empower yourself to fortify software solutions with the shield of security. Enjoy the exploration!
All of the source code is available in GitHub: RBAC Repo We will not go through all the data coding required inside may of the functions. Just enough to show how all the security is implemented. Once done, it should be easy enough to expand API methods, coding the CLI to send data, etc.
Our platform will consist of three apps:
- API - Visual Studio API Project
- CLI - Visual Studio Console App
- SPA - GatsbyJS web site
The following is a high-level list of requirements:
- Server App Registration (API)
- Controller with four simple GETs
- Each GET will have different Role requirements
- Roles
- Anonymous - Technically not "defined" in the App Registration
- Devices - For CLI apps with no user intervention
- Users - Any authenticated User, doesn't require a specific role
- Admins - Most restrictive permissions
- Device App Registration (CLI)
- Uses a Client Secret to access the API
- Used for scenarios such as IOT devices which there is no user login/password
- Client App Registration (SPA)
- Login using Microsoft Identity against Azure AD
- Show the logged in User's Roles
- Execute API commands using the current User's token
Describing every single step would make this document too lengthy. The following assumes a good degree of understanding of Azure, Azure AD, Visual Studio/Code, APIs, SPAs and CLIs. If something isn't clear enough, please post a comment below and the directions may be expanded.
A Note on Roles, App Registrations and Tokens
In our Weather Platform, we've streamlined Roles to be managed within the API's App Registration, a straightforward process. With a singular API, accessing Roles is uncomplicated. However, our SPA introduces an innovative approach, leveraging the API Roles. When users sign in to the SPA, an ID token is issued, housing fundamental data like username, issuer, and expiration. Notably, the ID token omits Roles, which reside within the API App Registration.
When our SPA requests an Access Token, it operates within the scope of the API App Registration (api://API_CLIENT_ID/.default
).
This Access Token carries the Roles and acts as the conduit to interact with the API.
This architecture ensures efficient, secure communication within our Weather Platform.
Additional Roles
In certain scenarios, the SPA might possess distinct Roles, like weatherspa.publishers
, that don't pertain to executing API calls.
In such cases, a Role would find its definition within the SPA App Registration.
It's important to note that this particular scenario falls beyond the purview of this document.
Nevertheless, after successfully traversing the steps outlined below, the implementation of this functionality becomes evident. This approach lays a solid foundation, making the integration of such features seamlessly comprehensible.
Setting up the API
Our API takes center stage as the 'core' app registration, serving as the hub for defining authentication, authorization permissions, roles, scopes, and more. In the realm of Azure AD Enterprise Applications, user-role associations are established. Complementing this, both the CLI and SPA function as secondary app registrations, ingeniously sharing the same set of configurations. No redundancy is necessary for duplicating Roles across these secondary registrations, as the API stands as the singular source of authority in this ecosystem. This streamlined approach ensures consistency and minimizes complexities across our applications.
Create the API App Registration
- In the Azure portal, create a new App Registration
- Name:
Weather Platform
- Supported account types:
Accounts in this organizational directory only (Single tenant)
(the other available options are outside the scope of this document) - Redirect URI (optional) - leave blank for now
The ports will be set when the solution is created and the URLs will automatically be added
- Name:
- Once done, the browser should be on the
Weather Platform
App Registration page - In the left nav, click
Owners
and add yourself as an Owner
(Many Azure app views default to 'My Apps' so this just makes it easier to find them) - Create Roles
- In the left nav, click
App Roles
- Create the following Roles with the following settings:
- Administrators, Users/Groups, weather.admins, Admins can perform/view
- Devices, Applications, weather.devices, IOT hardware with no user intervention
- In the left nav, click
- In the left nav, click
Expose an API
- Up top where it says Application ID URI, click
Add
- Accept the default setting
- Click
Save
- Up top where it says Application ID URI, click
- API Permissions
- In the left nav, click
API Permissions
- Click
Add a Permission
- Click
My APIs
- Click
Weather Platform
- Scroll down and check
weather.devices
- Click
Add Permissions
- Click
Grant admin consent for <mydomain.com>
- Click
Yes
- In the left nav, click
- Expose the API for the SPA
- In the left nav, click
Expose an API
- Click
Add scope
- Name:
weather.spa
- Admin consent display name:
Use the SPA
- Admin consent description:
Allow access to Weather via the SPA
- In the left nav, click
- Go to Azure AD -> Enterprise Applications -> Weather Platform
- Add yourself as an Owner
- In the left nav, click
Users and groups
- If your name is already listed:
- Check the box next to your name
- Click
Edit assignment
- Add the
Administrators
role
- Otherwise, add yourself as in the previous steps
- This is where permissions/roles are managed for the entire Weather Platform
- If your name is already listed:
API Code
- In Visual Studio, create a new project using the
ASP.NET Core Web API
template with the following settings:- Project Name:
Weather.Api
- Authentication Type:
Microsoft identity platform
- Check
Use Controllers
- Check
Enable OpenAPI support
- Click
Create
- You'll be prompted to add the dotnet msidentity tool, click
Next
- You'll be prompted to select the App Registration, select
Weather Platform
and clickFinish
- Project Name:
- Running the Identity tool modifies the Azure App Registration's Authentication section with the following:
- Adds a Web platform with sign in redirect URLs
- Enables
ID tokens (used for implicit and hybrid flows)
- In the
appsettings.json
, modify theAzureAD
section (critical to remove Scopes, replace values for your Tenant ID, API Client ID and your domain):
{ "AzureAd": { "Instance": "https://login.microsoftonline.com/", "Domain": "mydomain.com", "TenantId": "TENANT_ID", "ClientId": "API_CLIENT_ID", "CallbackPath": "/signin-oidc", "Scopes": "access_as_user", "ClientSecret": "Client secret from app-registration. Check user secrets/azure portal.", "ClientCertificates": [], "Resource": "api://API_CLIENT_ID" }, /// ... }
- Add CORS to the
Program.cs
- Add the
builder.Services.AddCors
beforevar app = builder.Build();
builder.Services.AddCors(options => { options.AddDefaultPolicy(builder => { builder .WithOrigins(new string[]{ "http://localhost:8000", }) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); }); // ... var app = builder.Build();
- Add the
app.UseCors
beforeapp.Run();
app.UseCors(); // ... app.Run();
- Add the
- Add some more test methods to
WeatherForecastController.cs
The controller itself will have the[Authorize]
attribute. We need some methods for various Role testing. Add the following code, these simply return the default sampleGet()
:
[HttpGet("get-anon")] [AllowAnonymous] public IEnumerable<WeatherForecast> GetAnon() { return Get(); } [HttpGet("get-auth")] [Authorize] public IEnumerable<WeatherForecast> GetAuth() { return Get(); } [HttpGet("get-auth-admin")] [Authorize(Roles = "weather.admins")] public IEnumerable<WeatherForecast> GetAuthAdmin() { return Get(); } [HttpGet("get-auth-device")] [Authorize(Roles = "weather.devices")] public IEnumerable<WeatherForecast> GetAuthDevice() { return Get(); }
- Run the solution, the default page should be the Swagger URI
- At this point, only the
get-anon
should work, the others should still return a 401. - Leave the API running for now
Setting up the CLI
The CLI code will directly authenticate using client id/client secret, similar to username/password.
Since it's directly authenticating and acting similar to a User, will use the weather.devices
Role defined above.
CLI App Registration
- In the Azure portal, create a new App Registration
- Name:
Weather Devices
- Supported account types:
Accounts in this organizational directory only (Single tenant)
- Redirect URI (optional) - leave blank for now
- Name:
- In the left nav, click
Owners
and add yourself as an Owner - API Permissions
- In the left nav, click
API Permissions
- Click
Add a Permission
- Click
My APIs
- Click
Weather Platform
- Scroll down and check
weather.devices
- Click
Add Permissions
- Click
Grant admin consent for mydomain.com
- Click
Yes
- In the left nav, click
- Secret - our devices will use App Secrets to access the APIs
- In the left nav, click
Certificates & secrets
- Ensure the
Client secrets
tab is selected - Click
New client secret
- Description:
For IOT devices
- Expires: leave the default for now
- Click
Add
- Description:
- Copy the value of the secret - must do it now, it will never again be visible
- Paste it in notepad for now, we'll need it when we create the CLI app
- In the left nav, click
CLI Code
- Create a new Console App VS project called
Weather.Cli
- Add the
Microsoft.Identity.Client
NuGet package - Right click the project and click
Manage User Secrets
which will install additional packages - Update the
secrets.json
file (replace appropriate values):
{ "TenantId": "TENANT_ID", "ClientId": "CLI_CLIENT_ID", "ClientSecret": "CLI_APP_SECRET_FROM_ABOVE", "Scope": "api://API_CLIENT_ID/.default" }
- Replace all of the code in the
Program.cs
with the following (update the port on line 25):
using Microsoft.Extensions.Configuration; using Microsoft.Identity.Client; using System.Net.Http.Headers; IConfiguration configuration = new ConfigurationBuilder() .AddUserSecrets<Program>() .Build(); // Login var authority = $"https://login.microsoftonline.com/{configuration["TenantId"]}"; var scopes = new string[] { configuration["Scope"] }; var app = ConfidentialClientApplicationBuilder .Create(configuration["ClientId"]) .WithClientSecret(configuration["ClientSecret"]) .WithAuthority(new Uri(authority)) .Build(); var authenticationResult = app.AcquireTokenForClient(scopes).ExecuteAsync().Result; // Test Access Token, all methods should work except for get-auth-admin var methods = new string[] { "get-anon", "get-auth", "get-auth-admin", "get-auth-device" }; using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authenticationResult.AccessToken); foreach (var method in methods) { var url = $"https://localhost:7063/WeatherForecast/{method}"; try { var httpResponseMessage = httpClient.GetAsync(url).Result; httpResponseMessage.EnsureSuccessStatusCode(); Console.WriteLine($"Success calling {method}"); } catch (Exception ex) { Console.WriteLine($"Error calling {method}:"); Console.WriteLine($"\t{ex.Message}"); } }
- Run the CLI, the output should be:
Success calling get-anon Success calling get-auth Error calling get-auth-admin: Response status code does not indicate success: 403 (Forbidden). Success calling get-auth-device
Setting up the SPA
Authentication for users will be orchestrated through the SPA, unlike the CLI which follows a direct path. Leveraging the defined Scope and Delegated Permissions, users will be authenticated. Our choice of GatsbyJS for this purpose is notable, although the underlying principles are transferrable to other frameworks as well. This ensures a seamless and unified authentication experience across the board, regardless of the specific technology employed.
SPA App Registration
- In the Azure portal, create a new App Registration
- Name:
Weather SPA
- Supported account types:
Accounts in this organizational directory only (Single tenant)
- Redirect URI Section:
- Select
Single-page application (SPA)
- Add a URL:
http://localhost:8000
- Select
- Name:
- In the left nav, click
Owners
and add yourself as an Owner - API Permissions
- In the left nav, click
API Permissions
- Click
Add a Permission
- Click
My APIs
- Click
Weather Platform
- Click
Delegated Permissions
- Scroll down and check
weather.spa
- Click
Add Permissions
- Click
Grant admin consent for <mydomain.com>
- Click
Yes
- In the left nav, click
SPA Code
- Create a new SPA
gatsby new
- Name
Weather
- Folder:
Weather.Spa
- Name
- Add some dependencies:
npm install @azure/msal-react @azure/msal-browser jwt-decode
- Add MSAL Configuration
- Create a new file
authConfig.ts
in thesrc
folder (update your IDs):
import { LogLevel } from "@azure/msal-browser"; export const msalConfig = { auth: { clientId: process.env.GATSBY_CLIENT_ID || 'SPA_CLIENT_ID', authority: process.env.GATSBY_AUTHORITY || 'https://login.microsoftonline.com/TENANT_ID', redirectUri: process.env.GATSBY_REDIRECT || 'http://localhost:8000', }, cache: { cacheLocation: "sessionStorage", storeAuthStateInCookie: false, }, system: { loggerOptions: { loggerCallback: (level: LogLevel, message: string, containsPii: boolean) => { if (containsPii) { return; } switch (level) { case LogLevel.Error: console.error(message); return; case LogLevel.Info: console.info(message); return; case LogLevel.Verbose: console.debug(message); return; case LogLevel.Warning: console.warn(message); return; default: return; } } } } }; export const scopes = [ process.env.GATSBY_SCOPE || 'api://API_CLIENT_ID/.default' ] // By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. export const loginRequest = { scopes };
- Update the GUIDs, etc.
- Create a new file
- Add a file
gatsby-browser.tsx
in the project root and add the following code:
import { msalConfig } from './src/authConfig'; import { PublicClientApplication } from '@azure/msal-browser'; import { MsalProvider } from '@azure/msal-react'; import React from 'react'; const msalInstance = new PublicClientApplication(msalConfig); export const wrapRootElement = ({ element }: any) => { return ( <MsalProvider instance={msalInstance} > {element} </MsalProvider> ) }
- Replace all the code in the home page
index.tsx
(Update the port on line 59):
import * as React from "react" import { useMsal } from '@azure/msal-react'; import { loginRequest, scopes } from "../authConfig"; import { useState } from "react"; import jwt_decode from "jwt-decode"; const IndexPage = () => { const { accounts, instance } = useMsal(); const [tokenRaw, setTokenRaw] = useState(''); const [tokenDecoded, setTokenDecoded] = useState(''); const handleLogin = () => { instance.loginPopup(loginRequest) .then((response) => { console.log(response); }) .catch((e) => { console.log(e); }); } const aquireToken = () => { const accessTokenRequest = { scopes, account: accounts[0] }; instance.acquireTokenSilent(accessTokenRequest) .then((accessTokenResponse) => { console.log(accessTokenResponse); setTokenRaw(accessTokenResponse.accessToken); setTokenDecoded(jwt_decode(accessTokenResponse.accessToken)); }); } const handleLogout = () => { clearData(); instance.logoutPopup({ postLogoutRedirectUri: "/", }); } const callApis = () => { const methods = ["get-anon", "get-auth", "get-auth-admin", "get-auth-device"] methods.forEach(method => { const headers = new Headers(); headers.set('method', 'GET') headers.set("Content-Type", 'application/json') headers.set("contentType", 'application/x-www-form-urlencoded') if (method !== "get-anon") headers.set('Authorization', 'Bearer ' + tokenRaw); const requestInit: RequestInit = { method: 'GET', headers, } const url = "https://localhost:7032/WeatherForecast/" + method; fetch(url, requestInit) .then(response => { if (response.ok) { console.info("Success on " + method); return response.json() } else { console.warn("Response not OK on " + method + ": " + response.status) return null } }) .then(data => { if (data) { console.log(data); return data; } }) .catch(error => { console.warn(error); return null; }) }); } const clearData = () => { setTokenRaw(''); setTokenDecoded(''); } return ( <div style={{ padding: '20px' }}> <div> <h1>RBAC Testing</h1> <button type="button" onClick={() => { handleLogin() }} disabled={(accounts && accounts.length > 0)}>Sign In</button> <button type="button" onClick={() => { handleLogout() }} disabled={!(accounts && accounts.length > 0)}>Sign Out</button> <button type="button" onClick={() => { aquireToken() }} disabled={!(accounts && accounts.length > 0)}>Aquire Access Token</button> <button type="button" onClick={() => { callApis() }}>Call APIs</button> </div> <br /> <div> <h2>Current Account:</h2> <pre> { (accounts && accounts.length > 0) ? JSON.stringify(accounts[0], null, 2) : 'Not signed in' } </pre> </div> <br /> <div> <h2>Access Token Raw:</h2> <div> <pre style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word' }}> { (accounts && accounts.length > 0) ? tokenRaw : 'Not signed in' } </pre> </div> </div> <br /> <div> <h2>Access Token Decoded:</h2> <pre> { (accounts && accounts.length > 0) ? JSON.stringify(tokenDecoded, null, 2) : 'Not signed in' } </pre> </div> <br /> <div> <h2>Access Token Roles:</h2> <pre> { (tokenDecoded?.roles?.length > 0) ? JSON.stringify(tokenDecoded.roles, null, 2) : 'No Access Token or Roles' } </pre> </div> </div> ) } export default IndexPage
- Run the project:
gatsby develop
Next Steps
With our platform now operational and boasting a robust configuration, extending its capabilities becomes a straightforward endeavor. Here's how you can seamlessly integrate new functionalities:
- Enhance API for Temperature Data: Expand the API's capabilities by introducing POST methods to efficiently receive temperature data from the CLI.
- CLI Integration for Data Submission: Equip the CLI with the necessary code to effectively send temperature data to the API, establishing a seamless data flow.
- Token Debugging and Analysis: For debugging purposes, delve into the world of token examination. Utilize platforms like jwt.ms to decode Access Tokens, allowing you to inspect their contents, including Roles.
- Empower SPA with Additional Roles:
Elevate the SPA's capabilities by adding a new Role within the SPA App Registration—perhaps
weatherspa.publishers
. Assign this Role to yourself, and its presence will be visible on the SPA's home page, marking a significant accomplishment.
As the platform evolves, these successive steps demonstrate the agility and adaptability of our architecture, making new feature integration a seamless and gratifying experience.