Function app AAD authorization

How to do access control to you API

This document provides an overview of setting up authentication to an Azure Function using a service principal. The process involves enabling authentication and creating a service principal to use as a broker. The document includes a step-by-step guide with screenshots and code snippets. The document also includes resources for further reading and code for automation. Additionally, the document includes a sample code for checking claims in C# to ensure that only authorized users can access the endpoint.

Overview

This document explains the process to setup authentication to an azure function using a service principal. This document gives a high level overview on how it works:

https://docs.microsoft.com/en-us/azure/app-service/overview-authentication-authorization#identity-providers

The setup follows the steps in Microsoft documentaion on enabling AD authentication for daemon type application:

https://docs.microsoft.com/en-us/azure/app-service/configure-authentication-provider-aad#configure-client-apps-to-access-your-app-service

The specific setup in the Azure Function and Active directory works as in the following diagram:

Untitled.png

There are two Service principals involved in the workflow:

  • AD Broker Application: application managed by the azure function representing the service to interface with Active Directory for authentication. Tokens and claims will be managed by this application.
  • Client application: Application used by the users of the API to request a token. The token needs to be requested to the AD Broker Application and then used in the requests to the Azure Function.

Resources

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow

https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens

AD Authentication set up process

1. Enable authentication in the Azure Function. It will need it’s own Service principal to use as a broker

Untitled.png

2. Create a client service principal for clients of the API to use

3. Create App Role

Untitled.png

4. Assign role to client SPN

Untitled.png

Untitled.png

Untitled.png

Untitled.png

Client Usage

1. Request token from client Service Princripal

https://login.microsoftonline.com//oauth2/token

The body needs the following values:

child_database

2. Use the token and function keys as part of the request

https://.azurewebsites.net/api/&code=

The authorization header should include the access token from step 1. as the bearer token. See https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/v1-oauth2-client-creds-grant-flow#use-the-access-token-to-access-the-secured-resource

Setup as code for automation

Set up broker for function app (graph api & az-cli < 2.35)

# Create Broker for an especific function and set authentication in it

set -e -o pipefail

subscription=$1
broker_name=$2
rsg=$3
role_value=$4
app_name=$5

az account set -s $subscription

# Update manifest with role name
sed "s/<role-value>/${role_value}/" app-roles-manifest.json > /tmp/app-roles-manifest.json
set -x

# Create SPN
broker_ids=$(az ad app create --display-name $broker_name --app-roles @/tmp/app-roles-manifest.json --reply-urls "https://${app_name}.bpweb.bp.com/.auth/login/aad/callback" --query "[appId,objectId]" --output tsv)

broker_id=$(echo $broker_ids | tr " " "\n" | head -n 1)
broker_object_id=$(echo $broker_ids | tr " " "\n" | tail -n 1)
password=$(openssl rand -base64 16)
sleep 30

# Create service principal
az ad sp create --id "$broker_id" || true 

# Update service principal to restrict usage
broker_sp_object_id=$(az ad sp show --id "$broker_id" --query "objectId" --output tsv)
az ad sp update --id $broker_sp_object_id --set appRoleAssignmentRequired=true

# Add URI
az ad app update --id $broker_object_id --identifier-uris "api://${broker_id}" 

# Turn it into Access token version 2
az rest --method PATCH --headers "Content-Type=application/json" --uri "https://graph.microsoft.com/v1.0/applications/${broker_object_id}/" --body '{"api":{"requestedAccessTokenVersion": 2}}'

# Add a secret
az ad app credential reset --id $broker_object_id --append --credential-description "Broker" --password "$password" --years 2

# Deploy authentication to function
az deployment group create -f function-app-auth.bicep  -g $rsg --parameters broker_client_id=$broker_id app_name=$app_name

# Add secret to function configuration
az functionapp config appsettings set -g $rsg -n $app_name --settings AAD_PROVIDER_AUTHENTICATION_SECRET="$password"

Manifest for role

[
    {
        "allowedMemberTypes": [
            "Application"
        ],
        "description": "<role-value>",
        "displayName": "<role-value>",
        "id": "b36b5661-23b0-44f1-8871-7ca4a3bf6c26",
        "isEnabled": true,
        "lang": null,
        "origin": "Application",
        "value": "<role-value>"
    }
]

Bicep for Function App auth

param tenant_id string = subscription().tenantId
param broker_client_id string
param app_name string

resource site 'Microsoft.Web/sites@2021-01-15' existing = {
  name: app_name
}

