Azure federated identity credentials — operator runbook¶
Plan 115 — kills the long-lived ENTRA_CLIENT_SECRET from Key
Vault in favour of a short-lived JWT client assertion signed by our
private OIDC issuer. Microsoft Entra validates the JWT against a
federated identity credential (FIC) configured on the app
registration. Result: no persistent client secret on Secruna's side,
and a one-line answer to enterprise security review questions about
how we store Azure access.
This runbook walks the operator through:
- Provisioning the OIDC issuer keypair.
- Configuring the FIC on the Entra app registration (one-time, Azure Portal).
- Flipping the
ENTRA_USE_FEDERATED_IDENTITYflag. - Verifying with a connector re-auth.
- Rotation flow + 24h JWKS overlap.
- Rolling back / disabling FIC if something breaks.
Pre-requisites¶
- You are signed in as a Secruna platform admin (not just an
org admin — every endpoint under
/admin/setup/*requiresis_platform_admin). - The platform-level Entra app registration that powers the Azure /
M365 connectors is already in place. Its client id is in
ENTRA_CLIENT_ID; its object id (UUID, different from the client id) you will paste into theENTRA_APP_OBJECT_IDenv var in step 3. OIDC_ISSUER_URLis set on the cp-api Container App. The default ishttps://api.secruna.com/oidc. If your deployment runs under a different hostname, override the env var BEFORE provisioning the keypair so the discovery doc embeds the rightissuerclaim.
Step 1 — Provision the OIDC issuer keypair¶
Open /admin/setup in the dashboard. The 10th row is OIDC issuer
keypair (Plan 115). Status starts as not_set because the
cp-api default for OIDC_ISSUER_PRIVATE_KEY_PEM is empty.
Click Generate & provision. The endpoint
POST /admin/setup/oidc-issuer/provision-keypair will:
- Mint an RSA-2048 keypair via
cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key. - Serialise the private key to PKCS#8 PEM and write it to Key Vault
under the canonical name
oidc-issuer-private-key-pem. - Attach the KV reference to the cp-api Container App secret bag (idempotent — skips when the bag already carries it).
- Restart the latest cp-api revision so the new env var lands.
- Write a
platform.oidc_issuer.keypair_provisionedaudit row (with the KV version id + the public JWKSkidbut never the raw private key).
The response body carries public_key_jwks — the JWKS shape Entra
expects when configuring the FIC. The frontend logs it to the
browser console; copy it for the next step. Example:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "0a1b2c3d4e5f6a7b",
"n": "<base64url-encoded modulus>",
"e": "AQAB"
}
]
}
After ~30s the cp-api revision will be running with the new env var. Verify by curl'ing the public discovery + JWKS endpoints:
curl https://api.secruna.com/oidc/.well-known/openid-configuration
curl https://api.secruna.com/oidc/jwks
Both should return 200. Before provisioning they returned 503.
Recovery / split-brain: if a prior attempt died after the KV write but before the bag wiring landed, a repeat click takes the recovered path automatically and the response still carries the JWKS for the existing key — no manual cleanup needed.
Re-provisioning: clicking Generate & provision when the keypair is already wired returns
already_provisionedwith the existing JWKS. Use the Rotate affordance instead if you want to mint a fresh key — see step 5 below.
Step 2 — Configure the FIC on the Entra app registration¶
This is a one-time Azure Portal step. ~2 minutes.
- Open the Entra Portal and navigate
to App registrations → your platform app (whose client id is
in
ENTRA_CLIENT_ID). - In the left nav, open Certificates & secrets → Federated credentials → Add credential.
- Select Other issuer as the federated credential scenario.
- Fill in:
- Issuer URL:
https://api.secruna.com/oidc(matches yourOIDC_ISSUER_URLsetting from the pre-requisites). - Subject identifier: the app registration's Object ID
(under Overview → "Object ID"; UUID, NOT the client id). This
is the same value you will paste into
ENTRA_APP_OBJECT_IDin step 3 below. - Audience:
api://AzureADTokenExchange. This is fixed across all FIC token-exchange flows; Microsoft documents it as such. - Name: anything descriptive, e.g.
secruna-control-plane. - Save.
Verify by listing the app's federated credentials:
A row matching the issuer URL + subject + audience above should appear.
Step 3 — Flip the ENTRA_USE_FEDERATED_IDENTITY flag¶
The 11th row in /admin/setup is Entra federated identity. With
the keypair provisioned and the flag still off, the status is amber
(uses_dev_default) — Secruna is still using the legacy
client_secret path for token exchange.
Before flipping the flag, set ENTRA_APP_OBJECT_ID on the cp-api
Container App. Pin it via the env-var override of your choice
(typically the same channel you used for ENTRA_CLIENT_ID):
az containerapp update \
--resource-group <rg> \
--name <cp-api-app-name> \
--set-env-vars "ENTRA_APP_OBJECT_ID=<object-id-uuid>"
Then in the dashboard, click Enable federated identity on the
Entra federated identity row. The endpoint
POST /admin/setup/entra-fic/enable will:
- Verify the OIDC issuer keypair is provisioned (409 with a helpful message if not).
- Patch the cp-api Container App template, setting the
ENTRA_USE_FEDERATED_IDENTITYenv var totrue. - Restart the latest cp-api revision so the new env var lands.
- Write a
platform.entra_fic.toggledaudit row with the previous value and the new value.
After ~30s, re-fetch /admin/setup/checklist. The row should now
read provisioned (green badge).
Step 4 — Verify with a connector re-auth¶
In a customer tenant (or your own dev tenant), trigger a
re-authorisation of an Azure or M365 connection from the
/connections page. The connection should land in active state
without contacting our ENTRA_CLIENT_SECRET — the cp-api logs
should show acquire_azure_access_token taking the FIC branch
(client_assertion POST to Entra).
Once successful, the /connections/[id] detail page renders a small
emerald Auth: Federated identity badge next to the provider chip
— this is the customer-visible signal that we no longer hold a
long-lived secret for that connector.
If the re-auth fails:
AADSTS70021: No matching federated identity record found→ the FIC configuration in step 2 is missing or has a typo. Verify Issuer / Subject / Audience match the values above exactly.AADSTS50027: Invalid JWT token→ the OIDC discovery / JWKS endpoint isn't reachable from Microsoft. Verifyhttps://api.secruna.com/oidc/jwksis publicly reachable; check Cache-Control headers haven't been overridden by an intermediary.AADSTS50012: Invalid client secret→ the cp-api is still on the legacy path. ConfirmENTRA_USE_FEDERATED_IDENTITY=trueis set on the running revision (az containerapp revision show ...) and that the cp-api restart in step 3 completed.
You can disable FIC at any time via the same row (the button now
reads Disable federated identity when the flag is on) to fall
back to the legacy client_secret path.
Step 5 — Rotation flow + 24h overlap¶
Click Rotate next to the OIDC issuer keypair row when:
- The compliance team asks for periodic rotation (90 days is a reasonable cadence).
- You suspect a compromise of the private key (cp-api KV access).
The endpoint POST /admin/setup/oidc-issuer/rotate-keypair:
- Reads the current PEM from KV.
- Writes the current PEM to a separate KV slot,
oidc-issuer-private-key-pem-previous. - Mints a fresh RSA-2048 keypair, overwrites the current slot.
- Attaches both KV refs to the cp-api Container App secret bag and restarts the revision.
- Writes a
platform.oidc_issuer.rotatedaudit row capturing the old + newkid(sha256 fingerprint of the public key PEM).
The JWKS endpoint at /oidc/jwks now serves both keys. Entra's
JWKS cache (~60 min) will pick up the new key on its next refresh,
but any in-flight token exchanges signed with the OLD key continue
to validate because the old kid is still in the JWKS. After 24h
you can run the rotation again to drop the previous key — the new
key becomes "current", the rotated-out key becomes "previous", and
the now-twice-rotated original ages out.
Why 24h? Microsoft documents Entra's JWKS cache as ~60 min, but real propagation across their fleet can take longer in practice. 24h is a conservative belt-and-braces window.
Step 6 — Roll back / disable FIC¶
If the FIC handshake breaks for any reason, click Disable
federated identity on the same row in /admin/setup. The endpoint
flips ENTRA_USE_FEDERATED_IDENTITY back to false; the cp-api
revision restart picks up the legacy client_secret path within
~30s. The audit row records the toggle.
The
ENTRA_CLIENT_SECRETstays in Key Vault as long as the FIC flag is potentially toggle-able. Plan 115 explicitly does NOT delete the secret — that's a future cleanup PR once the FIC path has soaked for ~1 week in prod and the rollback fallback is no longer needed.
Reference — KV secret names¶
| Purpose | KV secret name |
|---|---|
| Current OIDC issuer private key | oidc-issuer-private-key-pem |
| Previous OIDC issuer private key (24h overlap) | oidc-issuer-private-key-pem-previous |
| Legacy Entra confidential client secret | entra-client-secret |
Reference — env vars on cp-api¶
| Env var | Default | Purpose |
|---|---|---|
OIDC_ISSUER_URL |
https://api.secruna.com/oidc |
Embedded in the discovery doc + the JWT iss claim. |
OIDC_ISSUER_PRIVATE_KEY_PEM |
"" |
PKCS#8 PEM. Bound from KV via the setup endpoint. |
OIDC_ISSUER_PREVIOUS_PRIVATE_KEY_PEM |
"" |
Rotation overlap slot. |
ENTRA_USE_FEDERATED_IDENTITY |
false |
When true, Azure / M365 token exchange uses the FIC path. |
ENTRA_APP_OBJECT_ID |
"" |
App registration object id used as the FIC sub claim. |