Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,12 @@ export function createApp(env: EnvMap = process.env): Hono<AppEnv> {

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";
Comment on lines 321 to 330
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify createAuth() plugin population + OIDC configuration contract
rg -n --type ts "createAuth\(" api/src -C 5

Repository: RackulaLives/Rackula

Length of output: 1425


🏁 Script executed:

#!/bin/bash
# Find all usages of auth.options.plugins
rg -n --type ts "auth\.options\.plugins" api/src -C 3

Repository: RackulaLives/Rackula

Length of output: 388


🏁 Script executed:

#!/bin/bash
# Find OIDC-related configuration
rg -n --type ts "authMode|OIDC|oidc" api/src -C 3

Repository: RackulaLives/Rackula

Length of output: 22483


🏁 Script executed:

#!/bin/bash
# Check the specific lines mentioned in the review (321-330)
sed -n '315,335p' api/src/app.ts

Repository: RackulaLives/Rackula

Length of output: 785


Clarify authPlugins extraction for better type narrowing; validate OIDC guard assumptions.

The current ternary mixes optional chaining in the condition with direct access in the true branch. While runtime-safe, splitting the condition improves readability and helps TS analysis. Additionally, using authPlugins.length > 0 as an OIDC configured predicate is valid—createAuth() only populates the genericOAuth plugin when OIDC credentials are present, and throws if authMode is "oidc" but credentials are missing. However, the redundancy could be clarified with a comment.

Suggested clarification (same behavior, better narrowing)
-    const authPlugins = Array.isArray(auth?.options?.plugins)
-      ? auth.options.plugins
-      : [];
+    const configuredPlugins = auth?.options?.plugins;
+    const authPlugins = Array.isArray(configuredPlugins) ? configuredPlugins : [];

Optionally, add a comment on line 328:

authPlugins.length > 0 && // Confirms OIDC provider was initialized with credentials

The duck-typing checks for authApi methods (lines 329–330) already follow the learning guidance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/src/app.ts` around lines 321 - 330, Extract auth plugins into a
safely-narrowed variable before the guard: check if auth is truthy then set
authPlugins = Array.isArray(auth.options?.plugins) ? auth.options.plugins : [];
use that authPlugins in the oidcApiAvailable expression and add an inline
comment on the authPlugins.length > 0 check noting "Confirms OIDC provider was
initialized with credentials"; keep the duck-typing checks for
authApi.signInWithOAuth2 and authApi.oAuth2Callback as-is. Ensure you reference
the existing symbols auth, auth?.options?.plugins, authPlugins,
oidcApiAvailable, and authApi.signInWithOAuth2/authApi.oAuth2Callback when
making the change.


Expand Down
23 changes: 13 additions & 10 deletions deploy/nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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/ {
Expand Down Expand Up @@ -163,15 +163,15 @@ 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) {
return 204;
}
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;
Expand All @@ -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
Expand Down
32 changes: 27 additions & 5 deletions docs/deployment/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 │ │
│ └─────────────────────────────────────────┘ │
└────────────┬────────────────────────────────────┘
Expand All @@ -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>` (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

Expand Down
22 changes: 18 additions & 4 deletions docs/guides/SELF-HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down