1. Home
  2. Technical Documentation
  3. How to configure Client Apps with Azure Entra Id and Easy Auth

How to configure Client Apps with Azure Entra Id and Easy Auth

This article covers how to register and configure client applications — the apps and services that call the Warewolf Lightweight Execution Engine’s secured endpoints — with Microsoft Entra ID and Azure Easy Auth. It is the companion to Configure-WwExecutionAuth.ps1, which provisions the engine’s own Entra app registration, Easy Auth, and the interactive-user groups in secure.config. That article configures the resource (the engine); this one configures the callers.

Download the Deployment Scripts. Download the Warewolf Azure Execution Engine deployment scripts from warewolf.io/release-notes and extract the .zip so that the scripts land in D:\ExecutionEngine\Scripts. All examples in this article assume that path.

Six runnable reference clients ship under Dev\Warewolf.Execution.Lightweight.ClientExamples, one per client type. Use whichever matches your integration:

Example Stack What it demonstrates
AzureFunction .NET 8 isolated-worker Azure Function An Azure Function App calling the Execution Engine downstream — app-only / Managed Identity, no secrets at rest.
AzureServiceBus .NET 8 isolated-worker Azure Function An Azure Function App triggered by an Azure Service Bus message queue, executing a workflow per message using an app-only client-credentials/Managed Identity token.
DotNetConsole .NET 8 console A pure HTTP client with four token-acquisition flows (device code, interactive, client-credentials, managed identity) — patterns apply to any stack.
DotNetWebMvc ASP.NET Core MVC (net8.0) A server-rendered confidential web app calling the engine on behalf of a signed-in user via Microsoft.Identity.Web.
React React 18 + TypeScript + Vite A public-client SPA using MSAL React with Authorization Code + PKCE — no secret in the browser.
Angular17 Angular 17 A public-client SPA using MSAL Angular with Authorization Code + PKCE.

The authorization contract

A call to /secure/{workflow}.json or /services/{workflow}.json must satisfy two independent checks. Getting only one right produces a confusing failure, so treat them as a checklist:

  1. Authentication (token side) — the access token’s aud claim must equal api://<ResourceAppId>. Easy Auth validates this before the request reaches the engine; a mismatched audience is rejected before your code ever runs.
  2. Authorization (policy side) — the caller must carry a role the engine recognises for that workflow. Permissions are resolved from secure.config at request time, never from the token itself — the token only proves identity and role membership.
Route Auth required Header(s)
GET /public/{workflow}.json No
GET|POST /secure/{workflow}.json Yes Authorization: Bearer <token>
GET|POST /services/{workflow}.json Yes Authorization: Bearer <token> and x-functions-key: <key>

App-only callers are safe-by-default. A daemon, Managed Identity, or client-credentials caller with no assigned app role carries no roles claim at all, and is rejected — there is no such thing as an accidentally-public app-only client.

Scope Used by Token kind
api://<ResourceAppId>/user_impersonation SPA, Web MVC, Console (interactive/device-code) Delegated (scp claim)
api://<ResourceAppId>/.default Console (client-credentials), Daemon, Service Bus worker, Managed Identity App-only (roles claim)

Prerequisites

Tool Minimum version Notes
PowerShell 7.0+ Required by every script referenced here. Install via winget install Microsoft.PowerShell.
Azure CLI (az) 2.55.0+ All provisioning runs through az. Must be logged in before running any script. Install via winget install Microsoft.AzureCLI.

The engine’s Entra app registration and Easy Auth must already exist (via Configure-WwExecutionAuth.ps1) before you provision client apps — you need its Resource App ID (the <FunctionAppName>-auth registration’s client ID).

Plane Role Why
Azure RBAC Contributor on the Function App (or its resource group) Only needed for SPA registrations, which add CORS origins to the Function App.
Microsoft Entra (directory) Application Administrator (or Graph Application.ReadWrite.All) Creates/updates client app registrations, service principals, app-role assignments, and client secrets.

Client types & which registration each example uses

Type Grant flow Use case Secret required
SPA Authorization Code + PKCE Browser apps (React, Angular) No (public client)
Confidential Authorization Code + On-Behalf-Of Server-side web apps calling the engine on a user’s behalf Yes
Daemon Client Credentials Background services, CI/CD, Managed-Identity workers Yes (or Managed Identity — no secret)
Console Device Code + Interactive and Client Credentials on one registration .NET console / CLI tools that mix interactive sign-in with unattended runs Yes (public-client + secret)