resource authSettings 'Microsoft.Web/sites/config@2021-01-15' = {
  parent: site
  name: 'authsettingsV2'    
  properties: {
    globalValidation: {
      requireAuthentication: true
      unauthenticatedClientAction: 'Return401'
    }
    identityProviders: {
      azureActiveDirectory: {
        enabled: true
        registration: {
          // openIdIssuer: 'https://login.microsoftonline.com/${tenant_id}/v2.0'
          openIdIssuer: 'https://sts.windows.net/${tenant_id}/v2.0'
          clientId: broker_client_id
          clientSecretSettingName: 'AAD_PROVIDER_AUTHENTICATION_SECRET'
        }
        validation: {
          allowedAudiences: [
              'api://${broker_client_id}'
          ]
        }
        isAutoProvisioned: false
      }
      login: {
        tokenStore: {
          enabled: true
        }
      }
    }
  }       
}

Add SP2SP role:

appIdForBroker=$1 # "ebad5b0e-7173-4035-879b-e5afcc140869"
roleValue=$2 # "PublishingJobUse"
appIdForClient=$3 # "a10c50f2-f56f-4678-a854-a2c46764a3ba"

roleguid=$(az ad app show --id "$appIdForBroker" --query "appRoles[? value == \`$roleValue\`].id | [0]" --output tsv)
oidForClientSP=$(az ad sp show --id $appIdForClient --query "objectId" --output tsv)
oidForBrokerSP=$(az ad sp show --id $appIdForBroker --query "objectId" --output tsv)

az rest -m POST -u https://graph.microsoft.com/beta/servicePrincipals/$oidForClientSP/appRoleAssignments -b "{\"principalId\": \"$oidForClientSP\", \"resourceId\": \"$oidForBrokerSP\",\"appRoleId\": \"$roleguid\"}"ttps://graph.microsoft.com/beta/servicePrincipals/$oidForClientSP/appRoleAssignments -b "{\"principalId\": \"$oidForClientSP\", \"resourceId\": \"$oidForBrokerSP\",\"appRoleId\": \"$roleguid\"}"

Role claims check

To check claims for clients, you can use the following code that will fetch and parse the headers from the function and validate the roles.

import logging
import azure.functions as func
import base64
from functools import wraps
import json
from dataclasses import dataclass

@dataclass
class AuthException(Exception):
    """Authentication Exception."""
    msg: str
    status_code: int = 403

    def response(self) -> func.HttpResponse:
        return func.HttpResponse(
            self.msg,
            status_code=self.status_code,
        )

def parse_roles(header):
    """Parses claims from header if auth type is AAD."""
    auth = json.loads(base64.b64decode(header.get("x-ms-client-principal")).decode())

    if auth.get("auth_typ") != 'aad':
        raise AuthException("Only AAD auth is supported.", status_code=401)

    return auth.get("claims")

def validate_roles(claims, valid_roles):
    """Validate roles from claims."""
    roles = [c.get('val') for c in claims if c.get('typ') == "roles"]

    if not set(roles).intersection(set(valid_roles)):
        raise AuthException("Client has no role allowed to access the endpoint.")


def authorize_call(valid_roles):
    """"""
    def _decorator(f):
        @wraps(f)
        def _wrap(req: func.HttpRequest, *args, **kwargs):
            try:
                h = parse_roles(req.headers)
                validate_roles(h, valid_roles)

                return f(req, *args, **kwargs)

            except AuthException as e:
                return e.response()

        return _wrap
    return _decorator


@authorize_call(["MyApp.ReadWrite"])
def main(req: func.HttpRequest) -> func.HttpResponse:
    ...
namespace test_auth_func {

    static class Helper {
        public static bool claims_check(string token) {

            var validRoles = new List<string>{"KnowledgeMiner"};  // TODO: put as env var in app settings 
            var decodedToken = Encoding.UTF8.GetString(Convert.FromBase64String(token));
            dynamic json = JsonConvert.DeserializeObject(decodedToken);
            if (json["auth_typ"] != "aad") return false;
            var claims = json["claims"];
            var checks = ((IEnumerable)claims).Cast<dynamic>().Where(c => c["typ"] == "roles" && validRoles.Contains((string)c["val"])).ToList();
            if (checks.Count > 0){
                return true;
            }
            else return false;
        }
    }


    public static class test_auth {
        [FunctionName("test_auth")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log) {

            var headers = req.Headers;
            headers.TryGetValue("x-ms-client-principal", out var token);
            if (token == ""){
                return new BadRequestObjectResult("please bearer token is malformed or not present, please authenticate");
            }
            if (Helper.claims_check(token)){
                return new OkObjectResult("you are a super user");
            }
            else {
                return new OkObjectResult("you are a normal user");
            }  
        }
    }
}

Claims Check C# Attribute

Function code

using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using MethodBoundaryAspect.Fody.Attributes;
using System.Text;
using System.Collections;
using Newtonsoft.Json;
using Azure.Analytics.Purview.Catalog;
using Azure.Identity;
using System.Text.Json;

