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"; diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 5bead036..0397bb17 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,11 @@ server { } location @auth_login_redirect { - # Use normalized URI here to avoid passing raw query delimiters in next. - 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 eb05ff7d..ea7df165 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, same methods): + - `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=` (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 diff --git a/docs/guides/SELF-HOSTING.md b/docs/guides/SELF-HOSTING.md index db520c50..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,6 +286,7 @@ http { location / { auth_basic "Rackula Protected"; auth_basic_user_file /run/secrets/rackula_htpasswd; + limit_req zone=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; @@ -302,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; @@ -315,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; @@ -328,7 +329,20 @@ 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; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + 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=per_ip burst=20 nodelay; proxy_pass http://rackula_upstream; proxy_http_version 1.1; proxy_set_header Host $host;