migrated
This commit is contained in:
@@ -17,6 +17,23 @@ interface TerminalErrorPageProps {
|
|||||||
disableTerminal?: boolean;
|
disableTerminal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safe router hook wrappers that return undefined if outside Route context
|
||||||
|
function useSafeNavigate() {
|
||||||
|
try {
|
||||||
|
return useNavigate();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSafeLocation() {
|
||||||
|
try {
|
||||||
|
return useLocation();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function TerminalErrorPage(props: TerminalErrorPageProps) {
|
export function TerminalErrorPage(props: TerminalErrorPageProps) {
|
||||||
const [command, setCommand] = createSignal("");
|
const [command, setCommand] = createSignal("");
|
||||||
const [history, setHistory] = createSignal<CommandHistoryItem[]>([]);
|
const [history, setHistory] = createSignal<CommandHistoryItem[]>([]);
|
||||||
@@ -24,8 +41,8 @@ export function TerminalErrorPage(props: TerminalErrorPageProps) {
|
|||||||
const [btopOpen, setBtopOpen] = createSignal(false);
|
const [btopOpen, setBtopOpen] = createSignal(false);
|
||||||
let inputRef: HTMLInputElement | undefined;
|
let inputRef: HTMLInputElement | undefined;
|
||||||
let footerRef: HTMLDivElement | undefined;
|
let footerRef: HTMLDivElement | undefined;
|
||||||
const navigate = useNavigate();
|
const navigate = useSafeNavigate();
|
||||||
const location = useLocation();
|
const location = useSafeLocation();
|
||||||
const { isDark } = useDarkMode();
|
const { isDark } = useDarkMode();
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -107,6 +124,7 @@ export function TerminalErrorPage(props: TerminalErrorPageProps) {
|
|||||||
<div class="text-subtext1">Quick actions:</div>
|
<div class="text-subtext1">Quick actions:</div>
|
||||||
{props.quickActions}
|
{props.quickActions}
|
||||||
|
|
||||||
|
<Show when={navigate}>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate!("/")}
|
onClick={() => navigate!("/")}
|
||||||
class="group border-surface0 bg-mantle hover:border-blue hover:bg-surface0 flex w-full cursor-pointer items-center gap-2 border px-4 py-3 text-left transition-all"
|
class="group border-surface0 bg-mantle hover:border-blue hover:bg-surface0 flex w-full cursor-pointer items-center gap-2 border px-4 py-3 text-left transition-all"
|
||||||
@@ -118,6 +136,7 @@ export function TerminalErrorPage(props: TerminalErrorPageProps) {
|
|||||||
[Return home]
|
[Return home]
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => window.history.back()}
|
onClick={() => window.history.back()}
|
||||||
|
|||||||
@@ -51,26 +51,25 @@ interface AuthContextType {
|
|||||||
const AuthContext = createContext<AuthContextType>();
|
const AuthContext = createContext<AuthContextType>();
|
||||||
|
|
||||||
export const AuthProvider: ParentComponent = (props) => {
|
export const AuthProvider: ParentComponent = (props) => {
|
||||||
// Signal to force re-fetch when auth state changes
|
// Get server state using createAsync which works with cache()
|
||||||
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
|
const serverAuth = createAsync(() => getUserState(), { deferStream: true });
|
||||||
|
|
||||||
// Get server state via SolidStart query - tracks refreshTrigger for reactivity
|
// Refresh callback that forces re-fetch
|
||||||
const serverAuth = createAsync(
|
|
||||||
() => {
|
|
||||||
refreshTrigger(); // Track the signal to force re-run
|
|
||||||
return getUserState();
|
|
||||||
},
|
|
||||||
{ deferStream: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh callback that invalidates cache and forces re-fetch
|
|
||||||
const refreshAuth = () => {
|
const refreshAuth = () => {
|
||||||
revalidate(getUserState.key);
|
// Manually trigger a re-fetch by calling the revalidate function
|
||||||
setRefreshTrigger((prev) => prev + 1); // Trigger re-fetch
|
revalidate(["user-auth-state"]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Server-side refresh in getUserState() handles auto-signin during SSR
|
// Convenience accessors with safe defaults - MUST BE DEFINED BEFORE onMount
|
||||||
// No client-side fallback needed - server handles everything with httpOnly cookies
|
const isAuthenticated = () => serverAuth()?.isAuthenticated ?? false;
|
||||||
|
const email = () => serverAuth()?.email ?? null;
|
||||||
|
const displayName = () => serverAuth()?.displayName ?? null;
|
||||||
|
const userId = () => serverAuth()?.userId ?? null;
|
||||||
|
const isAdmin = () => serverAuth()?.privilegeLevel === "admin";
|
||||||
|
const isEmailVerified = () => serverAuth()?.emailVerified ?? false;
|
||||||
|
|
||||||
|
// Server handles all token refresh logic
|
||||||
|
// Client just displays the current auth state from server
|
||||||
|
|
||||||
// Listen for auth refresh events from external sources (token refresh, etc.)
|
// Listen for auth refresh events from external sources (token refresh, etc.)
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -88,14 +87,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convenience accessors with safe defaults
|
|
||||||
const isAuthenticated = () => serverAuth()?.isAuthenticated ?? false;
|
|
||||||
const email = () => serverAuth()?.email ?? null;
|
|
||||||
const displayName = () => serverAuth()?.displayName ?? null;
|
|
||||||
const userId = () => serverAuth()?.userId ?? null;
|
|
||||||
const isAdmin = () => serverAuth()?.privilegeLevel === "admin";
|
|
||||||
const isEmailVerified = () => serverAuth()?.emailVerified ?? false;
|
|
||||||
|
|
||||||
// Start/stop token refresh manager based on auth state
|
// Start/stop token refresh manager based on auth state
|
||||||
let previousAuth: boolean | undefined = undefined;
|
let previousAuth: boolean | undefined = undefined;
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
/**
|
|
||||||
* Shared Auth Query
|
|
||||||
* Single source of truth for authentication state across the app
|
|
||||||
*
|
|
||||||
* Security Model:
|
|
||||||
* - Server query reads from httpOnly cookies (secure)
|
|
||||||
* - Client context syncs from this query (UI convenience)
|
|
||||||
* - Server endpoints always validate independently (never trust client)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { query, revalidate as revalidateKey } from "@solidjs/router";
|
import { query, revalidate as revalidateKey } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
|
|
||||||
@@ -21,70 +11,31 @@ export interface UserState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global auth state query - single source of truth
|
* Get current user state from server
|
||||||
* Called on server during SSR, cached by SolidStart router
|
* Uses cache() to ensure single execution per request and proper SSR hydration
|
||||||
*/
|
*/
|
||||||
export const getUserState = query(async (): Promise<UserState> => {
|
export const getUserState = query(async (): Promise<UserState> => {
|
||||||
"use server";
|
"use server";
|
||||||
const { getPrivilegeLevel, getUserID } = await import("~/server/auth");
|
const { checkAuthStatus } = await import("~/server/auth");
|
||||||
const { ConnectionFactory } = await import("~/server/utils");
|
const { ConnectionFactory } = await import("~/server/utils");
|
||||||
const { getCookie, setCookie } = await import("vinxi/http");
|
|
||||||
const event = getRequestEvent()!;
|
|
||||||
|
|
||||||
let privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
const event = getRequestEvent();
|
||||||
let userId = await getUserID(event.nativeEvent);
|
|
||||||
|
|
||||||
// If no userId but refresh token exists, attempt server-side token refresh
|
// Safety check: if no event, we're not in a request context
|
||||||
// Use a flag cookie to prevent infinite loops (only try once per request)
|
if (!event || !event.nativeEvent) {
|
||||||
if (!userId) {
|
return {
|
||||||
const refreshToken = getCookie(event.nativeEvent, "refreshToken");
|
isAuthenticated: false,
|
||||||
const refreshAttempted = getCookie(event.nativeEvent, "_refresh_attempted");
|
userId: null,
|
||||||
|
email: null,
|
||||||
if (refreshToken && !refreshAttempted) {
|
displayName: null,
|
||||||
console.log(
|
emailVerified: false,
|
||||||
"[Auth-Query] Access token expired but refresh token exists, attempting server-side refresh"
|
privilegeLevel: "anonymous"
|
||||||
);
|
};
|
||||||
|
|
||||||
// Set flag to prevent retry loops (expires immediately, just for this request)
|
|
||||||
setCookie(event.nativeEvent, "_refresh_attempted", "1", {
|
|
||||||
maxAge: 1,
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Import token rotation function
|
|
||||||
const { attemptTokenRefresh } =
|
|
||||||
await import("~/server/api/routers/auth");
|
|
||||||
|
|
||||||
// Attempt to refresh tokens server-side
|
|
||||||
const refreshed = await attemptTokenRefresh(
|
|
||||||
event.nativeEvent,
|
|
||||||
refreshToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (refreshed) {
|
|
||||||
console.log("[Auth-Query] Server-side token refresh successful");
|
|
||||||
// Re-check auth state with new tokens
|
|
||||||
privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
|
||||||
userId = await getUserID(event.nativeEvent);
|
|
||||||
} else {
|
|
||||||
console.log("[Auth-Query] Server-side token refresh failed");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[Auth-Query] Error during server-side token refresh:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (refreshAttempted) {
|
|
||||||
console.log(
|
|
||||||
"[Auth-Query] Refresh already attempted this request, skipping"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId) {
|
const auth = await checkAuthStatus(event.nativeEvent);
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated || !auth.userId) {
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -98,7 +49,7 @@ export const getUserState = query(async (): Promise<UserState> => {
|
|||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const res = await conn.execute({
|
const res = await conn.execute({
|
||||||
sql: "SELECT email, display_name, email_verified FROM User WHERE id = ?",
|
sql: "SELECT email, display_name, email_verified FROM User WHERE id = ?",
|
||||||
args: [userId]
|
args: [auth.userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.rows.length === 0) {
|
if (res.rows.length === 0) {
|
||||||
@@ -116,22 +67,23 @@ export const getUserState = query(async (): Promise<UserState> => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
userId,
|
userId: auth.userId,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
displayName: user.display_name ?? null,
|
displayName: user.display_name ?? null,
|
||||||
emailVerified: user.email_verified === 1,
|
emailVerified: user.email_verified === 1,
|
||||||
privilegeLevel
|
privilegeLevel: auth.isAdmin ? "admin" : "user"
|
||||||
};
|
};
|
||||||
}, "global-auth-state");
|
}, "user-auth-state");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revalidate auth state globally
|
* Revalidate auth state globally
|
||||||
* Call this after login, logout, token refresh, email verification
|
* Call this after login, logout, token refresh, email verification
|
||||||
*/
|
*/
|
||||||
export function revalidateAuth() {
|
export function revalidateAuth() {
|
||||||
revalidateKey(getUserState.key);
|
// Revalidate the cache
|
||||||
|
revalidateKey("user-auth-state");
|
||||||
|
|
||||||
// Dispatch browser event to trigger UI updates (client-side only)
|
// Dispatch event to trigger UI updates (client-side only)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new CustomEvent("auth-state-changed"));
|
window.dispatchEvent(new CustomEvent("auth-state-changed"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { getCookie, getEvent, setCookie } from "vinxi/http";
|
import { getEvent, clearSession } from "vinxi/http";
|
||||||
|
import { sessionConfig } from "~/server/session-config";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
"use server";
|
"use server";
|
||||||
const event = getEvent()!;
|
const event = getEvent()!;
|
||||||
|
|
||||||
setCookie(event, "userIDToken", "", {
|
// Clear Vinxi session
|
||||||
path: "/",
|
await clearSession(event, sessionConfig);
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
maxAge: 0,
|
|
||||||
expires: new Date(0)
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|||||||
@@ -1158,8 +1158,7 @@ export default function TestPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="mb-2 text-lg font-semibold">🔴 Admin Required</h3>
|
<h3 class="mb-2 text-lg font-semibold">🔴 Admin Required</h3>
|
||||||
<p class="mb-2 text-sm">
|
<p class="mb-2 text-sm">
|
||||||
Maintenance endpoints require admin privileges (userIDToken
|
Maintenance endpoints require admin privileges.
|
||||||
cookie with ADMIN_ID).
|
|
||||||
</p>
|
</p>
|
||||||
<ul class="ml-6 list-disc space-y-1 text-sm">
|
<ul class="ml-6 list-disc space-y-1 text-sm">
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,10 +68,6 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const user = res.rows[0] as unknown as User;
|
const user = res.rows[0] as unknown as User;
|
||||||
|
|
||||||
setCookie(ctx.event.nativeEvent, "emailToken", email, {
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
|
|
||||||
return toUserProfile(user);
|
return toUserProfile(user);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -194,15 +190,6 @@ export const userRouter = createTRPCRouter({
|
|||||||
args: [newPasswordHash, userId]
|
args: [newPasswordHash, userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(ctx.event.nativeEvent, "emailToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: "success" };
|
return { success: true, message: "success" };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -255,15 +242,6 @@ export const userRouter = createTRPCRouter({
|
|||||||
args: [passwordHash, userId]
|
args: [passwordHash, userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(ctx.event.nativeEvent, "emailToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: "success" };
|
return { success: true, message: "success" };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -324,15 +302,6 @@ export const userRouter = createTRPCRouter({
|
|||||||
args: [null, 0, null, "user deleted", null, null, userId]
|
args: [null, 0, null, "user deleted", null, null, userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie(ctx.event.nativeEvent, "emailToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: "deleted" };
|
return { success: true, message: "deleted" };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { getCookie, setCookie } from "vinxi/http";
|
import { getCookie } from "vinxi/http";
|
||||||
import { jwtVerify, type JWTPayload } from "jose";
|
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||||
import { getRequestIP } from "vinxi/http";
|
import { getRequestIP } from "vinxi/http";
|
||||||
|
import { getAuthSession } from "~/server/session-helpers";
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
event: APIEvent;
|
event: APIEvent;
|
||||||
@@ -13,26 +13,15 @@ export type Context = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function createContextInner(event: APIEvent): Promise<Context> {
|
async function createContextInner(event: APIEvent): Promise<Context> {
|
||||||
const userIDToken = getCookie(event.nativeEvent, "userIDToken");
|
// Get auth session from Vinxi encrypted session
|
||||||
|
const session = await getAuthSession(event.nativeEvent);
|
||||||
|
|
||||||
let userId: string | null = null;
|
let userId: string | null = null;
|
||||||
let privilegeLevel: "anonymous" | "user" | "admin" = "anonymous";
|
let privilegeLevel: "anonymous" | "user" | "admin" = "anonymous";
|
||||||
|
|
||||||
if (userIDToken) {
|
if (session && session.userId) {
|
||||||
try {
|
userId = session.userId;
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
privilegeLevel = session.isAdmin ? "admin" : "user";
|
||||||
const { payload } = await jwtVerify(userIDToken, secret);
|
|
||||||
|
|
||||||
if (payload.id && typeof payload.id === "string") {
|
|
||||||
userId = payload.id;
|
|
||||||
privilegeLevel = payload.id === env.ADMIN_ID ? "admin" : "user";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setCookie(event.nativeEvent, "userIDToken", "", {
|
|
||||||
maxAge: 0,
|
|
||||||
expires: new Date("2016-10-05")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type AuditEventType =
|
|||||||
| "security.csrf.failed"
|
| "security.csrf.failed"
|
||||||
| "security.suspicious.activity"
|
| "security.suspicious.activity"
|
||||||
| "admin.action"
|
| "admin.action"
|
||||||
|
| "auth.session_created"
|
||||||
| "system.session_cleanup";
|
| "system.session_cleanup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,191 +1,63 @@
|
|||||||
import { getCookie, setCookie, type H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import { jwtVerify } from "jose";
|
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import type { Row } from "@libsql/client/web";
|
import type { Row } from "@libsql/client/web";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { ConnectionFactory } from "./database";
|
import { getAuthSession } from "./session-helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract cookie value from H3Event (works in both production and tests)
|
* Check authentication status
|
||||||
* Falls back to manual header parsing if vinxi's getCookie fails
|
* Consolidates getUserID, getPrivilegeLevel, and checkAuthStatus into single function
|
||||||
|
* @param event - H3Event
|
||||||
|
* @returns Object with isAuthenticated, userId, and isAdmin flags
|
||||||
*/
|
*/
|
||||||
function getCookieValue(event: H3Event, name: string): string | undefined {
|
|
||||||
try {
|
|
||||||
// Try vinxi's getCookie first
|
|
||||||
return getCookie(event, name);
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback for tests: parse cookie header manually
|
|
||||||
try {
|
|
||||||
const cookieHeader =
|
|
||||||
event.headers?.get("cookie") || event.node?.req?.headers?.cookie || "";
|
|
||||||
const cookies = cookieHeader
|
|
||||||
.split(";")
|
|
||||||
.map((c) => c.trim())
|
|
||||||
.reduce(
|
|
||||||
(acc, cookie) => {
|
|
||||||
const [key, value] = cookie.split("=");
|
|
||||||
if (key && value) acc[key] = value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>
|
|
||||||
);
|
|
||||||
return cookies[name];
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cookie (works in both production and tests)
|
|
||||||
*/
|
|
||||||
function clearCookie(event: H3Event, name: string): void {
|
|
||||||
try {
|
|
||||||
setCookie(event, name, "", {
|
|
||||||
maxAge: 0,
|
|
||||||
expires: new Date("2016-10-05")
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// In tests, setCookie might fail silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate session and update last_used timestamp
|
|
||||||
* @param sessionId - Session ID from JWT
|
|
||||||
* @param userId - User ID from JWT
|
|
||||||
* @returns true if session is valid, false otherwise
|
|
||||||
*/
|
|
||||||
async function validateSession(
|
|
||||||
sessionId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const conn = ConnectionFactory();
|
|
||||||
const result = await conn.execute({
|
|
||||||
sql: `SELECT revoked, expires_at FROM Session
|
|
||||||
WHERE id = ? AND user_id = ?`,
|
|
||||||
args: [sessionId, userId]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
// Session doesn't exist
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = result.rows[0];
|
|
||||||
|
|
||||||
// Check if session is revoked
|
|
||||||
if (session.revoked === 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session is expired
|
|
||||||
const expiresAt = new Date(session.expires_at as string);
|
|
||||||
if (expiresAt < new Date()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last_used timestamp (fire and forget, don't block)
|
|
||||||
conn
|
|
||||||
.execute({
|
|
||||||
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
|
|
||||||
args: [sessionId]
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
|
||||||
console.error("Failed to update session last_used:", err)
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Session validation error:", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPrivilegeLevel(
|
|
||||||
event: H3Event
|
|
||||||
): Promise<"anonymous" | "admin" | "user"> {
|
|
||||||
try {
|
|
||||||
const userIDToken = getCookieValue(event, "userIDToken");
|
|
||||||
|
|
||||||
if (userIDToken) {
|
|
||||||
try {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const { payload } = await jwtVerify(userIDToken, secret);
|
|
||||||
|
|
||||||
if (payload.id && typeof payload.id === "string") {
|
|
||||||
// Validate session if session ID is present
|
|
||||||
if (payload.sid) {
|
|
||||||
const isValidSession = await validateSession(
|
|
||||||
payload.sid as string,
|
|
||||||
payload.id
|
|
||||||
);
|
|
||||||
if (!isValidSession) {
|
|
||||||
clearCookie(event, "userIDToken");
|
|
||||||
return "anonymous";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.id === env.ADMIN_ID ? "admin" : "user";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently clear invalid token (401s are expected for non-authenticated users)
|
|
||||||
clearCookie(event, "userIDToken");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return "anonymous";
|
|
||||||
}
|
|
||||||
return "anonymous";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserID(event: H3Event): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const userIDToken = getCookieValue(event, "userIDToken");
|
|
||||||
|
|
||||||
if (userIDToken) {
|
|
||||||
try {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const { payload } = await jwtVerify(userIDToken, secret);
|
|
||||||
|
|
||||||
if (payload.id && typeof payload.id === "string") {
|
|
||||||
// Validate session if session ID is present
|
|
||||||
if (payload.sid) {
|
|
||||||
const isValidSession = await validateSession(
|
|
||||||
payload.sid as string,
|
|
||||||
payload.id
|
|
||||||
);
|
|
||||||
if (!isValidSession) {
|
|
||||||
clearCookie(event, "userIDToken");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently clear invalid token (401s are expected for non-authenticated users)
|
|
||||||
clearCookie(event, "userIDToken");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAuthStatus(event: H3Event): Promise<{
|
export async function checkAuthStatus(event: H3Event): Promise<{
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
}> {
|
}> {
|
||||||
const userId = await getUserID(event);
|
try {
|
||||||
|
const session = await getAuthSession(event);
|
||||||
|
|
||||||
|
if (!session || !session.userId) {
|
||||||
return {
|
return {
|
||||||
isAuthenticated: !!userId,
|
isAuthenticated: false,
|
||||||
userId
|
userId: null,
|
||||||
|
isAdmin: false
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userId: session.userId,
|
||||||
|
isAdmin: session.isAdmin
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth check error:", error);
|
||||||
|
return {
|
||||||
|
isAuthenticated: false,
|
||||||
|
userId: null,
|
||||||
|
isAdmin: false
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user ID from session
|
||||||
|
* @param event - H3Event
|
||||||
|
* @returns User ID or null if not authenticated
|
||||||
|
*/
|
||||||
|
export async function getUserID(event: H3Event): Promise<string | null> {
|
||||||
|
const auth = await checkAuthStatus(event);
|
||||||
|
return auth.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Lineage mobile app authentication request
|
||||||
|
* Supports email (JWT), Apple (user string), and Google (OAuth token) providers
|
||||||
|
* @param auth_token - Authentication token from the app
|
||||||
|
* @param userRow - User database row
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
export async function validateLineageRequest({
|
export async function validateLineageRequest({
|
||||||
auth_token,
|
auth_token,
|
||||||
userRow
|
userRow
|
||||||
@@ -196,6 +68,7 @@ export async function validateLineageRequest({
|
|||||||
const { provider, email } = userRow;
|
const { provider, email } = userRow;
|
||||||
if (provider === "email") {
|
if (provider === "email") {
|
||||||
try {
|
try {
|
||||||
|
const { jwtVerify } = await import("jose");
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
const { payload } = await jwtVerify(auth_token, secret);
|
const { payload } = await jwtVerify(auth_token, secret);
|
||||||
if (email !== payload.email) {
|
if (email !== payload.email) {
|
||||||
@@ -210,7 +83,7 @@ export async function validateLineageRequest({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (provider == "google") {
|
} else if (provider == "google") {
|
||||||
const CLIENT_ID = process.env().VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
|
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
|
||||||
if (!CLIENT_ID) {
|
if (!CLIENT_ID) {
|
||||||
console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE");
|
console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE");
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication Security Tests
|
|
||||||
* Tests for authentication mechanisms including JWT, session management, and timing attacks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "bun:test";
|
|
||||||
import { getUserID, getPrivilegeLevel, checkAuthStatus } from "~/server/auth";
|
|
||||||
import {
|
|
||||||
createMockEvent,
|
|
||||||
createTestJWT,
|
|
||||||
createExpiredJWT,
|
|
||||||
createInvalidSignatureJWT,
|
|
||||||
measureTime
|
|
||||||
} from "./test-utils";
|
|
||||||
import { jwtVerify, SignJWT } from "jose";
|
|
||||||
import { env } from "~/env/server";
|
|
||||||
|
|
||||||
describe("Authentication Security", () => {
|
|
||||||
describe("JWT Token Validation", () => {
|
|
||||||
it("should validate correct JWT tokens", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBe(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject expired JWT tokens", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const expiredToken = await createExpiredJWT(userId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: expiredToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject JWT tokens with invalid signature", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const invalidToken = await createInvalidSignatureJWT(userId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: invalidToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject malformed JWT tokens", async () => {
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: "not-a-valid-jwt" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject empty JWT tokens", async () => {
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: "" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject JWT tokens with missing user ID", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const tokenWithoutId = await new SignJWT({})
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("1h")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: tokenWithoutId }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject JWT tokens with invalid user ID type", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const tokenWithNumberId = await new SignJWT({ id: 12345 })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("1h")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: tokenWithNumberId }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle missing cookie gracefully", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("JWT Token Tampering", () => {
|
|
||||||
it("should detect modified JWT payload", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
|
|
||||||
// Tamper with the payload (middle part of JWT)
|
|
||||||
const parts = token.split(".");
|
|
||||||
const tamperedPayload = Buffer.from(
|
|
||||||
JSON.stringify({ id: "attacker-id" })
|
|
||||||
).toString("base64url");
|
|
||||||
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: tamperedToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should detect modified JWT signature", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
|
|
||||||
// Tamper with the signature (last part of JWT)
|
|
||||||
const parts = token.split(".");
|
|
||||||
const tamperedToken = `${parts[0]}.${parts[1]}.modified-signature`;
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: tamperedToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject none algorithm JWT tokens", async () => {
|
|
||||||
// Try to create a token with 'none' algorithm (security vulnerability)
|
|
||||||
const payload = Buffer.from(
|
|
||||||
JSON.stringify({ id: "attacker-id", exp: Date.now() / 1000 + 3600 })
|
|
||||||
).toString("base64url");
|
|
||||||
const header = Buffer.from(
|
|
||||||
JSON.stringify({ alg: "none", typ: "JWT" })
|
|
||||||
).toString("base64url");
|
|
||||||
const noneToken = `${header}.${payload}.`;
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: noneToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedUserId = await getUserID(event);
|
|
||||||
expect(extractedUserId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Privilege Level Security", () => {
|
|
||||||
it("should return admin privilege for admin user", async () => {
|
|
||||||
const adminId = env.ADMIN_ID;
|
|
||||||
const token = await createTestJWT(adminId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return user privilege for regular user", async () => {
|
|
||||||
const userId = "regular-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("user");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return anonymous privilege for unauthenticated request", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return anonymous privilege for invalid token", async () => {
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: "invalid-token" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not allow privilege escalation through token manipulation", async () => {
|
|
||||||
const userId = "regular-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
|
|
||||||
// Even if attacker modifies the token, signature verification will fail
|
|
||||||
const parts = token.split(".");
|
|
||||||
const fakeAdminPayload = Buffer.from(
|
|
||||||
JSON.stringify({ id: env.ADMIN_ID })
|
|
||||||
).toString("base64url");
|
|
||||||
const fakeAdminToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: fakeAdminToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("anonymous"); // Token validation fails
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Session Management", () => {
|
|
||||||
it("should identify authenticated sessions correctly", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const authStatus = await checkAuthStatus(event);
|
|
||||||
expect(authStatus.isAuthenticated).toBe(true);
|
|
||||||
expect(authStatus.userId).toBe(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should identify unauthenticated sessions correctly", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const authStatus = await checkAuthStatus(event);
|
|
||||||
|
|
||||||
expect(authStatus.isAuthenticated).toBe(false);
|
|
||||||
expect(authStatus.userId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle session with expired token", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const expiredToken = await createExpiredJWT(userId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: expiredToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const authStatus = await checkAuthStatus(event);
|
|
||||||
expect(authStatus.isAuthenticated).toBe(false);
|
|
||||||
expect(authStatus.userId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Timing Attack Prevention", () => {
|
|
||||||
it("should have consistent timing for valid and invalid tokens", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const validToken = await createTestJWT(userId);
|
|
||||||
const invalidToken = "invalid-token";
|
|
||||||
|
|
||||||
// Measure time for valid token
|
|
||||||
const validEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: validToken }
|
|
||||||
});
|
|
||||||
const { duration: validDuration } = await measureTime(() =>
|
|
||||||
getUserID(validEvent)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Measure time for invalid token
|
|
||||||
const invalidEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: invalidToken }
|
|
||||||
});
|
|
||||||
const { duration: invalidDuration } = await measureTime(() =>
|
|
||||||
getUserID(invalidEvent)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Timing difference should be minimal (within reasonable variance)
|
|
||||||
// This helps prevent timing attacks to enumerate valid tokens
|
|
||||||
const timingDifference = Math.abs(validDuration - invalidDuration);
|
|
||||||
|
|
||||||
// Allow up to 5ms variance (accounts for system variations)
|
|
||||||
expect(timingDifference).toBeLessThan(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have consistent timing for different user privilege levels", async () => {
|
|
||||||
const adminId = env.ADMIN_ID;
|
|
||||||
const userId = "regular-user-123";
|
|
||||||
|
|
||||||
const adminToken = await createTestJWT(adminId);
|
|
||||||
const userToken = await createTestJWT(userId);
|
|
||||||
|
|
||||||
// Measure time for admin privilege check
|
|
||||||
const adminEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: adminToken }
|
|
||||||
});
|
|
||||||
const { duration: adminDuration } = await measureTime(() =>
|
|
||||||
getPrivilegeLevel(adminEvent)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Measure time for user privilege check
|
|
||||||
const userEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
const { duration: userDuration } = await measureTime(() =>
|
|
||||||
getPrivilegeLevel(userEvent)
|
|
||||||
);
|
|
||||||
|
|
||||||
const timingDifference = Math.abs(adminDuration - userDuration);
|
|
||||||
expect(timingDifference).toBeLessThan(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Token Expiration", () => {
|
|
||||||
it("should respect token expiration time", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const userId = "test-user-123";
|
|
||||||
|
|
||||||
// Create token expiring in 1 second
|
|
||||||
const shortLivedToken = await new SignJWT({ id: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("1s")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
// Should work immediately
|
|
||||||
const event1 = createMockEvent({
|
|
||||||
cookies: { userIDToken: shortLivedToken }
|
|
||||||
});
|
|
||||||
const id1 = await getUserID(event1);
|
|
||||||
expect(id1).toBe(userId);
|
|
||||||
|
|
||||||
// Wait for token to expire
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
||||||
|
|
||||||
// Should fail after expiration
|
|
||||||
const event2 = createMockEvent({
|
|
||||||
cookies: { userIDToken: shortLivedToken }
|
|
||||||
});
|
|
||||||
const id2 = await getUserID(event2);
|
|
||||||
expect(id2).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle tokens with very long expiration", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const userId = "test-user-123";
|
|
||||||
|
|
||||||
const longLivedToken = await new SignJWT({ id: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("365d") // 1 year
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: longLivedToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedId = await getUserID(event);
|
|
||||||
expect(extractedId).toBe(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject tokens with past expiration timestamps", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const userId = "test-user-123";
|
|
||||||
|
|
||||||
const pastToken = await new SignJWT({ id: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // 1 hour ago
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: pastToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedId = await getUserID(event);
|
|
||||||
expect(extractedId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
it("should handle very long JWT tokens", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const largePayload = {
|
|
||||||
id: "test-user-123",
|
|
||||||
extraData: "x".repeat(10000) // 10KB of extra data
|
|
||||||
};
|
|
||||||
|
|
||||||
const largeToken = await new SignJWT(largePayload)
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("1h")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: largeToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedId = await getUserID(event);
|
|
||||||
expect(extractedId).toBe("test-user-123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle special characters in user IDs", async () => {
|
|
||||||
const specialUserId = "user-with-special-!@#$%^&*()";
|
|
||||||
const token = await createTestJWT(specialUserId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedId = await getUserID(event);
|
|
||||||
expect(extractedId).toBe(specialUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle unicode user IDs", async () => {
|
|
||||||
const unicodeUserId = "user-with-unicode-🔐🛡️";
|
|
||||||
const token = await createTestJWT(unicodeUserId);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const extractedId = await getUserID(event);
|
|
||||||
expect(extractedId).toBe(unicodeUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject JWT with future issued-at time", async () => {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const futureToken = await new SignJWT({ id: "test-user-123" })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setIssuedAt(Math.floor(Date.now() / 1000) + 3600) // 1 hour in future
|
|
||||||
.setExpirationTime("2h")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: futureToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Some JWT libraries reject future iat, some don't
|
|
||||||
// This test documents the behavior
|
|
||||||
const extractedId = await getUserID(event);
|
|
||||||
// Behavior may vary - just ensure no crash
|
|
||||||
expect(extractedId === null || extractedId === "test-user-123").toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Performance", () => {
|
|
||||||
it("should validate tokens efficiently", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
await getUserID(event);
|
|
||||||
}
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
|
|
||||||
// Should validate 1000 tokens in less than 100ms
|
|
||||||
expect(duration).toBeLessThan(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should check privilege levels efficiently", async () => {
|
|
||||||
const userId = "test-user-123";
|
|
||||||
const token = await createTestJWT(userId);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
await getPrivilegeLevel(event);
|
|
||||||
}
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
|
|
||||||
// Should check 1000 privileges in less than 100ms
|
|
||||||
expect(duration).toBeLessThan(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authorization Tests
|
|
||||||
* Tests for access control, privilege escalation prevention, and admin access
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "bun:test";
|
|
||||||
import { getUserID, getPrivilegeLevel } from "~/server/auth";
|
|
||||||
import { createMockEvent, createTestJWT } from "./test-utils";
|
|
||||||
import { env } from "~/env/server";
|
|
||||||
|
|
||||||
describe("Authorization", () => {
|
|
||||||
describe("Admin Access Control", () => {
|
|
||||||
it("should grant admin access to configured admin user", async () => {
|
|
||||||
const adminToken = await createTestJWT(env.ADMIN_ID);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: adminToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deny admin access to regular users", async () => {
|
|
||||||
const userToken = await createTestJWT("regular-user-123");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("user");
|
|
||||||
expect(privilege).not.toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deny admin access to anonymous users", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
expect(privilege).not.toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not allow privilege escalation through token tampering", async () => {
|
|
||||||
// Create a regular user token
|
|
||||||
const regularToken = await createTestJWT("regular-user-123");
|
|
||||||
|
|
||||||
// Attacker tries to modify token to include admin ID
|
|
||||||
// This should fail signature verification
|
|
||||||
const parts = regularToken.split(".");
|
|
||||||
const fakeAdminPayload = Buffer.from(
|
|
||||||
JSON.stringify({ id: env.ADMIN_ID })
|
|
||||||
).toString("base64url");
|
|
||||||
const tamperedToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
|
|
||||||
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: tamperedToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("anonymous"); // Invalid token = anonymous
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle malformed admin ID gracefully", async () => {
|
|
||||||
const invalidIds = ["", null, undefined, " ", "admin'--"];
|
|
||||||
|
|
||||||
for (const invalidId of invalidIds) {
|
|
||||||
const token = await createTestJWT(invalidId as string);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
// Should not grant admin access for invalid IDs
|
|
||||||
expect(privilege).not.toBe("admin");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User Access Control", () => {
|
|
||||||
it("should grant user access to authenticated users", async () => {
|
|
||||||
const userToken = await createTestJWT("user-123");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("user");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deny user access to anonymous requests", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
expect(privilege).not.toBe("user");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain user access with valid token", async () => {
|
|
||||||
const userToken = await createTestJWT("user-456");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const userId = await getUserID(event);
|
|
||||||
expect(userId).toBe("user-456");
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("user");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Privilege Escalation Prevention", () => {
|
|
||||||
it("should prevent horizontal privilege escalation", async () => {
|
|
||||||
const user1Token = await createTestJWT("user-1");
|
|
||||||
const user2Token = await createTestJWT("user-2");
|
|
||||||
|
|
||||||
const event1 = createMockEvent({
|
|
||||||
cookies: { userIDToken: user1Token }
|
|
||||||
});
|
|
||||||
const event2 = createMockEvent({
|
|
||||||
cookies: { userIDToken: user2Token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const user1Id = await getUserID(event1);
|
|
||||||
const user2Id = await getUserID(event2);
|
|
||||||
|
|
||||||
expect(user1Id).toBe("user-1");
|
|
||||||
expect(user2Id).toBe("user-2");
|
|
||||||
expect(user1Id).not.toBe(user2Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent vertical privilege escalation", async () => {
|
|
||||||
// Regular user should not be able to become admin
|
|
||||||
const userToken = await createTestJWT("regular-user");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("user");
|
|
||||||
|
|
||||||
// Even with multiple checks, privilege should remain the same
|
|
||||||
const privilege2 = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege2).toBe("user");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not allow session hijacking through token reuse", async () => {
|
|
||||||
const user1Token = await createTestJWT("user-1");
|
|
||||||
|
|
||||||
// User 1's token should always return user 1's ID
|
|
||||||
const event1 = createMockEvent({
|
|
||||||
cookies: { userIDToken: user1Token }
|
|
||||||
});
|
|
||||||
const id1 = await getUserID(event1);
|
|
||||||
|
|
||||||
// Even if attacker captures token, it still identifies as user 1
|
|
||||||
const event2 = createMockEvent({
|
|
||||||
cookies: { userIDToken: user1Token }
|
|
||||||
});
|
|
||||||
const id2 = await getUserID(event2);
|
|
||||||
|
|
||||||
expect(id1).toBe("user-1");
|
|
||||||
expect(id2).toBe("user-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent privilege escalation via race conditions", async () => {
|
|
||||||
const userToken = await createTestJWT("concurrent-user");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate concurrent privilege checks
|
|
||||||
const results = await Promise.all([
|
|
||||||
getPrivilegeLevel(event),
|
|
||||||
getPrivilegeLevel(event),
|
|
||||||
getPrivilegeLevel(event),
|
|
||||||
getPrivilegeLevel(event),
|
|
||||||
getPrivilegeLevel(event)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// All results should be the same
|
|
||||||
expect(results.every((r) => r === "user")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Anonymous Access", () => {
|
|
||||||
it("should handle missing authentication token", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty authentication token", async () => {
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: "" }
|
|
||||||
});
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle invalid token format", async () => {
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: "not-a-jwt-token" }
|
|
||||||
});
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null user ID for anonymous users", async () => {
|
|
||||||
const event = createMockEvent({});
|
|
||||||
const userId = await getUserID(event);
|
|
||||||
|
|
||||||
expect(userId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Access Control Edge Cases", () => {
|
|
||||||
it("should handle user ID with special characters", async () => {
|
|
||||||
const specialUserId = "user-with-special-!@#$%";
|
|
||||||
const token = await createTestJWT(specialUserId);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const userId = await getUserID(event);
|
|
||||||
expect(userId).toBe(specialUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle very long user IDs", async () => {
|
|
||||||
const longUserId = "user-" + "x".repeat(1000);
|
|
||||||
const token = await createTestJWT(longUserId);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const userId = await getUserID(event);
|
|
||||||
expect(userId).toBe(longUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle user ID with unicode characters", async () => {
|
|
||||||
const unicodeUserId = "user-with-unicode-🔐";
|
|
||||||
const token = await createTestJWT(unicodeUserId);
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const userId = await getUserID(event);
|
|
||||||
expect(userId).toBe(unicodeUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle admin ID case sensitivity", async () => {
|
|
||||||
const adminId = env.ADMIN_ID;
|
|
||||||
const wrongCaseId = adminId.toUpperCase();
|
|
||||||
|
|
||||||
// Exact match required
|
|
||||||
const correctToken = await createTestJWT(adminId);
|
|
||||||
const wrongCaseToken = await createTestJWT(wrongCaseId);
|
|
||||||
|
|
||||||
const correctEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: correctToken }
|
|
||||||
});
|
|
||||||
const wrongCaseEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: wrongCaseToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const correctPrivilege = await getPrivilegeLevel(correctEvent);
|
|
||||||
const wrongCasePrivilege = await getPrivilegeLevel(wrongCaseEvent);
|
|
||||||
|
|
||||||
expect(correctPrivilege).toBe("admin");
|
|
||||||
// Wrong case should not get admin access (unless IDs match)
|
|
||||||
if (adminId !== wrongCaseId) {
|
|
||||||
expect(wrongCasePrivilege).toBe("user");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Authorization Attack Scenarios", () => {
|
|
||||||
it("should prevent session fixation attacks", async () => {
|
|
||||||
// Attacker cannot predict or fix session tokens
|
|
||||||
const token1 = await createTestJWT("user-1");
|
|
||||||
const token2 = await createTestJWT("user-1");
|
|
||||||
|
|
||||||
// Tokens should be different even for same user
|
|
||||||
// (Due to different timestamps, though payload is same)
|
|
||||||
expect(token1).toBeDefined();
|
|
||||||
expect(token2).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent parameter pollution attacks", async () => {
|
|
||||||
// Multiple cookie values should not cause confusion
|
|
||||||
const token1 = await createTestJWT("user-1");
|
|
||||||
const token2 = await createTestJWT("user-2");
|
|
||||||
|
|
||||||
// Only first cookie should be used
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: {
|
|
||||||
userIDToken: token1
|
|
||||||
// In practice, duplicate cookies are handled by the framework
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const userId = await getUserID(event);
|
|
||||||
expect(userId).toBe("user-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent token substitution attacks", async () => {
|
|
||||||
const legitimateToken = await createTestJWT("victim-user");
|
|
||||||
const attackerToken = await createTestJWT("attacker-user");
|
|
||||||
|
|
||||||
// Each token should only authenticate its respective user
|
|
||||||
const legitimateEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: legitimateToken }
|
|
||||||
});
|
|
||||||
const attackerEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: attackerToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const legitimateId = await getUserID(legitimateEvent);
|
|
||||||
const attackerId = await getUserID(attackerEvent);
|
|
||||||
|
|
||||||
expect(legitimateId).toBe("victim-user");
|
|
||||||
expect(attackerId).toBe("attacker-user");
|
|
||||||
expect(legitimateId).not.toBe(attackerId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent authorization bypass through empty checks", async () => {
|
|
||||||
const emptyChecks = [null, undefined, "", " ", "null", "undefined"];
|
|
||||||
|
|
||||||
for (const check of emptyChecks) {
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: check as string }
|
|
||||||
});
|
|
||||||
|
|
||||||
const privilege = await getPrivilegeLevel(event);
|
|
||||||
expect(privilege).toBe("anonymous");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multi-User Scenarios", () => {
|
|
||||||
it("should handle multiple concurrent user sessions", async () => {
|
|
||||||
const users = ["user-1", "user-2", "user-3", "user-4", "user-5"];
|
|
||||||
const tokens = await Promise.all(users.map((u) => createTestJWT(u)));
|
|
||||||
|
|
||||||
const events = tokens.map((token) =>
|
|
||||||
createMockEvent({ cookies: { userIDToken: token } })
|
|
||||||
);
|
|
||||||
|
|
||||||
const userIds = await Promise.all(events.map(getUserID));
|
|
||||||
|
|
||||||
// All users should be correctly identified
|
|
||||||
expect(userIds).toEqual(users);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain separate privileges for different users", async () => {
|
|
||||||
const adminToken = await createTestJWT(env.ADMIN_ID);
|
|
||||||
const user1Token = await createTestJWT("user-1");
|
|
||||||
const user2Token = await createTestJWT("user-2");
|
|
||||||
|
|
||||||
const adminEvent = createMockEvent({
|
|
||||||
cookies: { userIDToken: adminToken }
|
|
||||||
});
|
|
||||||
const user1Event = createMockEvent({
|
|
||||||
cookies: { userIDToken: user1Token }
|
|
||||||
});
|
|
||||||
const user2Event = createMockEvent({
|
|
||||||
cookies: { userIDToken: user2Token }
|
|
||||||
});
|
|
||||||
|
|
||||||
const [adminPriv, user1Priv, user2Priv] = await Promise.all([
|
|
||||||
getPrivilegeLevel(adminEvent),
|
|
||||||
getPrivilegeLevel(user1Event),
|
|
||||||
getPrivilegeLevel(user2Event)
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(adminPriv).toBe("admin");
|
|
||||||
expect(user1Priv).toBe("user");
|
|
||||||
expect(user2Priv).toBe("user");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Performance", () => {
|
|
||||||
it("should check privileges efficiently", async () => {
|
|
||||||
const userToken = await createTestJWT("perf-test-user");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
await getPrivilegeLevel(event);
|
|
||||||
}
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
|
|
||||||
// Should complete 1000 checks in less than 100ms
|
|
||||||
expect(duration).toBeLessThan(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract user IDs efficiently", async () => {
|
|
||||||
const userToken = await createTestJWT("perf-test-user");
|
|
||||||
const event = createMockEvent({
|
|
||||||
cookies: { userIDToken: userToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
await getUserID(event);
|
|
||||||
}
|
|
||||||
const duration = performance.now() - start;
|
|
||||||
|
|
||||||
// Should complete 1000 extractions in less than 100ms
|
|
||||||
expect(duration).toBeLessThan(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
51
src/server/session-config.ts
Normal file
51
src/server/session-config.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { SessionConfig } from "vinxi/http";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
import { AUTH_CONFIG } from "~/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session data stored in encrypted cookie
|
||||||
|
* This is synced with database Session table for serverless persistence
|
||||||
|
*/
|
||||||
|
export interface SessionData {
|
||||||
|
/** User ID */
|
||||||
|
userId: string;
|
||||||
|
/** Session ID for database lookup and revocation */
|
||||||
|
sessionId: string;
|
||||||
|
/** Token family for rotation chain tracking */
|
||||||
|
tokenFamily: string;
|
||||||
|
/** Whether user is admin (cached from DB) */
|
||||||
|
isAdmin: boolean;
|
||||||
|
/** Refresh token for rotation (opaque, hashed in DB) */
|
||||||
|
refreshToken: string;
|
||||||
|
/** Remember me preference for session duration */
|
||||||
|
rememberMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vinxi session configuration
|
||||||
|
* Uses iron-session style password-based encryption
|
||||||
|
*/
|
||||||
|
export const sessionConfig: SessionConfig = {
|
||||||
|
password: env.JWT_SECRET_KEY,
|
||||||
|
cookieName: "session",
|
||||||
|
cookieOptions: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/"
|
||||||
|
// maxAge is set dynamically based on rememberMe
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session cookie options with appropriate maxAge
|
||||||
|
* @param rememberMe - Whether to use extended session duration
|
||||||
|
*/
|
||||||
|
export function getSessionCookieOptions(rememberMe: boolean) {
|
||||||
|
return {
|
||||||
|
...sessionConfig.cookieOptions,
|
||||||
|
maxAge: rememberMe
|
||||||
|
? 90 * 24 * 60 * 60 // 90 days
|
||||||
|
: undefined // Session cookie (expires on browser close)
|
||||||
|
};
|
||||||
|
}
|
||||||
499
src/server/session-helpers.ts
Normal file
499
src/server/session-helpers.ts
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import { v4 as uuidV4 } from "uuid";
|
||||||
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
import type { H3Event } from "vinxi/http";
|
||||||
|
import {
|
||||||
|
useSession,
|
||||||
|
updateSession,
|
||||||
|
clearSession,
|
||||||
|
getSession
|
||||||
|
} from "vinxi/http";
|
||||||
|
import { ConnectionFactory } from "./database";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
import { AUTH_CONFIG, expiryToSeconds } from "~/config";
|
||||||
|
import { logAuditEvent } from "./audit";
|
||||||
|
import type { SessionData } from "./session-config";
|
||||||
|
import { sessionConfig } from "./session-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure refresh token
|
||||||
|
* @returns Base64URL-encoded random token (32 bytes = 256 bits)
|
||||||
|
*/
|
||||||
|
export function generateRefreshToken(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash refresh token for storage (one-way hash)
|
||||||
|
* Using SHA-256 since refresh tokens are high-entropy random values
|
||||||
|
* @param token - Plaintext refresh token
|
||||||
|
* @returns Hex-encoded hash
|
||||||
|
*/
|
||||||
|
export function hashRefreshToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session in database and Vinxi session
|
||||||
|
* @param event - H3Event
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param isAdmin - Whether user is admin
|
||||||
|
* @param rememberMe - Whether to use extended session duration
|
||||||
|
* @param ipAddress - Client IP address
|
||||||
|
* @param userAgent - Client user agent string
|
||||||
|
* @param parentSessionId - ID of parent session if this is a rotation (null for new sessions)
|
||||||
|
* @param tokenFamily - Token family UUID for rotation chain (generated if null)
|
||||||
|
* @returns Session data
|
||||||
|
*/
|
||||||
|
export async function createAuthSession(
|
||||||
|
event: H3Event,
|
||||||
|
userId: string,
|
||||||
|
isAdmin: boolean,
|
||||||
|
rememberMe: boolean,
|
||||||
|
ipAddress: string,
|
||||||
|
userAgent: string,
|
||||||
|
parentSessionId: string | null = null,
|
||||||
|
tokenFamily: string | null = null
|
||||||
|
): Promise<SessionData> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const sessionId = uuidV4();
|
||||||
|
const family = tokenFamily || uuidV4();
|
||||||
|
const refreshToken = generateRefreshToken();
|
||||||
|
const tokenHash = hashRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
// Calculate refresh token expiration
|
||||||
|
const refreshExpiry = rememberMe
|
||||||
|
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
|
||||||
|
: AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT;
|
||||||
|
|
||||||
|
const expiresAt = new Date();
|
||||||
|
if (refreshExpiry.endsWith("d")) {
|
||||||
|
const days = parseInt(refreshExpiry);
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + days);
|
||||||
|
} else if (refreshExpiry.endsWith("h")) {
|
||||||
|
const hours = parseInt(refreshExpiry);
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate access token expiry
|
||||||
|
const accessExpiresAt = new Date();
|
||||||
|
const accessExpiry =
|
||||||
|
env.NODE_ENV === "production"
|
||||||
|
? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
||||||
|
: AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV;
|
||||||
|
|
||||||
|
if (accessExpiry.endsWith("m")) {
|
||||||
|
const minutes = parseInt(accessExpiry);
|
||||||
|
accessExpiresAt.setMinutes(accessExpiresAt.getMinutes() + minutes);
|
||||||
|
} else if (accessExpiry.endsWith("h")) {
|
||||||
|
const hours = parseInt(accessExpiry);
|
||||||
|
accessExpiresAt.setHours(accessExpiresAt.getHours() + hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rotation count from parent if exists
|
||||||
|
let rotationCount = 0;
|
||||||
|
if (parentSessionId) {
|
||||||
|
const parentResult = await conn.execute({
|
||||||
|
sql: "SELECT rotation_count FROM Session WHERE id = ?",
|
||||||
|
args: [parentSessionId]
|
||||||
|
});
|
||||||
|
if (parentResult.rows.length > 0) {
|
||||||
|
rotationCount = (parentResult.rows[0].rotation_count as number) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert session into database
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO Session
|
||||||
|
(id, user_id, token_family, refresh_token_hash, parent_session_id,
|
||||||
|
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
family,
|
||||||
|
tokenHash,
|
||||||
|
parentSessionId,
|
||||||
|
rotationCount,
|
||||||
|
expiresAt.toISOString(),
|
||||||
|
accessExpiresAt.toISOString(),
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create session data
|
||||||
|
const sessionData: SessionData = {
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
tokenFamily: family,
|
||||||
|
isAdmin,
|
||||||
|
refreshToken,
|
||||||
|
rememberMe
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update Vinxi session with dynamic maxAge based on rememberMe
|
||||||
|
await updateSession(
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
...sessionConfig,
|
||||||
|
maxAge: rememberMe
|
||||||
|
? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG)
|
||||||
|
: undefined // Session cookie (expires on browser close)
|
||||||
|
},
|
||||||
|
sessionData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.session_created",
|
||||||
|
eventData: {
|
||||||
|
sessionId,
|
||||||
|
tokenFamily: family,
|
||||||
|
rememberMe,
|
||||||
|
parentSessionId
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current session from Vinxi and validate against database
|
||||||
|
* @param event - H3Event
|
||||||
|
* @returns Session data or null if invalid/expired
|
||||||
|
*/
|
||||||
|
export async function getAuthSession(
|
||||||
|
event: H3Event
|
||||||
|
): Promise<SessionData | null> {
|
||||||
|
try {
|
||||||
|
const session = await getSession<SessionData>(event, sessionConfig);
|
||||||
|
|
||||||
|
if (!session.data || !session.data.userId || !session.data.sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session against database
|
||||||
|
const isValid = await validateSessionInDB(
|
||||||
|
session.data.sessionId,
|
||||||
|
session.data.userId,
|
||||||
|
session.data.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
// Clear invalid session
|
||||||
|
await clearSession(event, sessionConfig);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting auth session:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate session against database
|
||||||
|
* Checks if session exists, not revoked, not expired, and refresh token matches
|
||||||
|
* @param sessionId - Session ID
|
||||||
|
* @param userId - User ID
|
||||||
|
* @param refreshToken - Plaintext refresh token
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
async function validateSessionInDB(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const tokenHash = hashRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
const result = await conn.execute({
|
||||||
|
sql: `SELECT revoked, expires_at, refresh_token_hash
|
||||||
|
FROM Session
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
args: [sessionId, userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = result.rows[0];
|
||||||
|
|
||||||
|
// Check if revoked
|
||||||
|
if (session.revoked === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
const expiresAt = new Date(session.expires_at as string);
|
||||||
|
if (expiresAt < new Date()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate refresh token hash (timing-safe comparison)
|
||||||
|
const storedHash = session.refresh_token_hash as string;
|
||||||
|
if (
|
||||||
|
!timingSafeEqual(
|
||||||
|
Buffer.from(tokenHash, "hex"),
|
||||||
|
Buffer.from(storedHash, "hex")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used timestamp (fire and forget)
|
||||||
|
conn
|
||||||
|
.execute({
|
||||||
|
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
console.error("Failed to update session last_used:", err)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Session validation error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate a specific session in database and clear Vinxi session
|
||||||
|
* @param event - H3Event
|
||||||
|
* @param sessionId - Session ID to invalidate
|
||||||
|
*/
|
||||||
|
export async function invalidateAuthSession(
|
||||||
|
event: H3Event,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
console.log(`[Session] Invalidating session ${sessionId}`);
|
||||||
|
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
await clearSession(event, sessionConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all sessions in a token family
|
||||||
|
* Used when breach is detected (token reuse)
|
||||||
|
* @param tokenFamily - Token family ID to revoke
|
||||||
|
* @param reason - Reason for revocation (for audit)
|
||||||
|
*/
|
||||||
|
export async function revokeTokenFamily(
|
||||||
|
tokenFamily: string,
|
||||||
|
reason: string = "breach_detected"
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// Get all sessions in family for audit log
|
||||||
|
const sessions = await conn.execute({
|
||||||
|
sql: "SELECT id, user_id FROM Session WHERE token_family = ? AND revoked = 0",
|
||||||
|
args: [tokenFamily]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke all sessions in family
|
||||||
|
console.log(
|
||||||
|
`[Token Family] Revoking entire family ${tokenFamily} (reason: ${reason}). Sessions affected: ${sessions.rows.length}`
|
||||||
|
);
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
|
||||||
|
args: [tokenFamily]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log audit events for each affected session
|
||||||
|
for (const session of sessions.rows) {
|
||||||
|
await logAuditEvent({
|
||||||
|
userId: session.user_id as string,
|
||||||
|
eventType: "auth.token_family_revoked",
|
||||||
|
eventData: {
|
||||||
|
tokenFamily,
|
||||||
|
sessionId: session.id as string,
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`Token family ${tokenFamily} revoked: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if a token is being reused after rotation
|
||||||
|
* Implements grace period for race conditions
|
||||||
|
* @param sessionId - Session ID being validated
|
||||||
|
* @returns true if reuse detected (and revocation occurred), false otherwise
|
||||||
|
*/
|
||||||
|
export async function detectTokenReuse(sessionId: string): Promise<boolean> {
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
|
// Check if this session has already been rotated (has child session)
|
||||||
|
const childCheck = await conn.execute({
|
||||||
|
sql: `SELECT id, created_at FROM Session
|
||||||
|
WHERE parent_session_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (childCheck.rows.length === 0) {
|
||||||
|
// No child session, this is legitimate first use
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childSession = childCheck.rows[0];
|
||||||
|
const childCreatedAt = new Date(childSession.created_at as string);
|
||||||
|
const now = new Date();
|
||||||
|
const timeSinceRotation = now.getTime() - childCreatedAt.getTime();
|
||||||
|
|
||||||
|
// Grace period for race conditions
|
||||||
|
if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) {
|
||||||
|
console.warn(
|
||||||
|
`[Token Reuse] Within grace period (${timeSinceRotation}ms < ${AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS}ms), allowing for session ${sessionId}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse detected outside grace period - this is a breach!
|
||||||
|
console.error(
|
||||||
|
`[Token Reuse] BREACH DETECTED! Session ${sessionId} rotated ${timeSinceRotation}ms ago. Child session: ${childSession.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get token family and revoke entire family
|
||||||
|
const sessionInfo = await conn.execute({
|
||||||
|
sql: "SELECT token_family, user_id FROM Session WHERE id = ?",
|
||||||
|
args: [sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionInfo.rows.length > 0) {
|
||||||
|
const tokenFamily = sessionInfo.rows[0].token_family as string;
|
||||||
|
const userId = sessionInfo.rows[0].user_id as string;
|
||||||
|
|
||||||
|
await revokeTokenFamily(tokenFamily, "token_reuse_detected");
|
||||||
|
|
||||||
|
// Log critical security event
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
eventType: "auth.token_reuse_detected",
|
||||||
|
eventData: {
|
||||||
|
sessionId,
|
||||||
|
tokenFamily,
|
||||||
|
timeSinceRotation
|
||||||
|
},
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate refresh token: invalidate old, issue new tokens
|
||||||
|
* Implements automatic breach detection
|
||||||
|
* @param event - H3Event
|
||||||
|
* @param oldSessionData - Current session data
|
||||||
|
* @param ipAddress - Client IP address for new session
|
||||||
|
* @param userAgent - Client user agent for new session
|
||||||
|
* @returns New session data or null if rotation fails
|
||||||
|
*/
|
||||||
|
export async function rotateAuthSession(
|
||||||
|
event: H3Event,
|
||||||
|
oldSessionData: SessionData,
|
||||||
|
ipAddress: string,
|
||||||
|
userAgent: string
|
||||||
|
): Promise<SessionData | null> {
|
||||||
|
console.log(
|
||||||
|
`[Token Rotation] Starting rotation for session ${oldSessionData.sessionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate old session exists in DB
|
||||||
|
const isValid = await validateSessionInDB(
|
||||||
|
oldSessionData.sessionId,
|
||||||
|
oldSessionData.userId,
|
||||||
|
oldSessionData.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.warn(
|
||||||
|
`[Token Rotation] Invalid session during rotation for ${oldSessionData.sessionId}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect token reuse (breach detection)
|
||||||
|
const reuseDetected = await detectTokenReuse(oldSessionData.sessionId);
|
||||||
|
if (reuseDetected) {
|
||||||
|
console.error(
|
||||||
|
`[Token Rotation] Token reuse detected for session ${oldSessionData.sessionId}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rotation limit
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const sessionCheck = await conn.execute({
|
||||||
|
sql: "SELECT rotation_count FROM Session WHERE id = ?",
|
||||||
|
args: [oldSessionData.sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionCheck.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotationCount = sessionCheck.rows[0].rotation_count as number;
|
||||||
|
if (rotationCount >= AUTH_CONFIG.MAX_ROTATION_COUNT) {
|
||||||
|
console.warn(
|
||||||
|
`[Token Rotation] Max rotation count reached for session ${oldSessionData.sessionId}`
|
||||||
|
);
|
||||||
|
await invalidateAuthSession(event, oldSessionData.sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session (linked to old via parent_session_id)
|
||||||
|
const newSessionData = await createAuthSession(
|
||||||
|
event,
|
||||||
|
oldSessionData.userId,
|
||||||
|
oldSessionData.isAdmin,
|
||||||
|
oldSessionData.rememberMe,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
oldSessionData.sessionId, // parent session
|
||||||
|
oldSessionData.tokenFamily // reuse family
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate old session
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||||
|
args: [oldSessionData.sessionId]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log rotation event
|
||||||
|
await logAuditEvent({
|
||||||
|
userId: oldSessionData.userId,
|
||||||
|
eventType: "auth.token_rotated",
|
||||||
|
eventData: {
|
||||||
|
oldSessionId: oldSessionData.sessionId,
|
||||||
|
newSessionId: newSessionData.sessionId,
|
||||||
|
tokenFamily: oldSessionData.tokenFamily,
|
||||||
|
rotationCount: rotationCount + 1
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Token Rotation] Successfully rotated session ${oldSessionData.sessionId} -> ${newSessionData.sessionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return newSessionData;
|
||||||
|
}
|
||||||
@@ -36,16 +36,3 @@ export function toUserProfile(user: User): UserProfile {
|
|||||||
hasPassword: !!user.password_hash
|
hasPassword: !!user.password_hash
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionPayload {
|
|
||||||
id: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailVerificationPayload {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetPayload {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user