Neo Lock on PCB


Navigating Software Security - A Practical Guide to Authentication and Authorization

Master security for software with Azure and Microsoft Identity. Safeguard effectively!
Published by Joseph McGurkin

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:

  1. API - Visual Studio API Project
  2. CLI - Visual Studio Console App
  3. SPA - GatsbyJS web site

The following is a high-level list of requirements:

  1. Server App Registration (API)
    1. Controller with four simple GETs
    2. Each GET will have different Role requirements
    3. Roles
      1. Anonymous - Technically not "defined" in the App Registration
      2. Devices - For CLI apps with no user intervention
      3. Users - Any authenticated User, doesn't require a specific role
      4. Admins - Most restrictive permissions
  2. Device App Registration (CLI)
    1. Uses a Client Secret to access the API
    2. Used for scenarios such as IOT devices which there is no user login/password
  3. Client App Registration (SPA)
    1. Login using Microsoft Identity against Azure AD
    2. Show the logged in User's Roles
    3. 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

  1. In the Azure portal, create a new App Registration
    1. Name: Weather Platform
    2. Supported account types: Accounts in this organizational directory only (Single tenant)
      (the other available options are outside the scope of this document)
    3. Redirect URI (optional) - leave blank for now
      The ports will be set when the solution is created and the URLs will automatically be added
  2. Once done, the browser should be on the Weather Platform App Registration page
  3. 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)
  4. Create Roles
    1. In the left nav, click App Roles
    2. Create the following Roles with the following settings:
      1. Administrators, Users/Groups, weather.admins, Admins can perform/view
      2. Devices, Applications, weather.devices, IOT hardware with no user intervention
  5. In the left nav, click Expose an API
    1. Up top where it says Application ID URI, click Add
    2. Accept the default setting
    3. Click Save
  6. API Permissions
    1. In the left nav, click API Permissions
    2. Click Add a Permission
    3. Click My APIs
    4. Click Weather Platform
    5. Scroll down and check weather.devices
    6. Click Add Permissions
    7. Click Grant admin consent for <mydomain.com>
    8. Click Yes
  7. Expose the API for the SPA
    1. In the left nav, click Expose an API
    2. Click Add scope
    3. Name: weather.spa
    4. Admin consent display name: Use the SPA
    5. Admin consent description: Allow access to Weather via the SPA
  8. Go to Azure AD -> Enterprise Applications -> Weather Platform
    1. Add yourself as an Owner
  9. In the left nav, click Users and groups
    1. If your name is already listed:
      1. Check the box next to your name
      2. Click Edit assignment
      3. Add the Administrators role
    2. Otherwise, add yourself as in the previous steps
    3. This is where permissions/roles are managed for the entire Weather Platform

API Code

  1. In Visual Studio, create a new project using the ASP.NET Core Web API template with the following settings:
    1. Project Name: Weather.Api
    2. Authentication Type: Microsoft identity platform
    3. Check Use Controllers
    4. Check Enable OpenAPI support
    5. Click Create
    6. You'll be prompted to add the dotnet msidentity tool, click Next
    7. You'll be prompted to select the App Registration, select Weather Platform and click Finish
  2. Running the Identity tool modifies the Azure App Registration's Authentication section with the following:
    1. Adds a Web platform with sign in redirect URLs
    2. Enables ID tokens (used for implicit and hybrid flows)
  3. In the appsettings.json, modify the AzureAD 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"
       },
    
       /// ...
     }
  4. Add CORS to the Program.cs
    1. Add the builder.Services.AddCors before var app = builder.Build();

      builder.Services.AddCors(options =>
      {
        options.AddDefaultPolicy(builder =>
        {
           builder
                 .WithOrigins(new string[]{
                       "http://localhost:8000",
                    })
                 .AllowAnyHeader()
                 .AllowAnyMethod()
                 .AllowCredentials();
        });
      });
      
      // ...
      
      var app = builder.Build();
    2. Add the app.UseCors before app.Run();

      app.UseCors();
      // ...
      app.Run();
  5. 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 sample Get():

     [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();
     }
  6. Run the solution, the default page should be the Swagger URI
  7. At this point, only the get-anon should work, the others should still return a 401.
  8. 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

  1. In the Azure portal, create a new App Registration
    1. Name: Weather Devices
    2. Supported account types: Accounts in this organizational directory only (Single tenant)
    3. Redirect URI (optional) - leave blank for now
  2. In the left nav, click Owners and add yourself as an Owner
  3. API Permissions
    1. In the left nav, click API Permissions
    2. Click Add a Permission
    3. Click My APIs
    4. Click Weather Platform
    5. Scroll down and check weather.devices
    6. Click Add Permissions
    7. Click Grant admin consent for mydomain.com
    8. Click Yes
  4. Secret - our devices will use App Secrets to access the APIs
    1. In the left nav, click Certificates & secrets
    2. Ensure the Client secrets tab is selected
    3. Click New client secret
      1. Description: For IOT devices
      2. Expires: leave the default for now
      3. Click Add
    4. Copy the value of the secret - must do it now, it will never again be visible
    5. Paste it in notepad for now, we'll need it when we create the CLI app

CLI Code

  1. Create a new Console App VS project called Weather.Cli
  2. Add the Microsoft.Identity.Client NuGet package
  3. Right click the project and click Manage User Secrets which will install additional packages
  4. 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"
     }
  5. 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}");
         }
     }
    
  6. 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

  1. In the Azure portal, create a new App Registration
    1. Name: Weather SPA
    2. Supported account types: Accounts in this organizational directory only (Single tenant)
    3. Redirect URI Section:
      1. Select Single-page application (SPA)
      2. Add a URL: http://localhost:8000
  2. In the left nav, click Owners and add yourself as an Owner
  3. API Permissions
    1. In the left nav, click API Permissions
    2. Click Add a Permission
    3. Click My APIs
    4. Click Weather Platform
    5. Click Delegated Permissions
    6. Scroll down and check weather.spa
    7. Click Add Permissions
    8. Click Grant admin consent for <mydomain.com>
    9. Click Yes

SPA Code

  1. Create a new SPA
    gatsby new
    1. Name Weather
    2. Folder: Weather.Spa
  2. Add some dependencies:
    npm install @azure/msal-react @azure/msal-browser jwt-decode
  3. Add MSAL Configuration
    1. Create a new file authConfig.ts in the src 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
      };
      
    2. Update the GUIDs, etc.
  4. 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>
       )
    }
  5. 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>
                &nbsp;&nbsp;&nbsp;&nbsp;
                <button type="button" onClick={() => { handleLogout() }} disabled={!(accounts && accounts.length > 0)}>Sign Out</button>
                &nbsp;&nbsp;&nbsp;&nbsp;
                <button type="button" onClick={() => { aquireToken() }} disabled={!(accounts && accounts.length > 0)}>Aquire Access Token</button>
                &nbsp;&nbsp;&nbsp;&nbsp;
                <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
  6. 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:

  1. Enhance API for Temperature Data: Expand the API's capabilities by introducing POST methods to efficiently receive temperature data from the CLI.
  2. 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.
  3. 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.
  4. 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.



Comments