NetOSField accessMenu
Signed in user unavailable
Documentation/Public Hostname & SSO
Download PDF

Xiber NetOS — Public Hostname & SSO

Cloudflare Tunnel deployment with Microsoft Entra ID single sign-on.


Overview

NetOS can be exposed publicly through a Cloudflare Tunnel and protected with Microsoft Entra ID SSO. The auth layer sits in front of the app — unauthenticated users never reach the API or web UI.

Internet
  → cloudflared        (Cloudflare Tunnel for netos.xiberian.net)
    → sso-proxy        (oauth2-proxy, Entra OIDC, :4180)
      → public-proxy   (nginx, :8080 — routes /api → FastAPI, / → Next.js)
        → api + web containers

This ordering matches docker-compose.public.yml: cloudflared depends on sso-proxy, sso-proxy's upstream is http://public-proxy:8080, and public-proxy proxies to web:3000 and api:8000. Authentication happens at sso-proxy before any request reaches nginx or the app.


Target Hostname

https://netos.xiberian.net

The hostname routes to the sso-proxy container, not directly to web or API.


Runtime Components

ContainerRolePort
cloudflaredOutbound tunnel to Cloudflare edge
sso-proxyOAuth2-Proxy with Entra OIDC4180
public-proxyNginx reverse proxy8080
webNext.js UI3000
apiFastAPI backend8000
postgresPostgreSQL 165432
redisRedis 76379

Routing Rules (Nginx)

PathDestination
/Web app (Next.js)
/api/FastAPI backend
/docs-api/FastAPI Swagger UI
/openapi.jsonOpenAPI schema

Prerequisites

  • Cloudflare account with Zero Trust enabled
  • Microsoft Entra ID tenant (Xiber: 4944843d-e347-4fe7-a279-4006ce5efc33)
  • Docker Engine 24+ with Docker Compose v1 (docker-compose 1.29.x). The NetOS server does not have the Compose v2 plugin (docker compose) available under sudo; use the hyphenated docker-compose binary.

Setup Steps

1. Create Entra App Registration

  1. Go to Azure Portal → App registrations → New registration
  2. Name: Xiber NetOS
  3. Redirect URI (Web): https://netos.xiberian.net/oauth2/callback
  4. Note the Application (client) ID and create a client secret

Recommended access policy:

  • Assign only Xiber users or a dedicated NetOS-Users security group
  • Require MFA and compliant device if available
  • Map Entra groups to NetOS roles (exec, finance, network_eng, etc.)

2. Create Environment File

Copy the example and fill in real values:

cp infra/docker/.env.public.example infra/docker/.env.public

Required variables (consumed by docker-compose.public.yml; see infra/docker/.env.public.example):

VariableDescriptionExample
NETOS_PUBLIC_HOSTNAMEPublic hostname (cookie/whitelist domain)netos.xiberian.net
NETOS_PUBLIC_URLPublic base URL; oauth2-proxy builds the redirect URL from thishttps://netos.xiberian.net
NETOS_ALLOWED_EMAIL_DOMAINEmail domain oauth2-proxy will admit (--email-domain)xiber.net
ENTRA_OIDC_ISSUER_URLEntra OIDC issuerhttps://login.microsoftonline.com/{tenant_id}/v2.0
ENTRA_OIDC_EMAIL_CLAIMOIDC claim used as email (default preferred_username)preferred_username
ENTRA_OIDC_USER_ID_CLAIMOIDC claim used as user id (default oid)oid
ENTRA_CLIENT_IDApp registration client ID29833a06-d27e-...
ENTRA_CLIENT_SECRETApp registration client secretb-V8Q~bO_YRL...
OAUTH2_PROXY_COOKIE_SECRETRandom 32-byte base64 string(generate below)
CLOUDFLARE_TUNNEL_TOKENTunnel token from Cloudflare dashboardeyJhIjoi...
ALLOW_DEV_AUTHMust be false in productionfalse
TRUST_PROXY_AUTH_HEADERSTrust identity headers from the proxytrue
CORS_ALLOWED_ORIGINSAllowed browser origin(s)https://netos.xiberian.net

NETOS_ALLOWED_EMAIL_DOMAIN is an email-domain filter enforced by oauth2-proxy. If login succeeds at Microsoft but oauth2-proxy then returns 403, the UPN suffix likely does not match this domain — temporarily set it to * to confirm, then restore the real domain.

Generate cookie secret:

python3 -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"

