From 306dcca2ec15c7f17607925d99e00780fd1655e0 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Thu, 26 Feb 2026 13:16:12 -0800 Subject: [PATCH 1/4] fix: align nginx auth routing contract and docs Fixes #1332 --- deploy/nginx.conf.template | 20 +++++++++---------- docs/deployment/AUTHENTICATION.md | 32 ++++++++++++++++++++++++++----- docs/guides/SELF-HOSTING.md | 14 ++++++++++++++ 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 5bead036..5d6e5b7b 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -81,7 +81,7 @@ server { location = /auth/login { resolver 127.0.0.11 valid=30s; set $api_upstream ${API_HOST}; - proxy_pass http://$api_upstream:${API_PORT}/auth/login$is_args$args; + proxy_pass http://$api_upstream:${API_PORT}/api/auth/login$is_args$args; include /etc/nginx/snippets/auth-proxy.conf; proxy_intercept_errors on; error_page 502 503 504 = @auth_service_unavailable; @@ -90,7 +90,7 @@ server { location = /auth/callback { resolver 127.0.0.11 valid=30s; set $api_upstream ${API_HOST}; - proxy_pass http://$api_upstream:${API_PORT}/auth/callback$is_args$args; + proxy_pass http://$api_upstream:${API_PORT}/api/auth/callback$is_args$args; include /etc/nginx/snippets/auth-proxy.conf; proxy_intercept_errors on; error_page 502 503 504 = @auth_service_unavailable; @@ -99,7 +99,7 @@ server { location = /auth/check { resolver 127.0.0.11 valid=30s; set $api_upstream ${API_HOST}; - proxy_pass http://$api_upstream:${API_PORT}/auth/check$is_args$args; + proxy_pass http://$api_upstream:${API_PORT}/api/auth/check$is_args$args; include /etc/nginx/snippets/auth-proxy.conf; proxy_intercept_errors on; error_page 502 503 504 = @auth_service_unavailable; @@ -108,7 +108,7 @@ server { location = /auth/logout { resolver 127.0.0.11 valid=30s; set $api_upstream ${API_HOST}; - proxy_pass http://$api_upstream:${API_PORT}/auth/logout$is_args$args; + proxy_pass http://$api_upstream:${API_PORT}/api/auth/logout$is_args$args; include /etc/nginx/snippets/auth-proxy.conf; proxy_intercept_errors on; error_page 502 503 504 = @auth_service_unavailable; @@ -123,12 +123,12 @@ server { # These paths don't correspond to valid API endpoints location = /api { default_type application/json; - return 400 '{"error": "Invalid API path", "hint": "Use /api/layouts, /api/assets, or /api/health"}'; + return 400 '{"error": "Invalid API path", "hint": "Use /api/layouts, /api/assets, /api/auth/*, or /api/health"}'; } location = /api/ { default_type application/json; - return 400 '{"error": "Invalid API path", "hint": "Use /api/layouts, /api/assets, or /api/health"}'; + return 400 '{"error": "Invalid API path", "hint": "Use /api/layouts, /api/assets, /api/auth/*, or /api/health"}'; } location /api/ { @@ -163,7 +163,7 @@ server { } # Internal auth probe for app-route protection. - # API validates the signed session cookie and returns 204 (ok) or 401 (unauthorized). + # API validates the signed session cookie and returns 204 (authorized) or 401 (unauthorized). location = /_rackula_auth_check { internal; if ($rackula_auth_mode_none) { @@ -171,7 +171,7 @@ server { } resolver 127.0.0.11 valid=30s; set $api_upstream ${API_HOST}; - proxy_pass http://$api_upstream:${API_PORT}/auth/check; + proxy_pass http://$api_upstream:${API_PORT}/api/auth/check; proxy_http_version 1.1; proxy_connect_timeout 3s; proxy_read_timeout 3s; @@ -196,8 +196,8 @@ server { } location @auth_login_redirect { - # Use normalized URI here to avoid passing raw query delimiters in next. - return 302 /auth/login?next=$uri; + # Preserve normalized path + query so deep links survive login. + return 302 /auth/login?next=$uri$is_args$args; } # Container health check - used by Docker/K8s for liveness/readiness probes diff --git a/docs/deployment/AUTHENTICATION.md b/docs/deployment/AUTHENTICATION.md index eb05ff7d..3d94005e 100644 --- a/docs/deployment/AUTHENTICATION.md +++ b/docs/deployment/AUTHENTICATION.md @@ -6,7 +6,7 @@ Rackula uses **Better Auth** with stateless cookie-based sessions to provide per - **Generic OIDC support** - Works with any OIDC-compliant identity provider - **Stateless sessions** - Cookie-only sessions that survive container restarts without server-side storage -- **Read-only unauthenticated access** - Users can design rack layouts without authentication; authentication required only for saving/managing layouts +- **Optional auth mode** - `RACKULA_AUTH_MODE=none` allows anonymous access; non-`none` modes block anonymous access on protected routes - **Security hardening** - Production-ready defaults with HttpOnly cookies, SameSite protection, and HTTPS enforcement ### Architecture @@ -31,6 +31,7 @@ Rackula uses **Better Auth** with stateless cookie-based sessions to provide per │ │ - /auth/login → redirects to IdP │ │ │ │ - /auth/callback → handles OIDC return │ │ │ │ - /auth/logout → clears session │ │ +│ │ - /api/auth/* compatibility routes │ │ │ │ - Session validation middleware │ │ │ └─────────────────────────────────────────┘ │ └────────────┬────────────────────────────────────┘ @@ -52,11 +53,32 @@ Rackula uses **Better Auth** with stateless cookie-based sessions to provide per - Sessions survive container restarts (stored in browser, not server memory) - No database required for session storage -**Read-Only Access:** +**Access Control:** -- Unauthenticated users can access the full design interface -- Authentication required only when saving layouts or managing saved layouts -- Core design principle: "zero friction for rack design" +- `RACKULA_AUTH_MODE=none`: routes follow existing unauthenticated behavior +- `RACKULA_AUTH_MODE=oidc|local`: anonymous access to protected routes is denied by default +- Auth check endpoint returns `204` for valid sessions and `401` for invalid/missing sessions + +### Reverse Proxy Auth Contract (Nginx) + +If you deploy Rackula behind Nginx auth_request, keep this contract consistent: + +- Browser-facing auth routes: + - `GET /auth/login` + - `GET /auth/callback` + - `GET /auth/check` + - `POST /auth/logout` +- API compatibility routes (also available): + - `/api/auth/login` + - `/api/auth/callback` + - `/api/auth/check` + - `/api/auth/logout` +- Internal auth probe contract: + - `204` = authenticated + - `401` = unauthenticated + +When protecting app routes with `auth_request`, redirect unauthorized requests to: +- `/auth/login?next=` ## Prerequisites diff --git a/docs/guides/SELF-HOSTING.md b/docs/guides/SELF-HOSTING.md index db520c50..39b23dc8 100644 --- a/docs/guides/SELF-HOSTING.md +++ b/docs/guides/SELF-HOSTING.md @@ -286,6 +286,7 @@ http { location / { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; + limit_req zone=api_per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; @@ -336,6 +337,19 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Explicit API allowlist: auth contract endpoints + location ~ ^/api/auth(/.*)?$ { + auth_basic "Rackula Protected"; + auth_basic_user_file /run/secrets/rackula_htpasswd; + + limit_req zone=api_per_ip burst=20 nodelay; + proxy_pass http://rackula_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Deny-by-default for unexpected API paths. location /api/ { return 403; From b5803b84274d2cc30fa244510905a2e6ebecab01 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Thu, 26 Feb 2026 14:55:09 -0800 Subject: [PATCH 2/4] fix: replace broken plugin.id OIDC detection with plugin count check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better Auth's genericOAuth plugin doesn't expose an id property, so plugin.id === "generic-oauth" always evaluated to false. Use authPlugins.length > 0 instead — the plugins array is only populated when OIDC env vars are configured in createAuth(). API method checks (signInSocial/callbackOAuth) are kept as a secondary guard. Co-Authored-By: Claude Opus 4.6 --- api/src/app.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/app.ts b/api/src/app.ts index 54fc3a45..88fe761e 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -320,14 +320,12 @@ export function createApp(env: EnvMap = process.env): Hono { if (securityConfig.authEnabled) { const authPlugins = Array.isArray(auth?.options?.plugins) - ? auth?.options?.plugins + ? auth.options.plugins : []; const oidcApiAvailable = Boolean(auth) && securityConfig.authMode === "oidc" && - authPlugins.some( - (plugin) => (plugin as { id?: unknown }).id === "generic-oauth", - ) && + authPlugins.length > 0 && typeof authApi.signInWithOAuth2 === "function" && typeof authApi.oAuth2Callback === "function"; From 4f575b9bf2c1b058b977c45defca4f395b758eec Mon Sep 17 00:00:00 2001 From: ggfevans Date: Thu, 26 Feb 2026 23:06:44 -0800 Subject: [PATCH 3/4] fix: address CodeRabbit feedback on PR #1339 - Fix nginx auth redirect to use next=$uri (path only) to prevent query string params from splitting across the redirect URL - Add HTTP method annotations to API compatibility routes in auth docs - Clarify that next= preserves path only, not query strings - Rename misleading api_per_ip rate limit zone to per_ip Co-Authored-By: Claude Opus 4.6 --- deploy/nginx.conf.template | 6 ++++-- docs/deployment/AUTHENTICATION.md | 10 +++++----- docs/guides/SELF-HOSTING.md | 12 ++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 5d6e5b7b..85d373c6 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -196,8 +196,10 @@ server { } location @auth_login_redirect { - # Preserve normalized path + query so deep links survive login. - return 302 /auth/login?next=$uri$is_args$args; + # Pass path only — query strings are not preserved across login because + # raw & in $args would split into separate query params, breaking the next= value. + # Standard nginx lacks URI-encoding support (requires OpenResty set_escape_uri). + return 302 /auth/login?next=$uri; } # Container health check - used by Docker/K8s for liveness/readiness probes diff --git a/docs/deployment/AUTHENTICATION.md b/docs/deployment/AUTHENTICATION.md index 3d94005e..13a7faa5 100644 --- a/docs/deployment/AUTHENTICATION.md +++ b/docs/deployment/AUTHENTICATION.md @@ -69,16 +69,16 @@ If you deploy Rackula behind Nginx auth_request, keep this contract consistent: - `GET /auth/check` - `POST /auth/logout` - API compatibility routes (also available): - - `/api/auth/login` - - `/api/auth/callback` - - `/api/auth/check` - - `/api/auth/logout` + - `GET /api/auth/login` + - `GET /api/auth/callback` + - `GET /api/auth/check` + - `POST /api/auth/logout` - Internal auth probe contract: - `204` = authenticated - `401` = unauthenticated When protecting app routes with `auth_request`, redirect unauthorized requests to: -- `/auth/login?next=` +- `/auth/login?next=` (path only; query strings are not preserved across login redirects because standard nginx cannot URI-encode the `next` value) ## Prerequisites diff --git a/docs/guides/SELF-HOSTING.md b/docs/guides/SELF-HOSTING.md index 39b23dc8..da5d2136 100644 --- a/docs/guides/SELF-HOSTING.md +++ b/docs/guides/SELF-HOSTING.md @@ -267,7 +267,7 @@ http { } # Per-client API throttle; tune for your environment. - limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s; server { listen 8080; @@ -286,7 +286,7 @@ http { location / { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; - limit_req zone=api_per_ip burst=20 nodelay; + limit_req zone=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; @@ -303,7 +303,7 @@ http { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; - limit_req zone=api_per_ip burst=20 nodelay; + limit_req zone=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; proxy_set_header Host $host; @@ -316,7 +316,7 @@ http { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; - limit_req zone=api_per_ip burst=20 nodelay; + limit_req zone=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; proxy_set_header Host $host; @@ -329,7 +329,7 @@ http { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; - limit_req zone=api_per_ip burst=20 nodelay; + limit_req zone=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; proxy_set_header Host $host; @@ -342,7 +342,7 @@ http { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; - limit_req zone=api_per_ip burst=20 nodelay; + limit_req zone=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; proxy_set_header Host $host; From 5944e8399949d4f28cbea0bc920a95f94ebd0d79 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Thu, 26 Feb 2026 23:41:26 -0800 Subject: [PATCH 4/4] fix: preserve query params in nginx auth redirect The login redirect was dropping query strings, so users returning from auth would lose their original URL parameters. Now uses $is_args$args to carry them through. Multi-param query strings with & will still split at the nginx level (proper fix needs OpenResty), but this is strictly better than losing everything. Co-Authored-By: Claude Opus 4.6 --- deploy/nginx.conf.template | 9 +++++---- docs/deployment/AUTHENTICATION.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 85d373c6..0397bb17 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -196,10 +196,11 @@ server { } location @auth_login_redirect { - # Pass path only — query strings are not preserved across login because - # raw & in $args would split into separate query params, breaking the next= value. - # Standard nginx lacks URI-encoding support (requires OpenResty set_escape_uri). - return 302 /auth/login?next=$uri; + # Preserve path and query string in the next= parameter. + # Limitation: if $args contains & characters, nginx will split them into + # separate query params. Only the first segment stays in next=. + # Full URI-encoding requires OpenResty (set_escape_uri), unavailable here. + return 302 /auth/login?next=$uri$is_args$args; } # Container health check - used by Docker/K8s for liveness/readiness probes diff --git a/docs/deployment/AUTHENTICATION.md b/docs/deployment/AUTHENTICATION.md index 13a7faa5..ea7df165 100644 --- a/docs/deployment/AUTHENTICATION.md +++ b/docs/deployment/AUTHENTICATION.md @@ -68,7 +68,7 @@ If you deploy Rackula behind Nginx auth_request, keep this contract consistent: - `GET /auth/callback` - `GET /auth/check` - `POST /auth/logout` -- API compatibility routes (also available): +- API compatibility routes (also available, same methods): - `GET /api/auth/login` - `GET /api/auth/callback` - `GET /api/auth/check` @@ -78,7 +78,7 @@ If you deploy Rackula behind Nginx auth_request, keep this contract consistent: - `401` = unauthenticated When protecting app routes with `auth_request`, redirect unauthorized requests to: -- `/auth/login?next=` (path only; query strings are not preserved across login redirects because standard nginx cannot URI-encode the `next` value) +- `/auth/login?next=` (path and query string are preserved; caveat: if the original URL contains multiple `&`-delimited query params, only the first segment stays in `next=` because standard nginx cannot URI-encode the value) ## Prerequisites