1. refactor navigation + add additional nav items
2. authentication changes
This commit is contained in:
@@ -1,20 +1,20 @@
|
|||||||
/* Desktop navigation - visible on medium screens and up */
|
/* Desktop navigation - visible on larger screens */
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 1280px) {
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile navigation - visible below medium screens */
|
/* Mobile navigation - visible below larger screens */
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 1280px) {
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 1280px) {
|
||||||
.mobile-menu-dropdown {
|
.mobile-menu-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -56,14 +56,14 @@ nav {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 1280px) {
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
gap: clamp(0.75rem, 1.5vw, 1.25rem);
|
gap: clamp(0.5rem, 1vw, 1rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,17 +72,20 @@ nav {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav-list li {
|
.desktop-nav-list li {
|
||||||
flex-shrink: 1;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav-list a {
|
.desktop-nav-list a {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-user-inline {
|
.nav-user-inline {
|
||||||
|
|||||||
@@ -14,6 +14,96 @@ function clearCookie(name: string): void {
|
|||||||
document.cookie = `${name}=; Max-Age=0; path=/;`;
|
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 {
|
interface LoginPageProps {
|
||||||
loggedIn?: boolean;
|
loggedIn?: boolean;
|
||||||
accessDenied?: 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<HTMLFormElement>): Promise<void> {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -86,11 +186,8 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
toast.success("Login successful!", {
|
toast.success("Login successful!", {
|
||||||
toastId: "login-success-toast",
|
toastId: "login-success-toast",
|
||||||
});
|
});
|
||||||
// Check for returnUrl in query params
|
// After fresh login, allow redirect to external trusted URLs
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const returnTo = getPostLoginRedirectUrl();
|
||||||
const returnUrl = urlParams.get('returnUrl');
|
|
||||||
// Validate returnUrl is a relative path (security: prevent open redirect)
|
|
||||||
const returnTo = (returnUrl && returnUrl.startsWith('/')) ? returnUrl : '/';
|
|
||||||
window.location.href = returnTo;
|
window.location.href = returnTo;
|
||||||
} else {
|
} else {
|
||||||
toast.error("Login failed: no access token received", {
|
toast.error("Login failed: no access token received", {
|
||||||
@@ -109,6 +206,10 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []);
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center px-4 py-16">
|
<div className="flex items-center justify-center px-4 py-16">
|
||||||
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
|
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
|
||||||
@@ -117,11 +218,11 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
You don't have permission to access this resource.
|
You don't have permission to access this resource.
|
||||||
</p>
|
</p>
|
||||||
{rolesList.length > 0 && (
|
{displayRoles.length > 0 && (
|
||||||
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{rolesList.length > 1 ? 's' : ''}:</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{displayRoles.length > 1 ? 's' : ''}:</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{rolesList.map((role, i) => (
|
{displayRoles.map((role, i) => (
|
||||||
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
|
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
|
||||||
{role}
|
{role}
|
||||||
</span>
|
</span>
|
||||||
@@ -129,6 +230,13 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isExternalDenial && displayRoles.length === 0 && (
|
||||||
|
<div className="mb-5 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200/60 dark:border-amber-700/40">
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
This resource requires elevated permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
|
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
|
||||||
If you believe this is an error, scream at codey.
|
If you believe this is an error, scream at codey.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ interface RequestJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
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() {
|
export default function RequestManagement() {
|
||||||
const [requests, setRequests] = useState<RequestJob[]>([]);
|
const [requests, setRequests] = useState<RequestJob[]>([]);
|
||||||
|
|||||||
@@ -22,9 +22,19 @@ const navItems = [
|
|||||||
{ label: "Memes", href: "/memes" },
|
{ label: "Memes", href: "/memes" },
|
||||||
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
||||||
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
||||||
{ label: "Lighting", href: "/lighting", auth: true },
|
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true },
|
||||||
{ label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
|
||||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
{ 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 },
|
{ label: "Login", href: "/login", guestOnly: true },
|
||||||
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||||||
];
|
];
|
||||||
@@ -34,6 +44,10 @@ const visibleNavItems = navItems.filter((item) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.adminOnly && !isAdmin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.guestOnly && isLoggedIn) {
|
if (item.guestOnly && isLoggedIn) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -155,7 +169,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
<!-- Mobile Navigation Menu -->
|
<!-- Mobile Navigation Menu -->
|
||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
class="mobile-menu-dropdown md:hidden"
|
class="mobile-menu-dropdown xl:hidden"
|
||||||
>
|
>
|
||||||
{isLoggedIn && userDisplayName && (
|
{isLoggedIn && userDisplayName && (
|
||||||
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
||||||
|
|||||||
@@ -5,8 +5,56 @@ import Root from "@/components/AppLayout.jsx";
|
|||||||
import { requireAuthHook } from '@/hooks/requireAuthHook';
|
import { requireAuthHook } from '@/hooks/requireAuthHook';
|
||||||
const user = await requireAuthHook(Astro);
|
const user = await requireAuthHook(Astro);
|
||||||
const isLoggedIn = Boolean(user);
|
const isLoggedIn = Boolean(user);
|
||||||
const accessDenied = Astro.locals.accessDenied || false;
|
// Detect if returnUrl is external (nginx-protected vhost redirect)
|
||||||
const requiredRoles = Astro.locals.requiredRoles || [];
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
---
|
---
|
||||||
<Base title="Login">
|
<Base title="Login">
|
||||||
|
|||||||
203
src/utils/jwt.ts
203
src/utils/jwt.ts
@@ -1,26 +1,27 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import fs from 'fs';
|
import crypto from 'crypto';
|
||||||
import path from 'path';
|
import { API_URL } from '../config';
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
// JWT keys location - can be configured via environment variable
|
// JWKS endpoint for fetching RSA public keys
|
||||||
// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.)
|
const JWKS_URL = `${API_URL}/auth/jwks`;
|
||||||
const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join(
|
|
||||||
os.homedir(),
|
|
||||||
'.config',
|
|
||||||
'api_jwt_keys.json'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Warn if using default location in production
|
// Cache for JWKS keys
|
||||||
if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
|
interface JwkKey {
|
||||||
console.warn(
|
kty: string;
|
||||||
'[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' +
|
use?: string;
|
||||||
'Consider using a secret management service in production.'
|
kid: string;
|
||||||
);
|
alg?: string;
|
||||||
|
n: string;
|
||||||
|
e: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JwtKeyFile {
|
interface JwksResponse {
|
||||||
keys: Record<string, string>;
|
keys: JwkKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedKeys {
|
||||||
|
keys: Map<string, string>; // kid -> PEM public key
|
||||||
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@@ -31,16 +32,98 @@ export interface JwtPayload {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and parse keys JSON once at startup
|
// Cache TTL: 5 minutes (keys are refreshed on cache miss or verification failure)
|
||||||
let keyFileData: JwtKeyFile;
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
try {
|
|
||||||
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
let cachedKeys: CachedKeys | null = null;
|
||||||
} 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.');
|
* 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<Map<string, string>> {
|
||||||
|
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<string, string>();
|
||||||
|
|
||||||
|
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<Map<string, string>> {
|
||||||
|
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<JwtPayload | null> {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -52,14 +135,22 @@ export function verifyToken(token: string | null | undefined): JwtPayload | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kid = decoded.header.kid;
|
const kid = decoded.header.kid;
|
||||||
const key = keyFileData.keys[kid];
|
let keys = await getKeys();
|
||||||
|
let publicKey = keys.get(kid);
|
||||||
|
|
||||||
if (!key) {
|
// If key not found, try refreshing JWKS (handles key rotation)
|
||||||
throw new Error(`Unknown kid: ${kid}`);
|
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
|
// Verify using RS256
|
||||||
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
|
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload;
|
||||||
return payload;
|
return payload;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -67,3 +158,53 @@ export function verifyToken(token: string | null | undefined): JwtPayload | null
|
|||||||
return 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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user