namespace Company.Function {

    // using MethodBoundaryAspect.Fody.Attributes;
    // using Newtonsoft.Json;

    public sealed class RoleAttribute : OnMethodBoundaryAspect {

        private readonly String[] roles;

        public RoleAttribute(params string[] role) {
            this.roles = role;
        }

        public bool claims_check(string token) {
            var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(token));
            dynamic json = JsonConvert.DeserializeObject(decoded);

            // Check auth type is aad
            if (json is null || json["auth_typ"] != "aad") return false;

            // Check claims are in roles
            var claims = (IEnumerable) json["claims"];
            var checks = claims.Cast<dynamic>().Where(c => c["typ"] == "roles" && this.roles.Contains((string)c["val"])).ToList();
            return checks.Count > 0;
        }

        public override void OnEntry(MethodExecutionArgs args) {

            HttpRequestData? req = null;
            for (int i = 0; i < args.Arguments.Length; i++) {
               if (args.Arguments[i] is HttpRequestData value) req = value;
            }

            if (req is null) {
                // Raise an exception for there not being a request
                throw new Exception("Not a HttpRequest trigger, does not have a HttpRequestData argument");
            }

            // Get token from headers, https://docs.microsoft.com/en-us/azure/app-service/configure-authentication-user-identities#access-user-claims-in-app-code
            var headers = req.Headers;
            headers.TryGetValues("x-ms-client-principal", out var values);
            var token = values?.FirstOrDefault();

            if (token is null || !claims_check(token)) {
                args.FlowBehavior = FlowBehavior.Return;
                args.ReturnValue = req.CreateResponse(HttpStatusCode.Forbidden);
            }
        }

    }


    public class HttpTrigger1 {
        private readonly ILogger _logger;

        public HttpTrigger1(ILoggerFactory loggerFactory) {
            _logger = loggerFactory.CreateLogger<HttpTrigger1>();
        }
        
        public record MultiOutputResult(HttpResponseData Response, [property: BlobOutput("multioutput/example.txt")] string BlobContent);


	      [Role("user.read")]
        [Function("HttpTrigger1")]
        public HttpResponseData Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req,
            FunctionContext context) {
            _logger.LogInformation("C# HTTP trigger function processed a request.");

            var credential = new DefaultAzureCredential();
            var client = new PurviewCatalogClient(new Uri("https://dgcm-dev-purview.purview.azure.com"), credential);

            var purviewresp = client.Types.GetTypeDefinitionByGuid("657fedf5-2f6b-f310-fcc8-d24846b15336");
            var responseDocument = JsonDocument.Parse(purviewresp.Content);

            var response = req.CreateResponse(HttpStatusCode.OK);
            response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

            response.WriteString(responseDocument.RootElement.ToString());

            return response;
        }
    }
}

csproj

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Azure.Analytics.Purview.Catalog" Version="1.0.0-beta.4" />
    <PackageReference Include="Azure.Identity" Version="1.6.0" />
    <PackageReference Include="CompileTimeWeaver.Fody" Version="3.3.3" />
    <PackageReference Include="MethodBoundaryAspect.Fody" Version="2.0.145" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.6.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.0.12" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs" Version="5.0.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.3.0" />
    <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="6.21.0" />
    <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.21.0" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.21.0" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
  <ItemGroup>
    <Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
    <Content Include="FodyWeavers.xml" />
  </ItemGroup>
</Project>

FodyWeavers.xsd

<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
  <xs:element name="Weavers">
    <xs:complexType>
      <xs:all>
        <xs:element name="MethodBoundaryAspect" minOccurs="0" maxOccurs="1" type="xs:anyType" />
        <xs:element name="CompileTimeWeaver" minOccurs="0" maxOccurs="1">
          <xs:complexType>
            <xs:all>
              <xs:element name="Content" type="xs:string" minOccurs="0" maxOccurs="1">
                <xs:annotation>
                  <xs:documentation>This is the documentation for the content</xs:documentation>
                </xs:annotation>
              </xs:element>
            </xs:all>
            <xs:attribute name="AdviceAttributes" type="xs:string">
              <xs:annotation>
                <xs:documentation>Attributes implementing IAdvice</xs:documentation>
              </xs:annotation>
            </xs:attribute>
            <xs:attribute name="References" type="xs:string">
              <xs:annotation>
                <xs:documentation>Additional reference assemblies</xs:documentation>
              </xs:annotation>
            </xs:attribute>
          </xs:complexType>
        </xs:element>
      </xs:all>
      <xs:attribute name="VerifyAssembly" type="xs:boolean">
        <xs:annotation>
          <xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
      <xs:attribute name="VerifyIgnoreCodes" type="xs:string">
        <xs:annotation>
          <xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
      <xs:attribute name="GenerateXsd" type="xs:boolean">
        <xs:annotation>
          <xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
        </xs:annotation>
      </xs:attribute>
    </xs:complexType>
  </xs:element>