3. Create Cloudflare Tunnel

  1. Go to Cloudflare Zero Trust → Tunnels → Create a tunnel
  2. Name: netos
  3. Copy the tunnel token into .env.public as CLOUDFLARE_TUNNEL_TOKEN
  4. Add a public hostname:
  • Hostname: netos.xiberian.net
  • Service: http://sso-proxy:4180
  1. Optionally add a Cloudflare Access policy (Microsoft Entra ID, Xiber users only) as a second enforcement layer

4. Start the Public Stack

The public overlay layers on the server base file (docker-compose.server.yml), not the local dev file. Use Compose v1 with the netos project name:

cd infra/docker
docker-compose -p netos \
  --env-file .env.public \
  -f docker-compose.server.yml \
  -f docker-compose.public.yml \
  --profile public \
  up -d

Do not use docker-compose.yml as the base here — that is the local dev stack (ALLOW_DEV_AUTH=true, ports published on all interfaces) and must not front the public hostname. If you hit the Compose v1 KeyError: 'ContainerConfig' recreate bug, docker stop + docker rm the affected netos_<svc>_1 container before re-running up -d (see the Admin & Operations deploy notes). To redeploy only the public web container, scripts/deploy-prod-web.sh does this for you.

5. Verify

Check containers:

docker-compose -p netos \
  --env-file .env.public \
  -f docker-compose.server.yml \
  -f docker-compose.public.yml \
  --profile public \
  ps

Open in browser:

https://netos.xiberian.net

You should be redirected to Microsoft login. After authenticating, you'll see the NetOS UI.


Architecture Notes

Why OAuth2-Proxy + Cloudflare Access?

Both layers serve complementary purposes:

LayerPurpose
Cloudflare AccessEdge enforcement — blocks unauthenticated traffic before it reaches your server
OAuth2-ProxyOrigin enforcement — protects the app even if tunnel routing is misconfigured

Keep both. The overhead is negligible and the defense-in-depth is worth it.

How the API consumes SSO identity today

In production the API does not validate JWTs itself. It trusts identity headers that oauth2-proxy sets after a successful Entra login:

  1. oauth2-proxy authenticates the user against Entra and sets X-Auth-Request-Email / X-Auth-Request-User (--set-xauthrequest=true, --pass-user-headers=true).
  2. nginx (public-proxy) forwards those headers to the API.
  3. The API accepts them only when TRUST_PROXY_AUTH_HEADERS=true and the request's client IP falls inside TRUSTED_AUTH_PROXY_CIDRS — so headers cannot be spoofed by clients outside the Docker/proxy network.
  4. NetOS then looks up the internal users record and applies its own roles/permissions. See Authorization & RBAC.

Note the proxy is configured with --pass-authorization-header=false and --pass-access-token=false, so the raw Entra token is intentionally not forwarded to the app.

Future work: in-app JWT signature validation against the Entra JWKS endpoint and direct Entra-group → NetOS-role mapping. Tracked in Roadmap → Authentication.


Security Checklist

ItemStatus
HTTPS via CloudflareAutomatic with tunnel
Entra OIDC authenticationVia OAuth2-Proxy
Cloudflare Access policyRecommended additional layer
Cookie secret rotationManual — regenerate and restart periodically
Client secret rotationVia Azure Portal — update .env.public after rotation
MFA enforcementConfigure in Entra Conditional Access
Role-based accessDatabase-driven RBAC live; Entra-group → role mapping planned
Dev auth disabled in productionALLOW_DEV_AUTH=false (server default) — required so dev@xiber.com cannot auto-bootstrap as Super Admin
Identity-header spoofingBlocked by TRUSTED_AUTH_PROXY_CIDRS; only the proxy network may set identity headers

Troubleshooting

IssueSolution
Redirect loop after loginCheck OAUTH2_PROXY_COOKIE_SECRET is exactly 32 bytes base64-encoded
502 Bad GatewayVerify sso-proxy and public-proxy containers are running
Tunnel not connectingCheck CLOUDFLARE_TUNNEL_TOKEN is correct; verify tunnel is active in CF dashboard
CORS errors in browserVerify Nginx config allows the public hostname as an origin
"Access Denied" after loginUser may not be in the assigned Entra group; check app assignment
403 immediately after Microsoft loginoauth2-proxy --email-domain mismatch — the UPN suffix is not NETOS_ALLOWED_EMAIL_DOMAIN. Set it to * to confirm, then restore the real domain
API returns 401/403 for a logged-in userConfirm TRUST_PROXY_AUTH_HEADERS=true, that nginx forwards X-Auth-Request-Email, and that the request reaches the API from a TRUSTED_AUTH_PROXY_CIDRS address. A 403 can also mean the user has no active internal users record with app_access — provision them (or add to RBAC_BOOTSTRAP_SUPER_ADMIN_EMAILS) per Authorization & RBAC