# BFF environment template # Copy to .env (which is gitignored) and fill in actual values for local development. # Production values are managed by the platform's secret manager (see future infrastructure ADR). # Postgres connection (per ADR-0006) # Local dev default: dockerised Postgres on port 5432, schema 'public'. # Username / password / db must match infra/local/.env (POSTGRES_USER / # POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth, # this is the BFF view of the same connection. # # IMPORTANT — URL encoding. The password is part of the URL userinfo # segment, so any of these characters must be URL-encoded: # @ → %40 # → %23 : → %3A / → %2F ? → %3F # % → %25 & → %26 = → %3D + → %2B ; → %3B # i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read # "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public" # The BFF aborts at boot with a clear error if it detects an unencoded # special character (see apps/portal-bff/src/config/check-database-url.ts). DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public" # Observability (per ADR-0012) # All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see # apps/portal-bff/src/observability/tracing.ts for the bootstrap. # Pino log level: 'info' in prod, 'debug' in dev (default if unset). LOG_LEVEL=debug OTEL_SERVICE_NAME=portal-bff OTEL_SERVICE_VERSION=dev # Default endpoint targets the Collector provisioned in # infra/local/dev.compose.yml. The /v1/traces suffix is required by # the HTTP/Protobuf transport. OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # v1 samples 100 % at the app; tail sampling is delegated to the # Collector (per ADR-0012). Override only for spike investigations. OTEL_TRACES_SAMPLER=always_on # Identity / Entra ID app registration (per ADR-0008 / ADR-0009) # Values come from the project's Entra application registration in the # Azure Admin Center → App registrations → APF Portal. The four # *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys # are mandatory; the BFF refuses to boot without them (see # apps/portal-bff/src/config/check-entra-config.ts). # # ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually # https://login.microsoftonline.com/. The authority used by MSAL is # `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows, # or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant # (per ADR-0008's dual-audience design). v1 uses the tenant-scoped # authority; the multi-tenant switch lands when External ID activation # is needed. # # ENTRA_CLIENT_SECRET is the high-value secret of this set. Never # commit a real value. Production manages it via the deploy platform's # secret manager (future infrastructure ADR). ENTRA_INSTANCE_URL=https://login.microsoftonline.com/ ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000 ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000 ENTRA_CLIENT_SECRET=replace_with_real_value # Redirect URIs registered in Entra alongside the same client id. # User portal — `/api/auth/callback` is the OIDC return URL; the # post-logout URL is where Entra sends the browser after RP-initiated # logout (typically the SPA landing page). ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/ # Admin portal — distinct callback per ADR-0020 §"Sessions — distinct # from `portal-shell`" so Entra routes the response to the matching # session. Both `ENTRA_REDIRECT_URI` and `ENTRA_ADMIN_REDIRECT_URI` # must be registered in the same Entra app registration's # "Redirect URIs" list. Distinct post-logout URL routes admin # sign-outs to the admin SPA's landing page (port 4300 in dev — see # apps/portal-admin/project.json `serve.options.port`). ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/ # Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the # transient pre-auth cookie that carries the OIDC `state` + PKCE # verifier between the /auth/login redirect and the /auth/callback # round-trip, and (once ADR-0010 ships) the session cookie's # integrity layer. Mandatory at boot — the BFF aborts if missing or # obviously weak (less than 32 base64-decoded bytes ≈ 256 bits of # entropy). Generate a fresh value per environment: # # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" SESSION_SECRET=replace_with_32_random_bytes_base64url # Redis connection (per ADR-0010). The BFF uses `ioredis` for session # storage (today: just the connection; the express-session + # connect-redis middleware lands in the next PR). # # REDIS_URL — full URL form including auth. Must match `infra/local/.env` # (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose # stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS + # REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current # variable supports the dev single-instance shape only. REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0 # Session payload encryption (per ADR-0010 §"At-rest encryption"). # AES-256-GCM key for encrypting the session JSON that connect-redis # writes to Redis, so a Redis dump never carries raw user identities # / future tokens / claims in plaintext. **Distinct** from # SESSION_SECRET, which only signs the cookie's session-id — never # reuse one for the other. Mandatory at boot. # # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url # OBO downstream-token cache encryption (per ADR-0014 §"Token cache # (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream # tokens cached in Redis under `obo:{actor_id_hash}:{resource}`. # **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a # leak of one does not cascade into the other. The boot validator # refuses an identical value. Mandatory at boot. # # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url # BFF JWKS signing material (per ADR-0014 §"Service strategy"). The # BFF mints short-lived `X-User-Assertion` JWTs to propagate user # identity to non-Entra downstreams; downstreams verify the signature # against `/.well-known/jwks.json`. Both values are mandatory at boot. # # Generate an RSA private key: # mkdir -p apps/portal-bff/.secrets && \ # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \ # -out apps/portal-bff/.secrets/jwks.pem # # RSA-2048 is the minimum the validator accepts; 3072 is the # default recommendation. EC P-256 / P-384 also accepted. BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem # Stable key id published in the JWKS + emitted in the JWT `kid` # header. URL-safe charset only ([A-Za-z0-9_-], 4–128 chars). Bump # this when rotating to a new key. BFF_JWKS_KID=bff-2026-05 # Session timeouts (per ADR-0010). Both optional with sensible # defaults; override only when staging / prod policy diverges. # SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request # extends the cookie's `expires` by this many seconds. # Default 1800 (30 min). # SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is # destroyed regardless of activity at this age. # Default 43200 (12 h). # SESSION_IDLE_TIMEOUT_SECONDS=1800 # SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200 # Per-environment salt used to pseudonymise the user id before it # lands in audit rows (per ADR-0013 §"Schema") and in Pino app log # lines (per ADR-0012 §"User id hashing"). Same value must be used # on both sides so audit and app logs join on `actor_id_hash`. # # Rotation invalidates the join key — old rows / log lines can no # longer be correlated with the new hash. Treat as long-lived per # environment. Mandatory at boot. # # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url # CORS allowlist (per ADR-0009 §"CORS"). Comma-separated list of # origins (scheme://host[:port]) allowed to call the BFF with # credentials. The BFF refuses to start without this — silently # defaulting to localhost is the classic "works in dev, breaks in # prod" trap. # # Local dev: portal-shell on :4200 and portal-admin on :4300 — both # call the BFF with credentials. Source of truth for the ports: # `apps//project.json` `serve.options.port`. CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300 # Rate limiting (per ADR-0015 §"DoS mitigation"). Both optional # with conservative defaults; override per environment when traffic # patterns demand it. The BFF keys buckets by session id when the # request is authenticated, by remote IP otherwise — rotating # sessions doesn't dodge the limit. # RATE_LIMIT_PER_MINUTE — general bucket. Default 120 (~ 2 r/s). # RATE_LIMIT_AUTH_PER_MINUTE — stricter bucket on /auth/login and # /auth/callback. Default 10/min, to # slow brute-force / replay loops # without inconveniencing legit users. # RATE_LIMIT_PER_MINUTE=120 # RATE_LIMIT_AUTH_PER_MINUTE=10 # Future env vars introduced by upcoming phases / ADRs: # # Auth flow (ADR-0009) — additional keys wired as the routes land: # ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET) # ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in # in the multi-tenant phase — empty means # "only ENTRA_TENANT_ID is accepted") # # Sessions (ADR-0010) — additional keys wired as the layers land: # REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA) # REDIS_SENTINEL_NAME (master name in Sentinel; prod HA) # REDIS_TLS ('true' in prod) # # MFA (ADR-0011): # MFA_FRESHNESS_SECONDS (default 600) # # Audit trail (ADR-0013): # AUDIT_DATABASE_URL (separate creds, role 'audit_writer') # AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job) # AUDIT_RETENTION_DAYS (default 365) # # Downstream API access (ADR-0014): # OBO_CACHE_ENCRYPTION_KEY — wired # BFF_JWKS_PRIVATE_KEY_PATH — wired # BFF_JWKS_KID — wired # _API_BASE_URL (per integrated downstream — lands with the first integration) # _TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)