Skip to content

Authentication Plan

Current State (post Arjan refactor, as of 2026-05-05)

Surface Auth Mechanism
Frontend (React SPA) None (public) nginx serves static files
Backend /api/admin/* Static bearer token (API_ADMIN_TOKEN) HTTPBearer + secrets.compare_digest in api/routers/admin.py:32
Backend public routes None Open
UIGen token endpoint No auth → guest token; valid admin bearer → echoes token back api/routers/uigen.py — DB-backed guest tokens (SHA-256 hashed in uigen_tokens table) with in-memory fallback
E2E tests EasyAuth bypass via X-ZUMO-AUTH header e2e/global-setup.ts does client_credentials flow

Arjan refactor changes (merged 2026-05-05) relevant to auth:

  • api/main.py is now a ~150-line create_app() factory; router wiring is in _register_routers()
  • api/core/config.py has Settings (pydantic-settings); new auth env vars go here
  • api/routers/admin.py has a standalone _require_admin dependency at module level — easy to swap
  • api/routers/uigen.py issues/validates guest tokens; admin path echoes bearer token — both need updating
  • api/db/models.py has uigen_tokens table; no users table exists yet
  • New file api/auth.py does NOT exist yet — to be created in Phase 1

Key observations:

  • Prod has enable_auth = false — no EasyAuth on any Container App in production.
  • Dev has enable_auth = true — EasyAuth gates frontend, UIGen, and docs.
  • Admin auth is a single static token in Key Vault (api-admin-token), not tied to any identity.
  • No user model, no user table, no multi-provider identity support.
  • The entra_app_client_id exists but is only used for EasyAuth on dev.

Audience Matrix

Audience Who Access Level
Admin OneDNA Entra tenant + portfolio-admins security group Full read/write on UIGen + /api/admin/*
Authenticated user Any IdP (Google, GitHub, Microsoft personal, …) via B2C — Phase 4 Contact form / CV download / CV generate with identity logging; optional personalised content
Anonymous Everyone else Frontend fully public; contact form, CV download and CV generate available without login

Key design decision: Contact form submission, CV download, and CV generate are public endpoints — no authentication required. Authentication is opt-in: if a user signs in, their identity is attached to logged actions (Phase 4). The "Sign in" UI entry point will be optional, never blocking.


Architecture Overview

Public frontend (React SPA)
  └── Fully public — no auth wall
  └── Optional "Sign in" for registered users (Phase 4: B2C)

Admin Modal (admin-modal.tsx)
  └── "Sign in with Microsoft" → MSAL.js PKCE flow → Entra ID
  └── Access token stored in sessionStorage via /auth endpoint on shim
  └── UIGen sends token as Authorization: Bearer on every API call

FastAPI backend (/api/*)
  └── Public endpoints: no auth required
  └── Admin endpoints (/api/admin/*): fastapi-azure-auth validates JWT + group membership
  └── User endpoints (future Phase 4): validate B2C token

UIGen shim proxy (port 4400)
  └── /auth (POST): write token to sessionStorage, redirect to UIGen
  └── /openapi-guest.json: serves filtered spec (GET only) for guest role
  └── Role detected from token claim → controls which spec UIGen fetches

Why fastapi-azure-auth (not EasyAuth) for Admin Routes

EasyAuth is a reverse-proxy layer on Container Apps — it gates the entire app or nothing. It cannot enforce group membership per-route. Since public GET routes must remain open while only /api/admin/* requires auth, group enforcement belongs inside FastAPI as a dependency. fastapi-azure-auth validates JWTs (signature, issuer, audience, expiry, groups) natively via FastAPI's dependency injection.


Token Flow — OneDNA Admin End-to-End

Browser                    nginx (frontend)           FastAPI backend          Entra ID
  |                            |                          |                      |
  |-- Click "Admin" ---------->|                          |                      |
  |-- "Sign in with Microsoft" →                          |                      |
  |   MSAL.js loginPopup()     |                          |                      |
  |<-- Redirect to login.microsoftonline.com ------------>|                      |
  |                            |                          |              (user signs in)
  |<-- Auth code redirect back to origin --------------------------------        |
  |-- MSAL.js exchanges code for access_token (direct to Entra, no backend) --->|
  |<-- JWT (aud=api://<backend-app-id>, groups claim) -------------------------- |
  |                            |                          |                      |
  |-- POST /admin/auth?token=<jwt> (shim)                 |                      |
  |   shim writes sessionStorage["uigen_auth"], redirects to UIGen               |
  |                            |                          |                      |
  |-- UIGen: Authorization: Bearer <jwt> → nginx → proxy_pass /api/ --------→   |
  |                            |                          |-- Validate JWT:       |
  |                            |                          |   signature (JWKS)    |
  |                            |                          |   audience, issuer    |
  |                            |                          |   expiry, groups     |
  |                            |                          |-- 200 OK / 403        |

Token details:

  • Type: OAuth 2.0 access token (JWT), Entra ID v2.0
  • Audience: api://<backend-app-registration-client-id>
  • Scopes: api://<backend-app-registration-client-id>/.default
  • Claims used: groups (array of security group object IDs), oid, preferred_username
  • Lifetime: 60–75 min; MSAL.js handles silent refresh

Layer 1 — Frontend (public, no change needed)

The React SPA remains fully public. No EasyAuth wall.

Phase 4 enhancement: add a "Sign in" button triggering B2C OIDC flow for registered users.


Layer 2 — Admin API (/api/admin/*)

FastAPI changes

  1. Add fastapi-azure-auth to requirements.txt / pyproject.toml.
  2. Create api/auth.py:
  3. SingleTenantAzureAuthorizationCodeBearer instance for OneDNA Entra tokens
  4. require_admin_group() dependency: checks groups claim against ENTRA_ADMIN_GROUP_ID
  5. Handles group-claim overflow: if user is in >200 groups, Entra omits groups claim — fall back to Microsoft Graph memberOf call (requires GroupMember.Read.All app permission)
  6. Update api/routers/admin.py: replace _require_admin (static token) with require_admin_group. Keep static token as fallback via LEGACY_ADMIN_TOKEN env var during transition (remove after 2 weeks in prod).
  7. Update api/routers/uigen.py: accept Entra ID JWTs alongside existing guest tokens.
  8. Update api/main.py: add BACKEND_APP_CLIENT_ID, AZURE_TENANT_ID, ENTRA_ADMIN_GROUP_ID to Settings; initialize fastapi-azure-auth at startup (fetches JWKS keys).
  9. Add GET /openapi-guest.json endpoint: filter full spec to only GET operations (no security requirements).

Entra app registration (manual — Bicep cannot create app registrations)

  • Create a new app registration for the backend API — do NOT reuse the existing EasyAuth one (that's a confidential client with a client secret; the backend API app should expose a scope instead).
  • Expose API: api://<client-id>, add scope admin.
  • Enable "groupMembershipClaims": "SecurityGroup" in the app manifest.
  • Add API permissions: openid, profile, email (delegated).
  • Add redirect URIs for MSAL.js:
  • https://www.sven-relijveld.com/admin/auth/callback
  • https://admin.sven-relijveld.com/auth/callback
  • http://localhost:4400/auth/callback

Entra security group (manual)

  • Create portfolio-admins group in OneDNA tenant.
  • Add admin user(s).

Layer 3 — Admin Modal (MSAL.js, replaces static token input)

Frontend changes

  1. Add @azure/msal-browser to package.json.
  2. Create client/src/auth/msal-config.ts — MSAL config with Entra authority + scopes.
  3. Update client/src/components/admin-modal.tsx:
  4. Replace "Enter admin token" text input with "Sign in with Microsoft" button.
  5. On click: msal.loginPopup() → acquire access token silently → call shim POST /auth with token.
  6. Keep "Continue as Guest" button (unchanged).

Shim proxy security improvement

The current shim passes the token as a URL query parameter (GET /auth?token=<jwt>). JWTs can be ~1 KB and leak via referer headers, browser history, and server logs. The shim /auth endpoint should accept POST with the token in the request body, set an HTTP-only session cookie, then redirect to UIGen.


Layer 4 — UIGen Guest Read-Only Mode

UIGen has no native concept of read-only mode. The cleanest approach is serving separate OpenAPI specs per role:

  • Admin: /openapi.json — full spec including all POST/PUT/PATCH/DELETE under /api/admin/*
  • Guest: /openapi-guest.json — filtered copy, only GET endpoints remain

UIGen renders only what the spec contains. If there are no POST operations, there are no create buttons. The shim proxy intercepts UIGen's spec fetch and serves the appropriate version based on the token role claim.

Risk: UIGen may cache the spec on first load. If so, switching roles requires a page reload. Test this before considering the feature complete.


Layer 5 — Local Development

No local EasyAuth equivalent

Azure EasyAuth is a Container Apps platform-level proxy — it only runs inside Azure. It cannot be replicated in docker-compose. There is no direct equivalent.

  1. For admin auth: fastapi-azure-auth validates JWTs regardless of environment. Obtain a token locally via Azure CLI (az account get-access-token --resource api://<client-id>) or MSAL.js browser flow. The backend validates the same JWT locally as in Azure.

  2. For dev convenience: Add DISABLE_AUTH=true env var (only honoured when ENVIRONMENT != prod) that makes the admin dependency return a mock principal. Mirrors the existing optional API_ADMIN_TOKEN pattern.

  3. docker-compose.yml: Add BACKEND_APP_CLIENT_ID, AZURE_TENANT_ID, ENTRA_ADMIN_GROUP_ID to the backend service environment. No mock OIDC container needed for Phase 1.

  4. For E2E Playwright: Update global-setup.ts to obtain a token against the new backend API app registration using client_credentials (requires Application permission configured on the app registration). Different test identities: admin (in group), guest (not in group).


Phase 6 — Public User Sign-up (Azure AD B2C, lowest priority)

For public self-signup with Google / GitHub / Microsoft personal accounts:

  1. Create an Azure AD B2C tenant (separate from OneDNA tenant; billing tied to Azure subscription).
  2. Configure user flows (sign-up/sign-in) with social IdPs (Google, GitHub, Microsoft personal).
  3. Expose a B2C-scoped API scope; add B2C token validation to api/auth.py (second authority).
  4. Alembic migration: users table (id, b2c_subject, display_name, email, identity_provider, role, timestamps).
  5. Add api/routers/auth.py: POST /api/auth/profile (upsert), GET /api/auth/me.
  6. Add @azure/msal-react to frontend; create sign-in button + user profile components.
  7. Update nav bar with "Sign in" / avatar.

B2C cost: Free for first 50,000 MAU/month — well within portfolio traffic.

Alternative if B2C complexity is not justified: Defer user sign-up entirely; or use a simple magic-link email auth with no external IdP. Decide before starting Phase 6.


Implementation Phases

Phase 1 — Admin JWT auth (replaces static token)

Entra ID prerequisites — automatable via Microsoft Graph Bicep extension:

Microsoft Graph Bicep extension (GA, requires Bicep ≥ v0.36.1, Azure CLI ≥ v2.73.0) supports Microsoft.Graph/applications, Microsoft.Graph/groups, and nested federatedIdentityCredentials — all 4 items below can be Bicep-managed. Deployment identity needs Application.ReadWrite.OwnedBy + Group.ReadWrite.All. See infra/modules/entra_app.bicep (to be created).

  • [ ] Create infra/modules/entra_app.bicep using Microsoft.Graph/applications@v1.0:
  • uniqueName: portfolio-backend-api
  • identifierUris: ['api://<client-id>']
  • api.oauth2PermissionScopes: scope admin with GUID + consent descriptions
  • groupMembershipClaims: 'SecurityGroup'
  • requiredResourceAccess: GroupMember.Read.All (Application permission for Graph fallback)
  • Redirect URIs for MSAL.js added here (Phase 2 prereq — can be set now)
  • [ ] Create portfolio-admins security group via Microsoft.Graph/groups@v1.0:
  • securityEnabled: true, mailEnabled: false
  • members.relationships: add admin user object IDs
  • [ ] Wire new Bicep module into infra/main.bicep; output client_id + group_id as params to container_apps module
  • [ ] Ensure deploying service principal has Application.ReadWrite.OwnedBy + Group.ReadWrite.All Graph permissions (add to OIDC app registration via portal or separate Bicep step)

Code changes:

  • [ ] Add fastapi-azure-auth to api/pyproject.toml (or requirements.txt — check which is used)
  • [ ] Create api/auth.py:
  • SingleTenantAzureAuthorizationCodeBearer instance
  • require_admin_group() dependency: checks groups claim vs ENTRA_ADMIN_GROUP_ID
  • DISABLE_AUTH bypass (only when ENVIRONMENT != prod) → returns mock principal
  • Graph memberOf fallback for >200 groups
  • [ ] Update api/core/config.py Settings: add backend_app_client_id, entra_admin_group_id, legacy_admin_token, disable_auth fields
  • [ ] Update api/routers/admin.py:59 router = APIRouter(...): swap _require_adminrequire_admin_group; add LEGACY_ADMIN_TOKEN fallback check inside require_admin_group
  • [ ] Update api/routers/uigen.py: replace secrets.compare_digest admin path with Entra JWT validation
  • [ ] Add GET /openapi-guest.json in api/main.py _register_routers (or as a direct route in create_app)
  • [ ] Call fastapi-azure-auth init in api/main.py startup block (after configure_logging())

Infrastructure changes:

  • [ ] Add backend_app_client_id + entra_admin_group_id params to infra/main.bicep
  • [ ] Pass through to infra/modules/container_apps.bicep as backend CA env vars
  • [ ] Add values to infra/parameters.dev.bicepparam + infra/parameters.prod.bicepparam
  • [ ] Update docker-compose.yml: add BACKEND_APP_CLIENT_ID, ENTRA_ADMIN_GROUP_ID, DISABLE_AUTH=true to backend service

Test + deploy:

  • [ ] Update tests/ — mock api.auth.require_admin_group where needed (existing tests mock _require_admin)
  • [ ] Deploy to dev, verify JWT validation works; then deploy to prod
  • [ ] Remove LEGACY_ADMIN_TOKEN fallback 2 weeks after prod deploy

Independently deployable: Yes. Public routes unaffected. UIGen guest mode unaffected.

Phase 2 — MSAL.js in Admin Modal (improves admin UX)

  • [ ] Add @azure/msal-browser to frontend
  • [ ] Create client/src/auth/msal-config.ts
  • [ ] Update admin-modal.tsx: replace token input with Microsoft sign-in button
  • [ ] Change shim /auth from GET to POST (token in body, HTTP-only cookie)
  • [ ] E2E test updates for MSAL flow

Independently deployable: Yes, but requires Phase 1 backend to validate the tokens.

Phase 3 — UIGen guest read-only mode (filtered OpenAPI spec)

  • [ ] Add GET /openapi-guest.json endpoint to FastAPI (may already be done in Phase 1)
  • [ ] Update UIGen shim proxy to serve guest spec when token role is "guest"
  • [ ] Test UIGen renders only GET operations with guest spec
  • [ ] Update .uigen/config.yaml if needed

Independently deployable: Yes. Falls back gracefully — guests see write buttons returning 401.

Phase 4 — Public user sign-up (B2C / Entra External ID)

See "Layer 5 — Public User Sign-up" above. Lowest priority; decide before starting.


Bicep / Infrastructure Changes

Microsoft Graph Bicep extension

The Microsoft Graph Bicep extension (GA, Bicep ≥ v0.36.1) enables managing Entra ID resources alongside Azure infrastructure in a single deployment. Supported resource types relevant to this project:

Resource type What it manages
Microsoft.Graph/applications@v1.0 App registration, scopes, groupMembershipClaims, redirect URIs
Microsoft.Graph/applications/federatedIdentityCredentials@v1.0 OIDC federated credentials
Microsoft.Graph/groups@v1.0 Security groups + member management
Microsoft.Graph/servicePrincipals@v1.0 Service principal for the app

Required extension declaration (add to infra/main.bicep or the new entra_app.bicep):

bicep
extension microsoftGraph

Deploying identity permissions needed (beyond existing Azure RBAC):

  • Application.ReadWrite.OwnedBy — create/update app registrations
  • Group.ReadWrite.All — create security groups and manage membership

These Graph permissions must be granted to the GitHub Actions OIDC service principal in the Entra portal (cannot be Bicep-bootstrapped without circular dependency).

New parameters (already implemented in Phase 1)

bicep
// infra/main.bicep — already added
param backend_app_client_id string = ''  // output from entra_app.bicep module
param entra_admin_group_id string = ''   // output from entra_app.bicep module

Both are passed through to infra/modules/container_apps.bicep as backend env vars. Not secrets — but kept out of bicepparam files in favour of Bicep module outputs.

Existing EasyAuth app registration

The existing entra_app_client_id is configured as a confidential client for EasyAuth. Do NOT reuse it as the backend API audience. Create a separate app registration that exposes an API scope.

Prod EasyAuth

Currently enable_auth = false in prod. Do not add EasyAuth to prod UIGen in Phase 1 — backend JWT validation is sufficient. Can be revisited as defence-in-depth later.


Security Considerations

  • Admin group object IDs must not be committed to source code — store in Key Vault
  • UIGen Bearer tokens expire after 1 hour — MSAL.js handles silent refresh
  • CORS: backend must whitelist admin.sven-relijveld.com + www.sven-relijveld.com for credential-bearing requests
  • CSP headers: update nginx config to allow login.microsoftonline.com frames for MSAL popup
  • Rate-limit /api/auth/* endpoints to prevent token stuffing
  • Tokens in URL query strings leak via referer headers / browser history — use POST for /auth

Open Questions

  1. Should guests be able to submit the contact form, or is that anonymous-only?
  2. Is Phase 4 (registered users / B2C) needed, or should it remain backlog indefinitely?
  3. Which social IdPs for Phase 4: Google only, or GitHub + Google?
  4. Should the frontend show a user avatar/name when signed in as a registered user?
  5. API_ADMIN_TOKEN deprecation timeline: 2 weeks after Phase 1 in prod?
  6. Add EasyAuth to prod UIGen Container App as defence-in-depth, or rely solely on backend JWT?

Critical Files

File Change Notes
api/auth.py New: fastapi-azure-auth config + require_admin_group dependency + Graph fallback Does not exist yet
api/core/config.py Add backend_app_client_id, azure_tenant_id (already exists), entra_admin_group_id, legacy_admin_token, disable_auth fields to Settings azure_tenant_id already present; api_admin_token stays as LEGACY_ADMIN_TOKEN
api/routers/admin.py:32 Replace _require_admin with require_admin_group from api/auth.py; keep LEGACY_ADMIN_TOKEN fallback Module-level router has dependencies=[Depends(_require_admin)] at line 59
api/routers/uigen.py Replace admin-token echo with Entra JWT validation path Both guest token (unchanged) and admin path need updating
api/main.py:70 Add GET /openapi-guest.json endpoint in _register_routers; call fastapi-azure-auth init at startup create_app() factory — add init after configure_logging() block
infra/modules/container_apps.bicep Add BACKEND_APP_CLIENT_ID, ENTRA_ADMIN_GROUP_ID env vars to backend CA AZURE_TENANT_ID may already be present — verify
infra/main.bicep Add backend_app_client_id, entra_admin_group_id params Pass through to container_apps module
infra/parameters.dev.bicepparam / parameters.prod.bicepparam Add values for new params Store group ID in Key Vault ref, not plaintext
client/src/components/admin-modal.tsx Replace token input with MSAL.js sign-in button Phase 2
scripts/uigen-shim-proxy.mjs Change /auth from GET to POST; HTTP-only cookie Phase 2
docker-compose.yml Add BACKEND_APP_CLIENT_ID, AZURE_TENANT_ID, ENTRA_ADMIN_GROUP_ID, DISABLE_AUTH to backend service
e2e/global-setup.ts Update to target new backend API app registration Phase 2

References