From af73e162c55b8510570bd4e2b61716895af26182 Mon Sep 17 00:00:00 2001 From: codey Date: Sat, 7 Feb 2026 21:17:41 -0500 Subject: [PATCH] 1. refactor navigation + add additional nav items 2. authentication changes --- src/assets/styles/nav.css | 23 +-- src/components/Login.tsx | 124 ++++++++++++- src/components/TRip/RequestManagement.tsx | 2 +- src/layouts/Nav.astro | 20 ++- src/pages/login.astro | 52 +++++- src/utils/jwt.ts | 203 ++++++++++++++++++---- 6 files changed, 369 insertions(+), 55 deletions(-) diff --git a/src/assets/styles/nav.css b/src/assets/styles/nav.css index 44bd64b..834a90c 100644 --- a/src/assets/styles/nav.css +++ b/src/assets/styles/nav.css @@ -1,20 +1,20 @@ -/* Desktop navigation - visible on medium screens and up */ +/* Desktop navigation - visible on larger screens */ .desktop-nav { display: none; } -@media (min-width: 768px) { +@media (min-width: 1280px) { .desktop-nav { display: flex; } } -/* Mobile navigation - visible below medium screens */ +/* Mobile navigation - visible below larger screens */ .mobile-nav { display: flex; } -@media (min-width: 768px) { +@media (min-width: 1280px) { .mobile-nav { display: none; } @@ -42,7 +42,7 @@ border-radius: 12px; } -@media (min-width: 768px) { +@media (min-width: 1280px) { .mobile-menu-dropdown { display: none; } @@ -56,14 +56,14 @@ nav { width: 100%; } -@media (min-width: 768px) { +@media (min-width: 1280px) { .desktop-nav { flex: 1; display: flex; align-items: center; min-width: 0; margin-left: 2rem; - gap: clamp(0.75rem, 1.5vw, 1.25rem); + gap: clamp(0.5rem, 1vw, 1rem); } } @@ -72,17 +72,20 @@ nav { flex: 1; justify-content: flex-end; align-items: center; - gap: clamp(0.75rem, 1.5vw, 1.25rem); + flex-wrap: nowrap; + overflow: visible; + gap: clamp(0.25rem, 0.75vw, 0.75rem); margin: 0; - padding: 0; + padding: 0 0.25rem; } .desktop-nav-list li { - flex-shrink: 1; + flex-shrink: 0; } .desktop-nav-list a { white-space: nowrap; + padding: 0.375rem 0.625rem; } .nav-user-inline { diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 8ee5041..7418106 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -14,6 +14,96 @@ function clearCookie(name: string): void { document.cookie = `${name}=; Max-Age=0; path=/;`; } +// Trusted domains for redirect validation +const TRUSTED_DOMAINS = ['codey.lol', 'boatson.boats']; + +/** + * Parse and decode the redirect URL from query params + */ +function getRedirectParam(): string | null { + const urlParams = new URLSearchParams(window.location.search); + let redirectParam = urlParams.get('redirect') || urlParams.get('returnUrl'); + + if (!redirectParam) return null; + + // Handle double-encoding + if (redirectParam.includes('%')) { + try { + redirectParam = decodeURIComponent(redirectParam); + } catch { + // If decoding fails, use the original value + } + } + return redirectParam; +} + +/** + * Check if a URL is a valid trusted external URL + */ +function isTrustedExternalUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return false; + return TRUSTED_DOMAINS.some(domain => + parsed.hostname === domain || parsed.hostname.endsWith('.' + domain) + ); + } catch { + return false; + } +} + +/** + * Get validated redirect URL for POST-LOGIN redirect (after form submission) + * Allows: relative paths OR trusted external URLs + * This is safe because the user just authenticated fresh + */ +function getPostLoginRedirectUrl(): string { + const redirectParam = getRedirectParam(); + if (!redirectParam) return '/'; + + // Relative path: must start with '/' but not '//' (protocol-relative) + if (redirectParam.startsWith('/') && !redirectParam.startsWith('//')) { + return redirectParam; + } + + // External URL: must be https and trusted domain + if (isTrustedExternalUrl(redirectParam)) { + return redirectParam; + } + + return '/'; +} + +/** + * Get validated redirect URL for AUTO-REDIRECT (when already logged in) + * Only allows: relative paths + * External URLs are BLOCKED because if a logged-in user was redirected here + * from an external resource, nginx denied them access - redirecting back would loop + */ +function getAutoRedirectUrl(): string | null { + const redirectParam = getRedirectParam(); + if (!redirectParam) return null; + + // Only allow relative paths for auto-redirect + if (redirectParam.startsWith('/') && !redirectParam.startsWith('//')) { + return redirectParam; + } + + // Block external URLs - would cause redirect loop with nginx + return null; +} + +/** + * Check if the returnUrl is an external trusted domain + * Used to detect access denial from nginx-protected external vhosts + */ +function isExternalReturnUrl(): boolean { + const redirectParam = getRedirectParam(); + if (!redirectParam) return false; + if (redirectParam.startsWith('/')) return false; + return isTrustedExternalUrl(redirectParam); +} + interface LoginPageProps { loggedIn?: boolean; accessDenied?: boolean; @@ -33,6 +123,16 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ } }, []); + // If user is already logged in and not access denied, redirect them + // This handles the case where token was refreshed successfully + // NOTE: Only auto-redirect to relative URLs - external URLs mean nginx denied access + useEffect(() => { + if (loggedIn && !accessDenied) { + const returnTo = getAutoRedirectUrl() || '/'; + window.location.href = returnTo; + } + }, [loggedIn, accessDenied]); + async function handleSubmit(e: FormEvent): Promise { e.preventDefault(); setLoading(true); @@ -86,11 +186,8 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ toast.success("Login successful!", { toastId: "login-success-toast", }); - // Check for returnUrl in query params - const urlParams = new URLSearchParams(window.location.search); - const returnUrl = urlParams.get('returnUrl'); - // Validate returnUrl is a relative path (security: prevent open redirect) - const returnTo = (returnUrl && returnUrl.startsWith('/')) ? returnUrl : '/'; + // After fresh login, allow redirect to external trusted URLs + const returnTo = getPostLoginRedirectUrl(); window.location.href = returnTo; } else { toast.error("Login failed: no access token received", { @@ -109,6 +206,10 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ if (loggedIn) { const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []); + // Check if this is an external resource denial (nginx redirect) + const isExternalDenial = rolesList.some(r => r === '(external resource)'); + const displayRoles = rolesList.filter(r => r !== '(external resource)'); + return (
@@ -117,11 +218,11 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ

You don't have permission to access this resource.

- {rolesList.length > 0 && ( + {displayRoles.length > 0 && (
-

Required role{rolesList.length > 1 ? 's' : ''}:

+

Required role{displayRoles.length > 1 ? 's' : ''}:

- {rolesList.map((role, i) => ( + {displayRoles.map((role, i) => ( {role} @@ -129,6 +230,13 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
)} + {isExternalDenial && displayRoles.length === 0 && ( +
+

+ This resource requires elevated permissions. +

+
+ )}

If you believe this is an error, scream at codey.

diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx index 7a494e3..9534617 100644 --- a/src/components/TRip/RequestManagement.tsx +++ b/src/components/TRip/RequestManagement.tsx @@ -36,7 +36,7 @@ interface RequestJob { } const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"]; -const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix +const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix export default function RequestManagement() { const [requests, setRequests] = useState([]); diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index f7324a4..4598968 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -22,9 +22,19 @@ const navItems = [ { label: "Memes", href: "/memes" }, { label: "TRip", href: "/TRip", auth: true, icon: "pirate" }, { label: "Discord Logs", href: "/discord-logs", auth: true }, - { label: "Lighting", href: "/lighting", auth: true }, - { label: "Status", href: "https://status.boatson.boats", icon: "external" }, + { label: "Lighting", href: "/lighting", auth: true, adminOnly: true }, { label: "Git", href: "https://kode.boatson.boats", icon: "external" }, + { label: "Glances", href: "https://_gl.codey.lol", auth: true, icon: "external", + adminOnly: true }, + { label: "PSQL", href: "https://_pg.codey.lol", auth: true, icon: "external", + adminOnly: true }, + { label: "qBitTorrent", href: "https://_qb.codey.lol", auth: true, icon: "external", + adminOnly: true }, + { label: "RQ", href: "https://_rq.codey.lol", auth: true, icon: "external", + adminOnly: true }, + { label: "RI", href: "https://_r0.codey.lol", auth: true, icon: "external", + adminOnly: true }, + // { label: "Status", href: "https://status.boatson.boats", icon: "external" }, { label: "Login", href: "/login", guestOnly: true }, ...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []), ]; @@ -34,6 +44,10 @@ const visibleNavItems = navItems.filter((item) => { return false; } + if (item.adminOnly && !isAdmin) { + return false; + } + if (item.guestOnly && isLoggedIn) { return false; } @@ -155,7 +169,7 @@ const currentPath = Astro.url.pathname;
{isLoggedIn && userDisplayName && (
diff --git a/src/pages/login.astro b/src/pages/login.astro index cbc19c2..53fa75f 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -5,8 +5,56 @@ import Root from "@/components/AppLayout.jsx"; import { requireAuthHook } from '@/hooks/requireAuthHook'; const user = await requireAuthHook(Astro); const isLoggedIn = Boolean(user); -const accessDenied = Astro.locals.accessDenied || false; -const requiredRoles = Astro.locals.requiredRoles || []; +// Detect if returnUrl is external (nginx-protected vhost redirect) +// If logged-in user arrives with external returnUrl, nginx denied them - show access denied +const returnUrl = Astro.url.searchParams.get('returnUrl') || Astro.url.searchParams.get('redirect'); +const trustedDomains = ['codey.lol', 'boatson.boats']; +let isExternalReturn = false; +let externalHostname = ''; + +if (returnUrl && !returnUrl.startsWith('/')) { + try { + const url = new URL(returnUrl); + if (url.protocol === 'https:') { + const isTrusted = trustedDomains.some(domain => + url.hostname === domain || url.hostname.endsWith('.' + domain) + ); + if (isTrusted) { + isExternalReturn = true; + externalHostname = url.hostname; + } + } + } catch { + // Invalid URL + } +} + +// If logged in and redirected from an external nginx-protected resource, +// nginx denied access (user lacks role) - treat as access denied to prevent redirect loop +const accessDenied = Astro.locals.accessDenied || (isLoggedIn && isExternalReturn); +const requiredRoles = Astro.locals.requiredRoles || (isExternalReturn ? ['(external resource)'] : []); + +// If user is authenticated and arrived with a returnUrl, redirect them there +// This handles the case where access_token expired but refresh_token was still valid +// BUT only for relative URLs - external URLs that sent us here mean access was denied +if (isLoggedIn && !accessDenied) { + if (returnUrl) { + // Validate the return URL for security + let isValidRedirect = false; + + // Only allow relative paths for auto-redirect + // External URLs arriving here mean nginx denied access, so don't redirect back + if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) { + isValidRedirect = true; + } + + if (isValidRedirect) { + return Astro.redirect(returnUrl, 302); + } + } + // No returnUrl or external URL (access denied case handled above) - redirect to home + return Astro.redirect('/', 302); +} --- diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 3ed121f..f8a3415 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,26 +1,27 @@ import jwt from 'jsonwebtoken'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; +import crypto from 'crypto'; +import { API_URL } from '../config'; -// JWT keys location - can be configured via environment variable -// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.) -const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join( - os.homedir(), - '.config', - 'api_jwt_keys.json' -); +// JWKS endpoint for fetching RSA public keys +const JWKS_URL = `${API_URL}/auth/jwks`; -// Warn if using default location in production -if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) { - console.warn( - '[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' + - 'Consider using a secret management service in production.' - ); +// Cache for JWKS keys +interface JwkKey { + kty: string; + use?: string; + kid: string; + alg?: string; + n: string; + e: string; } -interface JwtKeyFile { - keys: Record; +interface JwksResponse { + keys: JwkKey[]; +} + +interface CachedKeys { + keys: Map; // kid -> PEM public key + fetchedAt: number; } export interface JwtPayload { @@ -31,16 +32,98 @@ export interface JwtPayload { [key: string]: unknown; } -// Load and parse keys JSON once at startup -let keyFileData: JwtKeyFile; -try { - keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8')); -} catch (err) { - console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, (err as Error).message); - throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.'); +// Cache TTL: 5 minutes (keys are refreshed on cache miss or verification failure) +const CACHE_TTL_MS = 5 * 60 * 1000; + +let cachedKeys: CachedKeys | null = null; + +/** + * Convert a JWK RSA public key to PEM format + */ +function jwkToPem(jwk: JwkKey): string { + if (jwk.kty !== 'RSA') { + throw new Error(`Unsupported key type: ${jwk.kty}`); + } + + // Decode base64url-encoded modulus and exponent + const n = Buffer.from(jwk.n, 'base64url'); + const e = Buffer.from(jwk.e, 'base64url'); + + // Build the RSA public key using Node's crypto + const publicKey = crypto.createPublicKey({ + key: { + kty: 'RSA', + n: jwk.n, + e: jwk.e, + }, + format: 'jwk', + }); + + return publicKey.export({ type: 'spki', format: 'pem' }) as string; } -export function verifyToken(token: string | null | undefined): JwtPayload | null { +/** + * Fetch JWKS from the API and cache the keys + */ +async function fetchJwks(): Promise> { + try { + const response = await fetch(JWKS_URL, { + headers: { 'Accept': 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`); + } + + const jwks: JwksResponse = await response.json(); + const keys = new Map(); + + for (const jwk of jwks.keys) { + if (jwk.kty === 'RSA' && jwk.kid) { + try { + const pem = jwkToPem(jwk); + keys.set(jwk.kid, pem); + } catch (err) { + console.warn(`Failed to convert JWK ${jwk.kid} to PEM:`, (err as Error).message); + } + } + } + + if (keys.size === 0) { + throw new Error('No valid RSA keys found in JWKS'); + } + + cachedKeys = { + keys, + fetchedAt: Date.now(), + }; + + console.log(`[JWT] Fetched ${keys.size} RSA public key(s) from JWKS`); + return keys; + + } catch (error) { + console.error('[JWT] Failed to fetch JWKS:', (error as Error).message); + throw error; + } +} + +/** + * Get cached keys or fetch fresh ones + */ +async function getKeys(forceRefresh = false): Promise> { + const now = Date.now(); + + if (!forceRefresh && cachedKeys && (now - cachedKeys.fetchedAt) < CACHE_TTL_MS) { + return cachedKeys.keys; + } + + return fetchJwks(); +} + +/** + * Verify a JWT token using RS256 with keys from JWKS + */ +export async function verifyToken(token: string | null | undefined): Promise { if (!token) { return null; } @@ -52,14 +135,22 @@ export function verifyToken(token: string | null | undefined): JwtPayload | null } const kid = decoded.header.kid; - const key = keyFileData.keys[kid]; + let keys = await getKeys(); + let publicKey = keys.get(kid); - if (!key) { - throw new Error(`Unknown kid: ${kid}`); + // If key not found, try refreshing JWKS (handles key rotation) + if (!publicKey) { + console.log(`[JWT] Key ${kid} not in cache, refreshing JWKS...`); + keys = await getKeys(true); + publicKey = keys.get(kid); + + if (!publicKey) { + throw new Error(`Unknown kid: ${kid}`); + } } - // Verify using the correct key and HS256 algo - const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload; + // Verify using RS256 + const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload; return payload; } catch (error) { @@ -67,3 +158,53 @@ export function verifyToken(token: string | null | undefined): JwtPayload | null return null; } } + +/** + * Synchronous verification - uses cached keys only (for middleware compatibility) + * Falls back to null if cache is empty; caller should handle async verification + */ +export function verifyTokenSync(token: string | null | undefined): JwtPayload | null { + if (!token) { + return null; + } + + if (!cachedKeys || cachedKeys.keys.size === 0) { + // No cached keys - caller needs to use async version + return null; + } + + try { + const decoded = jwt.decode(token, { complete: true }); + if (!decoded?.header?.kid) { + throw new Error('No kid in token header'); + } + + const kid = decoded.header.kid; + const publicKey = cachedKeys.keys.get(kid); + + if (!publicKey) { + // Key not found - might need refresh, return null to signal async verification needed + return null; + } + + // Verify using RS256 + const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload; + return payload; + + } catch (error) { + console.error('JWT verification failed:', (error as Error).message); + return null; + } +} + +/** + * Pre-warm the JWKS cache at startup + */ +export async function initializeJwks(): Promise { + try { + await fetchJwks(); + } catch (error) { + console.error('[JWT] Failed to pre-warm JWKS cache:', (error as Error).message); + // Don't throw - allow lazy initialization on first verification + } +}