Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,26 @@ jobs:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Test certificate setup without Azure access
- name: Test EPP setup without Azure access
shell: pwsh
run: ./setup/tests/Certificates.Tests.ps1
run: |
./setup/tests/Certificates.Tests.ps1
./setup/tests/FrontDoor.Tests.ps1
- name: Compile Bicep without deploying
shell: pwsh
run: |
az bicep install
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
az bicep build --file (Join-Path $env:GITHUB_WORKSPACE 'setup/infra/main.bicep') --outfile (Join-Path $env:RUNNER_TEMP 'epp-main.json')
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
foreach ($name in @('main', 'frontdoor', 'frontdoor-regions')) {
az bicep build --file (Join-Path $env:GITHUB_WORKSPACE "setup/infra/$name.bicep") --outfile (Join-Path $env:RUNNER_TEMP "epp-$name.json")
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Test service plans without Azure access
shell: pwsh
run: ./setup/tests/ServicePlans.Tests.ps1 -TemplatePath (Join-Path $env:RUNNER_TEMP 'epp-main.json')
run: |
./setup/tests/ServicePlans.Tests.ps1 `
-TemplatePath (Join-Path $env:RUNNER_TEMP 'epp-main.json') `
-FrontDoorTemplatePath (Join-Path $env:RUNNER_TEMP 'epp-frontdoor-regions.json')

javascript:
name: JavaScript (Node.js)
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ For implementation details, configuration, packaging, and security behavior, see
| Option | Onboarding |
|---|---|
| Single region | Follow the steps below. `Setup-Epp.ps1` deploys one endpoint. |
| Multiple regions behind Azure Front Door | Follow the [manual guide](docs/FRONTDOOR.md). You'll need to configure the regions and implement a readiness endpoint yourself. We don't provide a Front Door setup script. |
| Multiple regions behind Azure Front Door | Use [JavaScript EP1 expansion](docs/FRONTDOOR.md#scripted-javascript-expansion) from an existing deployment, or follow the manual guidance for other configurations. Policy activation stays manual. |

## What you will set up

Expand Down Expand Up @@ -46,7 +46,7 @@ Choose a region with capacity and subscription quota for the selected Linux FC1
Application Insights provides operational telemetry. Provider API keys stay in Key Vault; supported
OAuth integrations use managed identity. The guided steps below cover the single-region topology
shown above. For one public URL backed by multiple regional origins, see the
[manual Front Door option](docs/FRONTDOOR.md), including the request failures seen during testing.
[Front Door option](docs/FRONTDOOR.md), including setup requirements and the request failures seen during testing.

## Before you start

Expand Down Expand Up @@ -236,7 +236,7 @@ application behavior or configuration details, use the technical documentation b

## More documentation

- [Optional manual Azure Front Door onboarding](docs/FRONTDOOR.md) - regional setup, readiness, security, and test results. No deployment script is provided.
- [Optional Azure Front Door onboarding](docs/FRONTDOOR.md) - JavaScript expansion, manual alternatives, readiness, security, and test results.
- [Setup guide](setup/docs/README.md) - permissions, deployment prompts, validation, and manual rollback.
- [Technical reference](TECHNICAL.md) - configuration, packages, provider behavior, and security.
- [Detailed configuration and validation](docs/ONBOARDING.md) - local development and deployment checks.
Expand Down
8 changes: 4 additions & 4 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
For the single-region customer deployment flow, start with the
[main onboarding guide](README.md).

Azure Front Door is an [optional manual multi-region design](docs/FRONTDOOR.md), not a feature
enabled by the single-region setup script. Its additional readiness endpoint is not shipped in
the release packages, and regional failover does not change the SendOtp contract.
Azure Front Door is an [optional multi-region design](docs/FRONTDOOR.md) with a separate
JavaScript/EP1 expansion script. It reuses the regional Function Bicep module and adds opt-in
readiness to the copied package. Regional failover does not change the SendOtp contract.

A provider-agnostic **OTP-delivery Azure Function** sample, implemented across multiple languages.
Each language folder is a self-contained implementation of the **same design and the same
Expand Down Expand Up @@ -278,7 +278,7 @@ authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package

- **[README.md](README.md)**: single-region customer onboarding.
- **[docs/ONBOARDING.md](docs/ONBOARDING.md)**: detailed configuration, security, deployment, and validation.
- **[docs/FRONTDOOR.md](docs/FRONTDOOR.md)**: optional manual multi-region onboarding and observed failover limitations.
- **[docs/FRONTDOOR.md](docs/FRONTDOOR.md)**: optional scripted/manual multi-region onboarding and observed failover limitations.
- **[docs/CONTRACT.md](docs/CONTRACT.md)**: the language-agnostic contract every implementation follows.
- **[Application logs](docs/CONTRACT.md#application-logs)**: separate service events, per-request summaries,
and the meaning of Microsoft, Function and provider identifier fields.
Expand Down
119 changes: 102 additions & 17 deletions docs/FRONTDOOR.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,113 @@
# Optional multi-region onboarding with Azure Front Door

Use Azure Front Door when you want **one public EPP URL backed by Function Apps in multiple regions**.
You configure this option manually. For a single region, start with the
You can expand a supported JavaScript deployment with the [PowerShell setup below](#scripted-javascript-expansion),
or follow the manual steps for other configurations. For a single region, start with the
[main onboarding guide](../README.md).

**We don't provide a Front Door deployment script.** The guided setup creates one regional endpoint,
not the multi-region setup described here. This guide covers what you need to configure and test.
Failover can still interrupt requests.
The original `Setup-Epp.ps1` still creates one regional endpoint. The separate
[Setup-EppFrontDoor.ps1](../setup/Setup-EppFrontDoor.ps1) creates new regional copies behind Front Door
and leaves the source endpoint and authentication policy unchanged. Failover can still interrupt requests.

## Scripted JavaScript expansion

Run from a reviewed repository checkout. [frontdoor-regions.bicep](../setup/infra/frontdoor-regions.bicep)
reuses the existing [Function module](../setup/infra/resources.bicep); single-region defaults stay unchanged.

### Supported source and prerequisites

| Requirement | Supported configuration |
|---|---|
| Tools | PowerShell **7.4+**, Azure CLI and Bicep, with an Azure user signed into the source tenant/subscription. Tools are not installed and the CLI default is not changed. |
| Source | **Linux JavaScript EP1**, private Blob run-from-package, system-assigned package identity. FC1, remote-build, Python, and .NET sources are rejected. |
| Caller trust | HTTPS Easy Auth, the selected tenant's issuer, a **v2 application-ID audience**, and a nonempty caller allowlist. Additional principal/claim restrictions are rejected, not dropped. |
| Certificate | Enabled, exportable RSA PEM certificate `phone-provider-encryption`. The source must pin its latest backing-secret version, with over 30 days remaining. No key rotation or registration is performed. |
| Permissions | Read source settings/package; back up its certificate and any provider secrets; deploy resources and scoped roles. No Graph or policy permissions are granted. |
| Regions | Two or three distinct regions in the **source vault's subscription and Azure geography**, with sufficient EP1 quota and all required services. |
| Provider | A supported API-key setup profile with credentials in the source certificate vault. OAuth federation is not automated. Use **`-EvaluationOnly`** for OAuth or unconfigured sources; it omits provider configuration and cannot deliver live messages. |

### Deploy

From the repository root:

```powershell
.\setup\Setup-EppFrontDoor.ps1 `
-SubscriptionId '<subscription-id>' `
-TenantId '<tenant-id>' `
-SourceResourceGroup '<existing-epp-resource-group>' `
-SourceFunctionApp '<existing-epp-function>' `
-ResourcePrefix 'myfront' `
-Locations @('centralus', 'westus2') `
-EvaluationOnly
```

Replace the placeholders. The prefix must be 3-10 lowercase letters/digits, starting with a letter.
The script inspects the source and downloads its package before asking for approval. Review the plan
and charges, then type `Yes`. Unattended runs require both `-NonInteractive -ApproveDeployment`.

Setup creates Front Door Standard and **new regional copies**, leaving the source outside the origin
group. It adds opt-in readiness to a copy of the ZIP without changing delivery files or dependencies,
records both package hashes, and publishes identical bytes to private regional storage. Certificate
and API-key credentials use encrypted Key Vault backups; incompatible existing copies are not overwritten.

Before completing routes, setup checks authentication, ingress restrictions, Function registration,
and resolved key references. Outputs default to the ignored `artifacts/frontdoor` directory:
`frontdoor-state.json`, the public certificate, and the reviewed ZIP, never plaintext private keys
or provider credentials. Use `-OutputDirectory` for a different location and keep it out of source control.
Front Door initially returned 404 during our deployment's propagation. **Do not activate policy based
on ARM success alone.**

### Verify and resume

Deployment still requires **authenticated validation**. Obtain a token through your approved caller
process with the source's tenant, audience, and allowed caller, not a Function key or ARM token.

```powershell
$token = Read-Host 'Approved EPP caller access token' -AsSecureString
try {
.\setup\Setup-EppFrontDoor.ps1 -Verify -AccessToken $token
}
finally {
$token.Dispose()
}
```

`-Verify` tests the saved HTTPS endpoint on port 443 without changing Azure resources: two rejected-token checks and
three encrypted evaluations, with sanitized results and correlation IDs. It sends no SMS/voice,
stops no origins, and does not prove each origin participated. Run the
[per-origin and failover checks](#5-validate-before-manually-activating-policy) separately.

Resume with the **same source, arguments, prefix, and output directory**. For an earlier run, pass
its original `-OutputDirectory` explicitly rather than moving or recreating its state. Setup checks the saved
package and source/configuration fingerprint. Source changes require review. Atomic checkpoints
retain a `.previous` copy; inspect it and Azure state before recovering a damaged checkpoint.
Never delete state to bypass ownership checks.

Reruns can close **new-origin ingress** while republishing, so schedule maintenance for serving
deployments. Failures attempt to close every target origin, even if deployment failed before saving
regional outputs. Cleanup failures are reported without replacing the original deployment error.
Resources are not deleted and remain billable. The source endpoint and policy stay unchanged.

## Scope and observed behavior

We tested an isolated JavaScript deployment with Front Door Standard, two and three regional
origins, a shared encryption certificate, and a separate readiness handler that sends no messages.
The tests covered encrypted evaluation requests, rejection of invalid callers, direct-origin
access restrictions, and stopping and restarting individual Functions.

**The readiness handler used in testing is not part of the released sample.** You must implement
and validate the [readiness contract below](#3-provide-a-non-delivering-readiness-endpoint) before
enabling multi-origin health probes. There is no app setting that adds this endpoint to a release ZIP.
You can adapt the design for other runtimes, but we haven't tested it with .NET or Python.

We **did not test** live SMS/voice delivery, calls and retries from the real Entra service, a full
Azure regional outage, custom domains, WAF, Private Link, or production load. Test the features your
deployment needs before activating policy.
The tests covered encrypted evaluation, invalid callers, direct-origin access restrictions, and
stopping and restarting individual Functions. Scripted expansion was tested in evaluation-only
mode in Central US and West US 2, including malformed/tampered requests, key/package continuity,
unchanged source settings, and resuming after an interrupted certificate restore.

The JavaScript readiness handler is included in this source revision and enabled only when
`EPP_FRONT_DOOR_HEALTH_ENABLED` is exactly `true`. The expansion script inserts it into the copied
source package before enabling it. An older release ZIP might not contain the handler, so setting
the flag alone is insufficient. Manual deployments must meet the
[readiness contract below](#3-provide-a-non-delivering-readiness-endpoint).
We haven't tested an equivalent .NET or Python handler.

We **did not test** provider-secret cloning, live SMS/voice delivery, OAuth federation, calls and
retries from the real Entra service, a full Azure regional outage, custom domains, WAF, Private Link,
or production load. Test the features your deployment needs before activating policy; the observed
failover results below are not a performance guarantee.

## Architecture: one URL, multiple origins

Expand Down Expand Up @@ -127,8 +212,8 @@ endpoint would test authentication failure rather than application readiness.
Implement a separate route such as **`/api/health/ready`** with this contract:

- Support HTTPS `HEAD` and `GET`; return no response body for `HEAD`.
- Return `200` only when the handler is running and the configured RSA decryption key is usable.
Return a generic `503` when not ready.
- Return `200` only when the handler is running and the configured RSA decryption key is at least
2048 bits and usable with RSA-OAEP-256. Return a generic `503` when not ready.
- Do not send an OTP or call a phone provider. Do not return a nonce, keys, credentials, or detailed
configuration. Use `Cache-Control: no-store`.
- If the route requires an Easy Auth exemption, exempt **only that exact readiness path**.
Expand Down
6 changes: 3 additions & 3 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ Provider (EPP) Function. Choose one language:
Use [CONTRACT.md](CONTRACT.md) for the full request contract and production limitations.

For a single public URL backed by multiple regions, see the
[manual Front Door guide](FRONTDOOR.md). You'll need to implement readiness checks, keep encryption
keys consistent across regions, restrict origin access, and test the deployment. This onboarding
flow doesn't automate those steps.
[Front Door guide](FRONTDOOR.md). The separate expansion script supports JavaScript on EP1 and
copies the source package and key into new origins. Other configurations need manual setup.
This single-region onboarding flow does not enable Front Door.

1. **Purchase a provider offer from Security Store.**

Expand Down
8 changes: 5 additions & 3 deletions javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ each provider owns its credentials, wire request, response interpretation, outco

## Setup

For multiple regional origins behind one URL, see [manual Front Door onboarding](../docs/FRONTDOOR.md).
The JavaScript evaluation trials used a separate readiness handler; it is not included in this
sample's release package. No Front Door deployment script is supplied.
For multiple regional origins behind one URL, see [Front Door onboarding](../docs/FRONTDOOR.md).
The separate JavaScript/EP1 expansion script reuses an existing deployment. This revision's
readiness handler is registered only when `EPP_FRONT_DOOR_HEALTH_ENABLED` is exactly `true`;
older release packages need the handler added before enabling the flag. Single-region behavior
is unchanged when the flag is absent.

1. Follow [customer onboarding](../docs/ONBOARDING.md). Choose a bundled provider and set
`EPP_PROVIDER_NAME` to its fixed id; `<provider-id>` is a placeholder, not a default.
Expand Down
70 changes: 70 additions & 0 deletions javascript/src/functions/health.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use strict';

const { app } = require('@azure/functions');
const crypto = require('node:crypto');
const {
createRequestContext,
extendRequestContext,
requestFailed,
requestCompleted,
} = require('./logging');

let cachedPem;
let cachedReady = false;

function isKeyReady(pem) {
if (pem === cachedPem) return cachedReady;
cachedPem = pem;
cachedReady = false;
try {
if (!pem) return false;
const normalized = pem.includes('-----BEGIN')
? pem : Buffer.from(pem, 'base64').toString('utf8');
const key = crypto.createPrivateKey(normalized);
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) return false;

// Exercise the JWE key-unwrapping algorithm with a synthetic AES-256 key.
const plaintext = Buffer.alloc(32);
const encrypted = crypto.publicEncrypt({
key: crypto.createPublicKey(key),
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
}, plaintext);
cachedReady = crypto.privateDecrypt({
key,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
}, encrypted).equals(plaintext);
} catch {
// Parser and crypto errors can contain configuration or key material.
cachedReady = false;
}
return cachedReady;
}

if (process.env.EPP_FRONT_DOOR_HEALTH_ENABLED === 'true') {
app.http('FrontDoorHealth', {
route: 'health/ready',
methods: ['GET', 'HEAD'],
authLevel: 'anonymous',
handler: async (request, azureContext) => {
const logContext = extendRequestContext(
createRequestContext(azureContext, crypto.randomUUID()),
{ functionName: 'FrontDoorHealth' },
);
const ready = isKeyReady(process.env.EPP_DECRYPTION_KEY_PEM);
const status = ready ? 200 : 503;
if (!ready) {
requestFailed(logContext, 'readiness', 'key_unavailable', status);
}
requestCompleted(logContext, status, ready ? 'ready' : 'not_ready');
return {
status,
headers: { 'Cache-Control': 'no-store' },
...(request.method === 'HEAD' ? {} : {
jsonBody: { status: ready ? 'ready' : 'not_ready' },
}),
};
},
});
}
Loading
Loading