fix(auth): resolve cookie authentication failure over HTTP

Cookies were set with secure flag based solely on NODE_ENV, causing
401 errors when accessing over HTTP with NODE_ENV=production.

- Add COOKIE_SECURE env var for explicit control
- Auto-detect HTTPS via X-Forwarded-Proto header in production
- Extract isSecureCookie() utility to lib/auth/utils.ts
- Document COOKIE_SECURE in README and .env.example

Fixes #39
This commit is contained in:
Michael T
2026-01-23 15:26:24 -05:00
parent be49b91188
commit 30c661a364
6 changed files with 30 additions and 7 deletions

View File

@@ -12,6 +12,11 @@ SECRET=
NODE_ENV=production NODE_ENV=production
PORT=3000 PORT=3000
# Cookie Security (Optional)
# Set to 'false' if accessing over HTTP (e.g., local LAN without HTTPS)
# When unset, auto-detects HTTPS via X-Forwarded-Proto header
# COOKIE_SECURE=false
# Timezone for logs (Optional) # Timezone for logs (Optional)
TZ=America/New_York TZ=America/New_York

View File

@@ -102,6 +102,11 @@ PGID=1000
# Optional - JWT Secret (auto-generated if not provided) # Optional - JWT Secret (auto-generated if not provided)
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
SECRET= SECRET=
# Optional - Cookie Security
# Set to 'false' for HTTP access (e.g., local LAN without HTTPS)
# When unset, auto-detects HTTPS via X-Forwarded-Proto header
COOKIE_SECURE=false
``` ```
### User Permissions (PUID/PGID) ### User Permissions (PUID/PGID)

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, validateAdminCredentials } from '@/lib/auth/utils'; import { generateAccessToken, generateRefreshToken, validateAdminCredentials, isSecureCookie } from '@/lib/auth/utils';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
// Set cookies // Set cookies
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isSecureCookie(request),
sameSite: 'lax' as const, sameSite: 'lax' as const,
path: '/', path: '/',
}; };

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '@/lib/auth/utils'; import { generateAccessToken, generateRefreshToken, verifyRefreshToken, isSecureCookie } from '@/lib/auth/utils';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
// Set new cookies // Set new cookies
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isSecureCookie(request),
sameSite: 'lax' as const, sameSite: 'lax' as const,
path: '/', path: '/',
}; };

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db'; import { db, settings } from '@/lib/db';
import crypto from 'crypto'; import crypto from 'crypto';
import { isSecureCookie } from '@/lib/auth/utils';
// POST /api/lock - Verify password // POST /api/lock - Verify password
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -41,12 +42,11 @@ export async function POST(request: NextRequest) {
message: 'Password verified', message: 'Password verified',
}); });
// Set an unlock cookie that expires in 24 hours
response.cookies.set('site_unlocked', 'true', { response.cookies.set('site_unlocked', 'true', {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isSecureCookie(request),
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24,
path: '/', path: '/',
}); });

View File

@@ -128,3 +128,16 @@ export function validateAdminCredentials(username: string, password: string): bo
return username === adminUsername && password === adminPassword; return username === adminUsername && password === adminPassword;
} }
export function isSecureCookie(request: { headers: { get(name: string): string | null }; url: string }): boolean {
if (process.env.COOKIE_SECURE !== undefined) {
return process.env.COOKIE_SECURE === 'true';
}
if (process.env.NODE_ENV === 'production') {
return (
request.headers.get('x-forwarded-proto') === 'https' ||
request.url.startsWith('https://')
);
}
return false;
}