Przejdź do treści

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:

  1. Provisioning the OIDC issuer keypair.
  2. Configuring the FIC on the Entra app registration (one-time, Azure Portal).
  3. Flipping the ENTRA_USE_FEDERATED_IDENTITY flag.
  4. Verifying with a connector re-auth.
  5. Rotation flow + 24h JWKS overlap.
  6. 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/* requires is_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 the ENTRA_APP_OBJECT_ID env var in step 3.
  • OIDC_ISSUER_URL is set on the cp-api Container App. The default is https://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 right issuer claim.

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:

  1. Mint an RSA-2048 keypair via cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key.
  2. Serialise the private key to PKCS#8 PEM and write it to Key Vault under the canonical name oidc-issuer-private-key-pem.
  3. Attach the KV reference to the cp-api Container App secret bag (idempotent — skips when the bag already carries it).
  4. Restart the latest cp-api revision so the new env var lands.
  5. Write a platform.oidc_issuer.keypair_provisioned audit row (with the KV version id + the public JWKS kid but 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_provisioned with 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.

  1. Open the Entra Portal and navigate to App registrations → your platform app (whose client id is in ENTRA_CLIENT_ID).
  2. In the left nav, open Certificates & secretsFederated credentialsAdd credential.
  3. Select Other issuer as the federated credential scenario.
  4. Fill in:
  5. Issuer URL: https://api.secruna.com/oidc (matches your OIDC_ISSUER_URL setting from the pre-requisites).
  6. 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_ID in step 3 below.
  7. Audience: api://AzureADTokenExchange. This is fixed across all FIC token-exchange flows; Microsoft documents it as such.
  8. Name: anything descriptive, e.g. secruna-control-plane.
  9. Save.

Verify by listing the app's federated credentials:

az ad app federated-credential list --id <client-id> -o table

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:

  1. Verify the OIDC issuer keypair is provisioned (409 with a helpful message if not).
  2. Patch the cp-api Container App template, setting the ENTRA_USE_FEDERATED_IDENTITY env var to true.
  3. Restart the latest cp-api revision so the new env var lands.
  4. Write a platform.entra_fic.toggled audit 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. Verify https://api.secruna.com/oidc/jwks is 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. Confirm ENTRA_USE_FEDERATED_IDENTITY=true is 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:

  1. Reads the current PEM from KV.
  2. Writes the current PEM to a separate KV slot, oidc-issuer-private-key-pem-previous.
  3. Mints a fresh RSA-2048 keypair, overwrites the current slot.
  4. Attaches both KV refs to the cp-api Container App secret bag and restarts the revision.
  5. Writes a platform.oidc_issuer.rotated audit row capturing the old + new kid (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_SECRET stays 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.