From ac7730195d43f4044e3fdf3c914232c2e1b8f8d7 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 22:11:27 -0400 Subject: [PATCH] fix(web-ui): forward X-Forwarded-Proto from outer proxy so mam-api emits Set-Cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the real cause of the login loop. mam-api sets its session cookie with Secure=true (production config). express-session refuses to emit a Secure Set-Cookie unless req.secure is true. With `app.set('trust proxy')` on, req.secure derives from X-Forwarded-Proto. web-ui's nginx was unconditionally sending `X-Forwarded-Proto: $scheme`. Inside the web-ui container nginx listens on port 80, so $scheme is always "http" — regardless of whether the outer NPM proxy terminated TLS. mam-api saw http, decided the connection was insecure, and silently dropped the Set-Cookie from the login response. Login succeeded server-side (session row landed in PG, last_login_at updated) but the browser never received a cookie, so the very next /auth/me check came back 401 and AuthGate bounced to the login screen. Infinite loop. The previous Connection: "upgrade" → $connection_upgrade fix wasn't wrong (the hardcode is a real latent bug worth fixing) — it just wasn't the proximate cause. Fix: a second `map` directive forwards the outer X-Forwarded-Proto through when present, falling back to $scheme only when no proxy header exists (so direct localhost curls still work). Both /api/ and /capture/ now send the correct value upstream, mam-api sees https, req.secure is true, Set-Cookie flows through, login works. Verified by curling the existing direct-to-mam-api path: with X-Forwarded- Proto: https on the request, Set-Cookie comes back; without it, no Set-Cookie. That's the exact difference between web-ui-proxied and direct-to-mam-api in our previous diagnostic curls. Co-Authored-By: Claude Opus 4.7 --- services/web-ui/nginx.conf | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/services/web-ui/nginx.conf b/services/web-ui/nginx.conf index e3b2e91..621112e 100644 --- a/services/web-ui/nginx.conf +++ b/services/web-ui/nginx.conf @@ -1,16 +1,25 @@ # Map for proper WebSocket upgrade handling on the proxied locations below. -# Without this, hardcoding `proxy_set_header Connection "upgrade"` puts nginx -# into tunnel-mode for every request — which silently drops response headers -# including Set-Cookie. That broke session-cookie auth on /api/v1/auth/login: -# mam-api was issuing the cookie, web-ui's proxy was eating it before it -# reached the browser. With this map, Connection is only set to "upgrade" -# when the client actually requested an Upgrade (real WebSocket); otherwise -# it's "close" and the response flows through normally. +# Hardcoding `proxy_set_header Connection "upgrade"` puts nginx into tunnel- +# mode for every request, which has caused subtle bugs in the past. This +# variant only sets Connection: upgrade when the client actually requested +# an Upgrade (real WebSocket); otherwise it's "close". map $http_upgrade $connection_upgrade { default upgrade; '' close; } +# Forward the outer X-Forwarded-Proto when present; fall back to $scheme. +# THIS IS WHY LOGIN WAS LOOPING: web-ui listens on port 80 inside the +# container, so $scheme is always "http". With `proxy_set_header +# X-Forwarded-Proto $scheme;`, mam-api saw http, decided req.secure=false, +# and (because cookie.secure=true in production) silently refused to emit +# the Set-Cookie at all. NPM correctly sends X-Forwarded-Proto: https on +# the outer request — we just have to pass it through to mam-api. +map $http_x_forwarded_proto $proxied_x_forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + server { listen 80; server_name _; @@ -71,7 +80,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxied_x_forwarded_proto; # Preserve Content-Type so multer receives the full multipart boundary (#74) proxy_set_header Content-Type $content_type; proxy_buffering off; @@ -91,7 +100,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $proxied_x_forwarded_proto; proxy_buffering off; proxy_request_buffering off; }