SPA, Confidential, and Daemon are provisioned together by -ClientType All. Console is opt-in (-ClientType Console) — a combined public-desktop + confidential registration, not included in All.

Example Registration type Provisioning invocation
Angular17 SPA (own registration) SPA -ClientDisplayNamePrefix wwexecution-angular -SpaRedirectUris http://localhost:4201
React SPA (own registration) SPA -ClientDisplayNamePrefix wwexecution-react -SpaRedirectUris http://localhost:5173
DotNetWebMvc Confidential Confidential (default redirect https://localhost:5001/signin-oidc)
DotNetConsole Console Console -AppRolesToAssign <group-role>
AzureFunction Daemon (Managed Identity) Daemon -DaemonUseManagedIdentity -ManagedIdentityObjectId <mi-sp-object-id> -AppRolesToAssign <group-role>
AzureServiceBus Daemon (Managed Identity) Daemon -DaemonUseManagedIdentity -ManagedIdentityObjectId <mi-sp-object-id> -AppRolesToAssign <group-role>

The two SPA examples use separate registrations (different dev ports — Angular 4201, React 5173): run the provisioning script once per app with a different -ClientDisplayNamePrefix and -SpaRedirectUris.

Step 1 — Reserve a client-apps group when configuring the engine

App-only callers cannot be listed in Configure-WwExecutionAuth.ps1‘s -UserAssignments — that section is UPN-only. Instead, keep a dedicated group for them in -GroupPermissions so the app role exists on the resource app; every key you declare there becomes an Entra app role with allowedMemberTypes: ['User','Application'], so it is assignable to both interactive users and app-only service principals.

Auth config JSON — a dedicated Warewolf_ClientApps group alongside the interactive-user groups.

{
"GroupPermissions": {
"Warewolf_Developers": [],
"Warewolf_Operators": [],
"Warewolf_Administrators": [],
"Warewolf_ClientApps": []
},
"UserAssignments": [
{ "Upn": "user1@yourtenant.com", "Group": "Warewolf_Administrators" },
{ "Upn": "user2@yourtenant.com", "Group": "Warewolf_Developers" }
]
}

Entra sanitises app-role values — runs of disallowed characters become _ — so Warewolf ClientApps becomes Warewolf_ClientApps in the token’s roles claim. Use the sanitised value everywhere below, including in secure.config.

Step 2 — Provision each client type

All commands use Scripts/Configure-WwExecutionAuth-Clients.ps1, which is idempotent and safe to re-run.

az login
$ResourceAppId = '<resource-app-id>' # the engine's Entra app client ID
$TenantId = '<tenant-id>'
cd D:\ExecutionEngine\Scripts

SPA — Angular / React

.\Configure-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType SPA `
-ClientDisplayNamePrefix 'wwexecution-angular' `
-SpaRedirectUris @('http://localhost:4201') `
-FunctionAppName '<wwexecution>' -FunctionAppResourceGroup '<rg>'

This sets the SPA platform’s redirect URIs (enabling PKCE), grants the delegated user_impersonation scope with admin consent, and adds each origin derived from -SpaRedirectUris to the Function App’s CORS allowed origins — required because browsers block the Authorization header cross-origin without it. Pass -SkipCorsConfiguration if CORS is managed elsewhere. Repeat with -ClientDisplayNamePrefix wwexecution-react -SpaRedirectUris http://localhost:5173 for the React example.

Confidential — DotNetWebMvc

.\Configure-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType Confidential `
-WebRedirectUris @('https://localhost:5001/signin-oidc')

Creates a client secret, sets the web redirect URI plus the site origin and a front-channel logout URL (/signout-callback-oidc) so Microsoft.Identity.Web sign-out is accepted, and grants delegated user_impersonation with admin consent. No CORS needed — this is a server-to-server confidential client, not a browser origin.

# Assign the role directly to an EXISTING managed identity's service principal —
# no new app registration is created.
.\Configure-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType Daemon `
-DaemonUseManagedIdentity `
-ManagedIdentityObjectId '<mi-sp-object-id>' `
-AppRolesToAssign @('Warewolf_ClientApps')

If the calling Function App’s Managed Identity is not yet enabled, pass -DaemonFunctionAppName <name> -DaemonFunctionAppResourceGroup <rg> instead of -ManagedIdentityObjectId: the script runs az functionapp identity assign for you, reads the resulting principalId, and assigns the role to it.

# Client-secret daemon (local/dev, or when Managed Identity isn't available)
.\Configure-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType Daemon `
-AppRolesToAssign @('Warewolf_ClientApps')

Fail-loud by design: because an app-only caller is authorised solely by its app roles, the Daemon/Managed-Identity path throws if -AppRolesToAssign resolves to no role on the resource app, rather than silently creating a roleless client. There is deliberately no default for this parameter — pass a group-role value that already exists on the resource app (created via Configure-WwExecutionAuth.ps1 -GroupPermissions), not a Permission.* name.

Console — DotNetConsole (device-code, interactive, and client-credentials on one registration)

.\Configure-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType Console `
-AppRolesToAssign @('Warewolf_ClientApps')

Sets public-client (mobile & desktop) redirect URIs (-ConsoleRedirectUris, default http://localhost) with isFallbackPublicClient=true for device-code/interactive sign-in, creates a client secret enabling client-credentials on the same registration, and grants both the delegated scope and the app role. -AppRolesToAssign is optional here — the delegated flows work without it — but omitting it means the client-credentials flow will be rejected by the engine; the script warns rather than fails.

Entra ID setup, app by app

Step 2 covers the registration script by client type. This section walks each of the five .NET/JS reference apps from its own README, so you can go from a fresh checkout to a working token in one pass. All five call the same engine routes (/public, /secure, /services, /apis.json) — what differs is the flow and where the config lives.

AzureFunction — .NET 8 isolated Function (HTTP + Timer triggers)

A Daemon caller: no user, no browser. In production it authenticates via its own Managed Identity; locally it falls back to az login or an MSAL client-credentials secret.

  1. Deploy or identify the Function App, then enable its Managed Identity and assign it the Warewolf_ClientApps role in one call:
    .\Configure-WwExecutionAuth-Clients.ps1 `
    -ResourceAppId $ResourceAppId -TenantId $TenantId `
    -ClientType Daemon -DaemonUseManagedIdentity `
    -DaemonFunctionAppName '<caller-func-app>' -DaemonFunctionAppResourceGroup '<rg>'

    or, for the whole example in one call, Configure-WwExecutionAuth-ClientApps.ps1 -Apps azurefunction -AzureFunctionClientAppName <caller-func-app> -AzureFunctionClientResourceGroup <rg>.
  2. Set these on local.settings.json (dev) or the Function App’s Application Settings (Azure):
    Setting Value
    WwExecution:BaseUrl Engine URL, e.g. https://WWExecutionEngine.azurewebsites.net
    WwExecution:TenantId Entra tenant GUID
    WwExecution:ResourceAppId Engine’s app-registration client ID
    WwExecution:Scope Blank → defaults to api://{ResourceAppId}/.default
    WwExecution:ScheduledWorkflow Workflow the timer trigger calls (default Hello World)
    WwExecution:FunctionKey Only if calling /services/*
    WwExecution:ClientId / :ClientSecret MSAL fallback for local dev only — leave blank to use az login
    AZURE_CLIENT_ID Set only for a user-assigned MI in Azure; omit for system-assigned
  3. Run locally with az login then func start, or deploy and let the Managed Identity handle auth. Test with curl "http://localhost:7071/api/run/Hello%20World?Name=CallerTest".

This is the only example with a diagnostic GET /api/info route that returns its own decoded downstream token — anonymous by default, so restrict or remove it before exposing the app publicly.

AzureServiceBus — .NET 8 isolated Service Bus-triggered worker

Also a Daemon caller — Service Bus itself cannot hold a token or call the engine, so this worker is the compute step that reads a queue message and makes the authenticated call on its behalf.

  1. Same Daemon registration as AzureFunction above, pointed at this worker’s Function App: -DaemonFunctionAppName <sb-worker-func-app> -DaemonFunctionAppResourceGroup <rg>. For a client-secret fallback instead of Managed Identity, use -DaemonClientId <app-id>.
  2. Configuration (local.settings.json / app settings), bound from the WwExecution section:
    Key Required Purpose
    WwExecution:BaseUrl yes Engine base URL
    WwExecution:TenantId yes Entra tenant id
    WwExecution:ResourceAppId yes Engine app-registration id
    WwExecution:Scope no Defaults to api://{ResourceAppId}/.default
    WwExecution:FunctionKey no Required only for /services/*
    WwExecution:UseClientSecretFallback no true to use ClientId/ClientSecret locally instead of Managed Identity
    WwExecution:ManagedIdentityClientId no Set for a user-assigned identity
    ServiceBusConnection yes SB connection string, or the …__fullyQualifiedNamespace form for identity-based SB auth
  3. Test by enqueuing a message: az servicebus queue message send --namespace-name <ns> --queue-name wwexecution-queue --body '{ "workflow": "Hello World", "inputs": { "Name": "FromServiceBus" } }'. A malformed message or a failed engine call is not swallowed — it throws so the Functions runtime dead-letters it after the queue’s max-delivery-count, rather than silently losing it.

DotNetConsole — .NET 8 console app

The only example that exercises all four token flows (client-credentials, device-code, interactive, Managed Identity) from one registration, so you can pick per environment.

  1. Provision one Console-type client with both public-client redirect URIs (for device-code/interactive) and a secret (for client-credentials):
    .\Configure-WwExecutionAuth-Clients.ps1 `
    -ResourceAppId $ResourceAppId -TenantId $TenantId `
    -ClientType Console -AppRolesToAssign @('Warewolf_ClientApps')
  2. The two flow families need different Entra grants — both are set up by the script above, but it’s worth knowing why: app-only flows (client-credentials, managed identity) need the Warewolf_ClientApps app role; delegated flows (device-code, interactive) instead need the signed-in user assigned to the engine’s enterprise application (Users and groups) with user_impersonation consented. A token with the right aud but no role/assignment is still denied.
  3. Edit appsettings.json (or override via WWEXEC_-prefixed env vars, e.g. WWEXEC_WwExecution__ClientSecret, or --WwExecution:TenantId=... on the command line):
    {
    "WwExecution": {
    "BaseUrl": "https://WWExecutionEngine.azurewebsites.net",
    "TenantId": "<tenant-guid>",
    "ResourceAppId": "<engine-app-guid>",
    "ClientId": "<client-app-guid>",
    "ClientSecret": "",
    "FunctionKey": "",
    "SampleWorkflow": "Hello World"
    }
    }

    Prefer dotnet user-secrets set "WwExecution:ClientSecret" "<secret>" over committing it.
  4. dotnet run, then pick a flow from the menu (1–4 call /secure with a different flow each; 5 calls /public; 6 calls /apis.json). Tokens persist across restarts in a local encrypted MSAL cache (DPAPI on Windows, Keychain on macOS, libsecret on Linux).

If a delegated flow throws AADSTS7000218 (expects a client secret), the app registration doesn’t have Allow public client flows enabled — the script above sets it, but double check if you registered manually.

DotNetWebMvc — ASP.NET Core MVC + Microsoft.Identity.Web

A confidential client that signs a real user in via OpenID Connect and calls the engine on that user’s behalf — the only example where the engine sees a human’s roles, not an app’s.

  1. Provision the confidential client registration:
    .\Configure-WwExecutionAuth-Clients.ps1 `
    -ResourceAppId $ResourceAppId -TenantId $TenantId `
    -ClientType Confidential -WebRedirectUris @('https://localhost:5001/signin-oidc')

    This sets the Web redirect URI, the matching sign-out callback (/signout-callback-oidc), creates a client secret, and grants delegated user_impersonation with admin consent.
  2. Because this app authenticates real users, you must additionally assign each test user an app role on the engine’s enterprise application (Entra admin center → Enterprise applications → the engine → Users and groups) — the client registration step above does not do this for you, as it has no knowledge of which humans should have access.
  3. Edit appsettings.json; put the secret in user-secrets, not in the file:
    "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "<tenant-guid>",
    "ClientId": "<this-web-apps-client-id>",
    "ClientSecret": "<set-via-user-secrets>",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath": "/signout-callback-oidc"
    },
    "WwExecution": {
    "BaseUrl": "https://WWExecutionEngine.azurewebsites.net",
    "Scopes": [ "api://<engine-resource-app-id>/user_impersonation" ],
    "FunctionKey": ""
    }

    dotnet user-secrets init
    dotnet user-secrets set "AzureAd:ClientSecret" "<the-secret-value>"
  4. dotnet run, browse to https://localhost:5001, sign in, and open Run Workflow. A signed-in user with no app role gets a friendly “assign an app role” message from the controller rather than a raw engine error.

For production, swap AddInMemoryTokenCaches() for AddDistributedTokenCaches() and prefer a certificate credential over the client secret.

React — React 18 + Vite + @azure/msal-react

A public client (SPA) — Authorization Code + PKCE, no secret ships to the browser.

  1. Register a separate SPA client from the Angular example (different dev port):
    .\Configure-WwExecutionAuth-Clients.ps1 `
    -ResourceAppId $ResourceAppId -TenantId $TenantId `
    -ClientType SPA -ClientDisplayNamePrefix 'wwexecution-react' `
    -SpaRedirectUris @('http://localhost:5173') `
    -FunctionAppName '<wwexecution>' -FunctionAppResourceGroup '<rg>'

    This registers the redirect URI under the Single-page application platform specifically (not Web — a Web-platform redirect URI causes AADSTS9002326 at token redemption), grants delegated user_impersonation, and — unless -SkipCorsConfiguration is passed — adds http://localhost:5173 to the engine Function App’s CORS allowed origins so the browser’s cross-origin Authorization header isn’t blocked.
  2. Assign each test user an app role on the engine’s enterprise application, same as DotNetWebMvc above — delegated tokens are denied without one.
  3. Copy the example env file and fill in your values — only VITE_-prefixed variables are exposed to the browser:
    cp .env.example .env.local

    Variable Description
    VITE_TENANT_ID Directory (tenant) ID
    VITE_SPA_CLIENT_ID This SPA’s client ID
    VITE_RESOURCE_APP_ID Engine API’s client ID
    VITE_FUNCTION_APP_URL Engine base URL, no trailing slash
  4. npm install && npm run dev, open http://localhost:5173, click Sign in with Microsoft, then call Hello World against /secure.

Never commit .env.local — it’s git-ignored. Tokens are cached in localStorage by default; switch to sessionStorage in authConfig.ts if your threat model requires it.

Step 3 — Wire secure.config for the client-apps group

The token’s roles claim only tells the engine who is calling. What that role can do is resolved entirely from secure.config at request time — add a per-workflow WindowsGroupPermissions row for every workflow the client needs to execute.

secure.config row granting the client-apps group View + Execute on the sales workflow.

{
"WindowsGroup": "Warewolf_ClientApps",
"ResourceID": "22222222-2222-2222-2222-222222222222",
"ResourceName": "sales",
"IsServer": false,
"View": true,
"Execute": true,
"Contribute": false,
"DeployTo": false,
"DeployFrom": false,
"Administrator": false
}

WindowsGroup must equal the sanitised Entra app-role value carried in the token, exactly as it appears in the roles claim — see Security — secure.config for the full permission model.

Step 4 — Acquire a token and call a secured endpoint

Client credentials (Daemon, Console non-interactive)

$TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$Scope = "api://$ResourceAppId/.default"

$Token = (Invoke-RestMethod -Method POST -Uri $TokenEndpoint -Body @{
grant_type = 'client_credentials'
client_id = $ClientId
client_secret = $ClientSecret
scope = $Scope
}).access_token

Invoke-RestMethod -Uri "https://<wwexecution>.azurewebsites.net/secure/Hello%20World.json?Name=Test" `
-Headers @{ Authorization = "Bearer $Token" }

curl -s -X POST "$TOKEN_ENDPOINT" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&scope=$SCOPE" \
| jq -r '.access_token'

curl -X GET "https://<wwexecution>.azurewebsites.net/secure/Hello%20World.json?Name=Test" \
-H "Authorization: Bearer $TOKEN"

Managed Identity (AzureFunction, AzureServiceBus in Azure)

No client ID or secret is needed in Azure — DefaultAzureCredential acquires the app-only token directly, and a DelegatingHandler injects the header on every outbound call. The Managed Identity’s service principal must hold the app role assigned in Step 2; locally, fall back to az login.

Device code (Console, SPA testing from a terminal)

# 1. Request a device code
curl -s -X POST "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode" \
-d "client_id=$ClientId&scope=$Scope"
# → shows a verification_uri and user_code; open the URL and enter the code

# 2. Poll the token endpoint until the user completes sign-in
curl -s -X POST "$TOKEN_ENDPOINT" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=$ClientId&device_code=$DeviceCode"

Expected token claims differ by caller type — check these when troubleshooting a 401/403:

Claim App-only (client-credentials / Managed Identity) Delegated (user sign-in)
aud api://<ResourceAppId> api://<ResourceAppId>
roles ["Warewolf_ClientApps"] ["Warewolf_Developers"] (present only if the user was assigned the group)
scp absent user_impersonation

Step 5 — Validate

Configure-WwExecutionAuth-Clients.ps1‘s companion orchestrator, Configure-WwExecutionAuth-ClientApps.ps1, can provision and validate all six example apps in one call:

.\Configure-WwExecutionAuth-ClientApps.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-FunctionAppName '<wwexecution>' -FunctionAppResourceGroup '<rg>' `
-AppRolesToAssign Warewolf_ClientApps `
-Validate -NonInteractive

App type Validation How
Daemon (secret), Console (client-credentials) Automatic Acquires a client-credentials token for .default, calls GET /secure/{workflow}.json, expects 2xx.
Daemon (Managed Identity) Skipped No local secret to test with — validate from inside Azure, where the Managed Identity is available.
SPA, Console (delegated) Interactive opt-in Device-code flow, then GET /secure/{workflow}.json.
Web MVC (confidential) Manual Sign in through the web app in a browser — a confidential client cannot use device-code.

Interpreting the result: 2xx = pass · 401 = audience/scope mismatch · 403 = role/permission denied · 500 with an Error{…} body = engine authorization denial (roleless caller, or a missing View/Execute row in secure.config).

Worked end-to-end example — deploying the engine and a Daemon client caller together

The steps above assume the engine is already deployed. This walkthrough starts from nothing: it deploys the engine, then creates and registers an AzureFunction-style Daemon client caller against it, using Managed Identity end to end (no secrets stored anywhere). It is the copy-paste sequence from the full Deploy-EndToEnd-Runbook.md runbook shipped alongside the deployment scripts.

1. Session setup (run once)

az login

# Resolve tenant + subscription from the current az context
$TenantId = az account show --query tenantId -o tsv
$SubscriptionId = az account show --query id -o tsv

# ── Engine targeting ────────────────────────────────────────────────────────
$ResourceGroup = '<your-resource-group>' # e.g. DEV2
$Location = 'southafricanorth'
$StorageAccount = '<your-storage-account>' # 3-24 lowercase chars
$AppName = '<your-function-app-name>' # engine app, globally unique

# ── Engine inputs (already-published package + config files) ─────────────────
$ScriptsDir = '<path>\ExecutionEngine\Scripts'
$PublishPath = '<path>\ExecutionEngine\AzureFunctionsPackage-<version>.zip' # folder or .zip
$AuthConfigPath = "$ScriptsDir\Deploy-WwExecutionEngine.authconfig.json"
$SecureConfigPath = 'C:\ProgramData\Warewolf\Server Settings\secure.config' # or plaintext JSON (auto-encrypted)
$WorkflowsSourcePath = 'C:\ProgramData\Warewolf\Resources'
$LicenseConfigPath = '<path>\ExecutionEngine\Warewolf License.secureconfig'
$ElasticsearchSourcePath = '<path>\ExecutionEngine\ElasticsearchLoggingSource.bite'
$KeyVaultName = '<your-key-vault-name>'
$KeyVaultSecretName = '<your-kv-secret-name>'
$ExecutionLogLevel = 'ERROR' # TRACE|DEBUG|INFO|WARN|ERROR|FATAL|OFF
$LogDir = "$ScriptsDir\logs"

az account set --subscription $SubscriptionId
Set-Location $ScriptsDir

Copy Deploy-WwExecutionEngine.authconfig.example.json to $AuthConfigPath and edit it — it already ships "Warewolf_ClientApps": [] in GroupPermissions, and secure.config.example.json already ships the matching Execute=true row, so a daemon client works out of the box for the shipped example workflows.

2. Deploy the engine, then capture its identifiers

$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
Start-Transcript -Path "$LogDir\deploy-$ts.log" | Out-Null

.\Deploy-WwExecutionEngine.ps1 `
-ResourceGroup $ResourceGroup -Location $Location `
-StorageAccount $StorageAccount -AppName $AppName `
-PublishPath $PublishPath `
-AuthConfigPath $AuthConfigPath `
-SecureConfigPath $SecureConfigPath `
-WorkflowsSourcePath $WorkflowsSourcePath `
-LicenseConfigPath $LicenseConfigPath `
-EncryptResources:$true -VerifyDecryption `
-KeyVaultName $KeyVaultName -KeyVaultSecretName $KeyVaultSecretName `
-EnableAppInsights:$true `
-ExecutionLogLevel $ExecutionLogLevel `
-EnableElasticsearch:$false `
-LogDir $LogDir `
-PublishMethod Zip `
-NonInteractive

Stop-Transcript | Out-Null

# The engine's ResourceAppId == the Easy Auth (Entra) clientId on the Function App
$ResourceAppId = az webapp auth show -g $ResourceGroup -n $AppName `
--query 'identityProviders.azureActiveDirectory.registration.clientId' -o tsv
$EngineUrl = "https://$AppName.azurewebsites.net"

az webapp auth show‘s output shape varies by CLI extension version — if the query above returns blank, retry with a properties. prefix: --query 'properties.identityProviders.azureActiveDirectory.registration.clientId'.

3. Create the client caller Function App

$ClientRg = $ResourceGroup # or a separate resource group
$ClientStorage = '<client-storage-account>'
$ClientAppName = '<client-function-app-name>'

az group create --name $ClientRg --location $Location | Out-Null
az storage account create --name $ClientStorage --resource-group $ClientRg `
--location $Location --sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2 | Out-Null
az functionapp create --name $ClientAppName --resource-group $ClientRg `
--storage-account $ClientStorage --consumption-plan-location $Location `
--runtime dotnet-isolated --runtime-version 8 --functions-version 4 `
--https-only true --os-type Windows | Out-Null

Publish the AzureFunction client example to it with func azure functionapp publish $ClientAppName — it authenticates with DefaultAzureCredential, so no secret is ever stored on this app.

4. Register the client as a Daemon

This does the two things the authorization contract requires in one call: enables the client Function App’s system-assigned Managed Identity, and assigns it the Warewolf_ClientApps role on the engine.

.\Configure-WwExecutionAuth-ClientApps.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-Apps azurefunction `
-AzureFunctionClientAppName $ClientAppName `
-AzureFunctionClientResourceGroup $ClientRg `
-FunctionAppName $AppName -FunctionAppResourceGroup $ResourceGroup `
-NonInteractive

-AppRolesToAssign is not passed here — for the daemon path, the ClientApps orchestrator defaults it to Warewolf_ClientApps and fails loudly if that role doesn’t exist on the engine (i.e. if step 1 above was skipped). The lower-level, single-registration equivalent is:

.\Configure-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType Daemon -DaemonUseManagedIdentity `
-DaemonFunctionAppName $ClientAppName -DaemonFunctionAppResourceGroup $ClientRg `
-NonInteractive

Manual equivalent — raw Graph/az REST calls, useful for troubleshooting what the scripts do under the hood.

# Step 1 — enable the client's system-assigned Managed Identity and read its principalId
$CallerPrincipalId = az functionapp identity assign `
--name $ClientAppName --resource-group $ClientRg --query principalId -o tsv

# Step 2 — resolve the engine SP + the Warewolf_ClientApps app-role id, then assign it
$EngineSpId = az ad sp show --id $ResourceAppId --query id -o tsv
$AppRoleId = az ad sp show --id $ResourceAppId `
--query "appRoles[?value=='Warewolf_ClientApps'].id | [0]" -o tsv

if ([string]::IsNullOrWhiteSpace($AppRoleId)) {
throw "Warewolf_ClientApps app role not found on the engine. Add it to the auth config and re-deploy."
}

$body = @{ principalId = $CallerPrincipalId; resourceId = $EngineSpId; appRoleId = $AppRoleId } |
ConvertTo-Json -Compress
$bodyFile = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllText($bodyFile, $body, (New-Object System.Text.UTF8Encoding $false))

az rest --method POST `
--url "https://graph.microsoft.com/v1.0/servicePrincipals/$EngineSpId/appRoleAssignedTo" `
--headers "Content-Type=application/json" --body "@$bodyFile"

Remove-Item $bodyFile -Force

5. Point the client app at the engine, and test

az functionapp config appsettings set --name $ClientAppName --resource-group $ClientRg --settings `
"WwExecution:BaseUrl=$EngineUrl" `
"WwExecution:TenantId=$TenantId" `
"WwExecution:ResourceAppId=$ResourceAppId" | Out-Null
# WwExecution:Scope is optional — it defaults to api:///.default when unset.
# Leave WwExecution:ClientId / :ClientSecret unset in Azure — the Managed Identity handles auth.

curl "https://$ClientAppName.azurewebsites.net/api/run/Hello%20World?Name=FromDaemon"

A denial here almost always traces back to one of two places — re-check both before anything else: the client’s Managed Identity is missing the Warewolf_ClientApps role (re-run step 4), or secure.config has no matching per-workflow Execute=true row (re-check step 1).

6. Tear it all down

# Client registration / role assignment (safe to re-run)
.\Remove-WwExecutionAuth-Clients.ps1 -ResourceAppId $ResourceAppId -TenantId $TenantId -ClientType Daemon

# Engine deployment — preview by default, only removes what that run created
$summary = (Get-ChildItem "$LogDir\deploy-WwExecutionEngine-*.summary.json" |
Where-Object { $_.Name -notlike '*dryrun*' } | Sort-Object LastWriteTime | Select-Object -Last 1).FullName
.\Rollback-WwExecutionEngine.ps1 -SummaryPath $summary

Cleaning up

Remove-WwExecutionAuth-Clients.ps1 reverses everything the provisioning script created — revokes app-role assignments, removes delegated/application permission grants, then deletes the client service principals and app registrations. It is safe to re-run; already-removed resources are logged as skips, not errors.

# Preview (default — no changes)
.\Remove-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-DryRun -NonInteractive

# Remove only the daemon registration
.\Remove-WwExecutionAuth-Clients.ps1 `
-ResourceAppId $ResourceAppId -TenantId $TenantId `
-ClientType Daemon -NonInteractive

Troubleshooting

Symptom Likely cause Fix
401 on /secure/* or /services/* Token aud does not equal api://<ResourceAppId>. Confirm the client requested scope=api://<ResourceAppId>/.default (app-only) or /user_impersonation (delegated), and that ResourceAppId matches the engine’s app registration.
500 with a nested Error{…} body Engine authorization denial — a roleless caller, or a permission gap in secure.config. App-only: assign an app role with -AppRolesToAssign (e.g. Warewolf_ClientApps) and confirm admin consent. Both cases: add/verify a secure.config WindowsGroupPermissions row with Execute=true for that workflow.
AADSTS7000218 on device-code or interactive sign-in The registration lacks public-client flows. SPA/Console registrations set isFallbackPublicClient=true automatically; a Confidential (web) registration cannot use device-code — sign in through the browser instead.
consent_required The delegated permission has not been consented for the tenant. Re-run an interactive flow once, or have a tenant admin grant admin consent for user_impersonation.
SPA request blocked by the browser (CORS) The SPA’s origin is missing from the Function App’s Allowed Origins. Re-run Configure-WwExecutionAuth-Clients.ps1 -ClientType SPA with -FunctionAppName/-FunctionAppResourceGroup set (omit -SkipCorsConfiguration).
DefaultAzureCredential fails locally No Managed Identity available outside Azure. Run az login locally, or fall back to a client secret for local development only.
Daemon/Managed-Identity provisioning throws instead of completing -AppRolesToAssign resolved to no role on the resource app. Pass a group-role value that already exists (declared via Configure-WwExecutionAuth.ps1 -GroupPermissions), not a Permission.* name.

See also

All Scripts/*.ps1 and docs/*.md files referenced below ship inside the Warewolf Azure Execution Engine deployment scripts .zip, downloadable from warewolf.io/release-notes.php. The client example apps under Dev\Warewolf.Execution.Lightweight.ClientExamples ship separately in source.

  • Scripts/Configure-WwExecutionAuth-Clients.ps1 — per-type client registration script.
  • Scripts/Configure-WwExecutionAuth-ClientApps.ps1 — provisions and validates all six reference examples in one call; defaults -AppRolesToAssign to Warewolf_ClientApps on the daemon path.
  • Scripts/Remove-WwExecutionAuth-Clients.ps1 — cleanup.
  • Scripts/Get-WwExecutionToken-AllFlows.ps1 — every OAuth flow in one script.
  • docs/Deploy-EndToEnd-Runbook.md — the full copy-paste runbook this article’s worked example is drawn from, including engine deployment.
  • docs/Deploy-RunGuide.md — full engine-deploy parameter reference, role/privilege matrix, and encryption detail.
  • Dev\Warewolf.Execution.Lightweight.ClientExamples — the six runnable reference apps.
  • Configure-WwExecutionAuth.ps1 — provisions the engine’s Entra app + Easy Auth + interactive-user groups.
  • Security — secure.config — the permission model every role above resolves against.

 

FacebookTwitterLinkedInGoogle+Email
Updated on July 9, 2026

Was this article helpful?

Related Articles

Enjoying Warewolf?

Write a review on G2 Crowd
Stars