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
.zipso that the scripts land inD:\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:
- Authentication (token side) — the access token’s
audclaim must equalapi://<ResourceAppId>. Easy Auth validates this before the request reaches the engine; a mismatched audience is rejected before your code ever runs. - Authorization (policy side) — the caller must carry a role the engine recognises for that workflow. Permissions are resolved from
secure.configat 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.
Daemon — AzureFunction / AzureServiceBus, with Managed Identity (recommended)
# 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.
- Deploy or identify the Function App, then enable its Managed Identity and assign it the
Warewolf_ClientAppsrole 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>. - Set these on
local.settings.json(dev) or the Function App’s Application Settings (Azure):
Setting Value WwExecution:BaseUrlEngine URL, e.g. https://WWExecutionEngine.azurewebsites.netWwExecution:TenantIdEntra tenant GUID WwExecution:ResourceAppIdEngine’s app-registration client ID WwExecution:ScopeBlank → defaults to api://{ResourceAppId}/.defaultWwExecution:ScheduledWorkflowWorkflow the timer trigger calls (default Hello World)WwExecution:FunctionKeyOnly if calling /services/*WwExecution:ClientId/:ClientSecretMSAL fallback for local dev only — leave blank to use az loginAZURE_CLIENT_IDSet only for a user-assigned MI in Azure; omit for system-assigned - Run locally with
az loginthenfunc start, or deploy and let the Managed Identity handle auth. Test withcurl "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.
- 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>. - Configuration (
local.settings.json/ app settings), bound from theWwExecutionsection:
Key Required Purpose WwExecution:BaseUrlyes Engine base URL WwExecution:TenantIdyes Entra tenant id WwExecution:ResourceAppIdyes Engine app-registration id WwExecution:Scopeno Defaults to api://{ResourceAppId}/.defaultWwExecution:FunctionKeyno Required only for /services/*WwExecution:UseClientSecretFallbackno trueto useClientId/ClientSecretlocally instead of Managed IdentityWwExecution:ManagedIdentityClientIdno Set for a user-assigned identity ServiceBusConnectionyes SB connection string, or the …__fullyQualifiedNamespaceform for identity-based SB auth - 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.
- 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') - 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_ClientAppsapp role; delegated flows (device-code, interactive) instead need the signed-in user assigned to the engine’s enterprise application (Users and groups) withuser_impersonationconsented. A token with the rightaudbut no role/assignment is still denied. - Edit
appsettings.json(or override viaWWEXEC_-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"
}
}
Preferdotnet user-secrets set "WwExecution:ClientSecret" "<secret>"over committing it. dotnet run, then pick a flow from the menu (1–4 call/securewith 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.
- 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 delegateduser_impersonationwith admin consent. - 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.
- 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>" dotnet run, browse tohttps://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.
- 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 causesAADSTS9002326at token redemption), grants delegateduser_impersonation, and — unless-SkipCorsConfigurationis passed — addshttp://localhost:5173to the engine Function App’s CORS allowed origins so the browser’s cross-originAuthorizationheader isn’t blocked. - Assign each test user an app role on the engine’s enterprise application, same as DotNetWebMvc above — delegated tokens are denied without one.
- Copy the example env file and fill in your values — only
VITE_-prefixed variables are exposed to the browser:
cp .env.example .env.localVariable Description VITE_TENANT_IDDirectory (tenant) ID VITE_SPA_CLIENT_IDThis SPA’s client ID VITE_RESOURCE_APP_IDEngine API’s client ID VITE_FUNCTION_APP_URLEngine base URL, no trailing slash npm install && npm run dev, openhttp://localhost:5173, click Sign in with Microsoft, then callHello Worldagainst/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://
# 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/*.ps1anddocs/*.mdfiles referenced below ship inside the Warewolf Azure Execution Engine deployment scripts.zip, downloadable from warewolf.io/release-notes.php. The client example apps underDev\Warewolf.Execution.Lightweight.ClientExamplesship 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-AppRolesToAssigntoWarewolf_ClientAppson 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.




