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.pyis now a ~150-linecreate_app()factory; router wiring is in_register_routers()api/core/config.pyhasSettings(pydantic-settings); new auth env vars go hereapi/routers/admin.pyhas a standalone_require_admindependency at module level — easy to swapapi/routers/uigen.pyissues/validates guest tokens; admin path echoes bearer token — both need updatingapi/db/models.pyhasuigen_tokenstable; nouserstable exists yet- New file
api/auth.pydoes 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_idexists 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¶
- Add
fastapi-azure-authtorequirements.txt/pyproject.toml. - Create
api/auth.py: SingleTenantAzureAuthorizationCodeBearerinstance for OneDNA Entra tokensrequire_admin_group()dependency: checksgroupsclaim againstENTRA_ADMIN_GROUP_ID- Handles group-claim overflow: if user is in >200 groups, Entra omits
groupsclaim — fall back to Microsoft GraphmemberOfcall (requiresGroupMember.Read.Allapp permission) - Update
api/routers/admin.py: replace_require_admin(static token) withrequire_admin_group. Keep static token as fallback viaLEGACY_ADMIN_TOKENenv var during transition (remove after 2 weeks in prod). - Update
api/routers/uigen.py: accept Entra ID JWTs alongside existing guest tokens. - Update
api/main.py: addBACKEND_APP_CLIENT_ID,AZURE_TENANT_ID,ENTRA_ADMIN_GROUP_IDtoSettings; initializefastapi-azure-authat startup (fetches JWKS keys). - Add
GET /openapi-guest.jsonendpoint: filter full spec to onlyGEToperations (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 scopeadmin. - 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/callbackhttps://admin.sven-relijveld.com/auth/callbackhttp://localhost:4400/auth/callback
Entra security group (manual)¶
- Create
portfolio-adminsgroup in OneDNA tenant. - Add admin user(s).
Layer 3 — Admin Modal (MSAL.js, replaces static token input)¶
Frontend changes¶
- Add
@azure/msal-browsertopackage.json. - Create
client/src/auth/msal-config.ts— MSAL config with Entra authority + scopes. - Update
client/src/components/admin-modal.tsx: - Replace "Enter admin token" text input with "Sign in with Microsoft" button.
- On click:
msal.loginPopup()→ acquire access token silently → call shim POST/authwith token. - 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 allPOST/PUT/PATCH/DELETEunder/api/admin/* - Guest:
/openapi-guest.json— filtered copy, onlyGETendpoints 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.
Recommended local dev approach¶
-
For admin auth:
fastapi-azure-authvalidates 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. -
For dev convenience: Add
DISABLE_AUTH=trueenv var (only honoured whenENVIRONMENT != prod) that makes the admin dependency return a mock principal. Mirrors the existing optionalAPI_ADMIN_TOKENpattern. -
docker-compose.yml: AddBACKEND_APP_CLIENT_ID,AZURE_TENANT_ID,ENTRA_ADMIN_GROUP_IDto the backend service environment. No mock OIDC container needed for Phase 1. -
For E2E Playwright: Update
global-setup.tsto obtain a token against the new backend API app registration using client_credentials (requiresApplicationpermission 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:
- Create an Azure AD B2C tenant (separate from OneDNA tenant; billing tied to Azure subscription).
- Configure user flows (sign-up/sign-in) with social IdPs (Google, GitHub, Microsoft personal).
- Expose a B2C-scoped API scope; add B2C token validation to
api/auth.py(second authority). - Alembic migration:
userstable (id,b2c_subject,display_name,email,identity_provider,role, timestamps). - Add
api/routers/auth.py:POST /api/auth/profile(upsert),GET /api/auth/me. - Add
@azure/msal-reactto frontend; create sign-in button + user profile components. - 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 nestedfederatedIdentityCredentials— all 4 items below can be Bicep-managed. Deployment identity needsApplication.ReadWrite.OwnedBy+Group.ReadWrite.All. Seeinfra/modules/entra_app.bicep(to be created).
- [ ] Create
infra/modules/entra_app.bicepusingMicrosoft.Graph/applications@v1.0: uniqueName:portfolio-backend-apiidentifierUris:['api://<client-id>']api.oauth2PermissionScopes: scopeadminwith GUID + consent descriptionsgroupMembershipClaims:'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-adminssecurity group viaMicrosoft.Graph/groups@v1.0: securityEnabled: true,mailEnabled: falsemembers.relationships: add admin user object IDs- [ ] Wire new Bicep module into
infra/main.bicep; outputclient_id+group_idas params to container_apps module - [ ] Ensure deploying service principal has
Application.ReadWrite.OwnedBy+Group.ReadWrite.AllGraph permissions (add to OIDC app registration via portal or separate Bicep step)
Code changes:
- [ ] Add
fastapi-azure-authtoapi/pyproject.toml(orrequirements.txt— check which is used) - [ ] Create
api/auth.py: SingleTenantAzureAuthorizationCodeBearerinstancerequire_admin_group()dependency: checksgroupsclaim vsENTRA_ADMIN_GROUP_IDDISABLE_AUTHbypass (only whenENVIRONMENT != prod) → returns mock principal- Graph
memberOffallback for >200 groups - [ ] Update
api/core/config.pySettings: addbackend_app_client_id,entra_admin_group_id,legacy_admin_token,disable_authfields - [ ] Update
api/routers/admin.py:59router = APIRouter(...): swap_require_admin→require_admin_group; addLEGACY_ADMIN_TOKENfallback check insiderequire_admin_group - [ ] Update
api/routers/uigen.py: replacesecrets.compare_digestadmin path with Entra JWT validation - [ ] Add
GET /openapi-guest.jsoninapi/main.py_register_routers(or as a direct route increate_app) - [ ] Call
fastapi-azure-authinit inapi/main.pystartup block (afterconfigure_logging())
Infrastructure changes:
- [ ] Add
backend_app_client_id+entra_admin_group_idparams toinfra/main.bicep - [ ] Pass through to
infra/modules/container_apps.bicepas backend CA env vars - [ ] Add values to
infra/parameters.dev.bicepparam+infra/parameters.prod.bicepparam - [ ] Update
docker-compose.yml: addBACKEND_APP_CLIENT_ID,ENTRA_ADMIN_GROUP_ID,DISABLE_AUTH=trueto backend service
Test + deploy:
- [ ] Update
tests/— mockapi.auth.require_admin_groupwhere needed (existing tests mock_require_admin) - [ ] Deploy to dev, verify JWT validation works; then deploy to prod
- [ ] Remove
LEGACY_ADMIN_TOKENfallback 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-browserto frontend - [ ] Create
client/src/auth/msal-config.ts - [ ] Update
admin-modal.tsx: replace token input with Microsoft sign-in button - [ ] Change shim
/authfrom 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.jsonendpoint 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.yamlif 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):
Deploying identity permissions needed (beyond existing Azure RBAC):
Application.ReadWrite.OwnedBy— create/update app registrationsGroup.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)¶
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.comfor credential-bearing requests - CSP headers: update nginx config to allow
login.microsoftonline.comframes 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¶
- Should guests be able to submit the contact form, or is that anonymous-only?
- Is Phase 4 (registered users / B2C) needed, or should it remain backlog indefinitely?
- Which social IdPs for Phase 4: Google only, or GitHub + Google?
- Should the frontend show a user avatar/name when signed in as a registered user?
API_ADMIN_TOKENdeprecation timeline: 2 weeks after Phase 1 in prod?- 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 |