</xs:schema>

FodyWeavers.xml

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <CompileTimeWeaver />
  <MethodBoundaryAspect />
</Weavers>

For az-cli > 2.37 (Microsoft graph)

Permission needed to add owners:

https://docs.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-1.0&tabs=http

Other permissions to manage the application:

Application.ReadWrite.OwnedBy

https://docs.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-1.0&tabs=http

# Create Broker for an especific function and set authentication in it

set -e -o pipefail

subscription=$1
broker_name=$2
rsg=$3
role_value=$4
app_name=$5
aad_owner_group="G DGCM AAD OWNERS"


az account set -s $subscription

# Update manifest with role name
sed "s/<role-value>/${role_value}/" app-roles-manifest.json > /tmp/app-roles-manifest.json
set -x

# Create APP
broker_ids=$(az ad app create --display-name $broker_name --app-roles @/tmp/app-roles-manifest.json --web-redirect-uris "https://${app_name}.bpweb.bp.com/.auth/login/aad/callback" --public-client-redirect-uris "https://${app_name}.bpweb.bp.com/.auth/login/aad/callback" --query "[appId,id]" --output tsv)

broker_id=$(echo $broker_ids | tr " " "\n" | head -n 1)
broker_object_id=$(echo $broker_ids | tr " " "\n" | tail -n 1)
sleep 30

# Add owners from group to APP 
az ad group member list -g "${aad_owner_group}" --query "[].id" --output tsv | xargs -n 1 -I {} az ad app owner add --id "${broker_id}" --owner-object-id "{}"


# Create service principal
az ad sp create --id "$broker_id" || true 

# Update service principal to restrict usage
broker_sp_object_id=$(az ad sp show --id "$broker_id" --query "id" --output tsv)
# az ad sp update --debug --id $broker_sp_object_id --set appRoleAssignmentRequired=true
az rest --method PATCH --headers "Content-Type=application/json" --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${broker_sp_object_id}/" --body '{"appRoleAssignmentRequired": true}'

# Add URI
az ad app update --id $broker_object_id --identifier-uris "api://${broker_id}" 

# Turn it into Access token version 2
az rest --method PATCH --headers "Content-Type=application/json" --uri "https://graph.microsoft.com/v1.0/applications/${broker_object_id}/" --body '{"api":{"requestedAccessTokenVersion": 2}}'

# Add a secret
password=$(az ad app credential reset --id $broker_object_id --years 2 --query "password" --output tsv)

# Deploy authentication to function
az deployment group create -f function-app-auth.bicep  -g $rsg --parameters broker_client_id=$broker_id app_name=$app_name

# Add secret to function configuration
az functionapp config appsettings set -g $rsg -n $app_name --settings AAD_PROVIDER_AUTHENTICATION_SECRET="$password"

For az-cli > 2.37, v2 (Microsoft graph)


parameters:
  - name: appIdForClient
    type: string

trigger: none


pool:
  vmImage: ubuntu-latest


variables:
- name: azureSubscription
  value: "<azure-subscription-name>"

- name: adoAADServiceConnection
  value: "<service-connection>"

- name: brokerName
  value: '<authentication-app-name>'

- name: roleValue
  value: '<default-role-value:API.ALL.USE>'

- name: resourceGroup
  value: '<function-app-rsg>'

- name: appName
  value: '<function-app-name>'

stages:
- stage: set_authentication
  displayName: Set Authentication
  jobs:
  - job: job
    steps:
    - template: scripts/function-app-roles/create-app.yaml
      parameters:
        azureSubscription: '$'
        brokerName: '$'
        roleValue: '$'
        appName: '$'
    - template: scripts/function-app-roles/configure-app.yaml
      parameters:
        azureSubscription: '$'
        resourceGroup: '$'
        appName: '$'
        variable: $(create_broker_output)
    
- stage: grant_access
  displayName: Grant access
  dependsOn: set_authentication
  jobs:
  - job: job
    steps:
    - task: AzureCLI@2
      displayName: 'Get function app broker id'
      inputs:
        azureSubscription: '$'
        scriptType: bash
        scriptLocation: 'inlineScript'
        inlineScript: |
          output=$(az webapp auth show -n "$" -g "$" --query "clientId" --output tsv)
          echo "##vso[task.setvariable variable=BROKER_ID]$output"

    - template: scripts/function-app-roles/assign-role-a2a.yaml
      parameters:
        azureSubscription: '$'
        appIdForBroker: '$(BROKER_ID)'
        roleValue: '$'
        appIdForClient: '$'
Share: X (Twitter) Facebook LinkedIn