This commit is contained in:
Michael Freno
2026-01-07 16:22:31 -05:00
parent 041b2f8dc2
commit 0a0c0e313e
15 changed files with 809 additions and 2251 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -68,10 +68,6 @@ export const userRouter = createTRPCRouter({
const user = res.rows[0] as unknown as User;
setCookie(ctx.event.nativeEvent, "emailToken", email, {
path: "/"
});
return toUserProfile(user);
}),
@@ -194,15 +190,6 @@ export const userRouter = createTRPCRouter({
args: [newPasswordHash, userId]
});
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/"
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/"
});
return { success: true, message: "success" };
}),
@@ -255,15 +242,6 @@ export const userRouter = createTRPCRouter({
args: [passwordHash, userId]
});
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/"
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/"
});
return { success: true, message: "success" };
}),
@@ -324,15 +302,6 @@ export const userRouter = createTRPCRouter({
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" };
})
});

View File

@@ -1,10 +1,10 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server";
import { getCookie, setCookie } from "vinxi/http";
import { jwtVerify, type JWTPayload } from "jose";
import { getCookie } from "vinxi/http";
import { env } from "~/env/server";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http";
import { getAuthSession } from "~/server/session-helpers";
export type Context = {
event: APIEvent;
@@ -13,26 +13,15 @@ export type 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 privilegeLevel: "anonymous" | "user" | "admin" = "anonymous";
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") {
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")
});
}
if (session && session.userId) {
userId = session.userId;
privilegeLevel = session.isAdmin ? "admin" : "user";
}
const req = event.nativeEvent.node?.req || event.nativeEvent;

View File

@@ -30,6 +30,7 @@ export type AuditEventType =
| "security.csrf.failed"
| "security.suspicious.activity"
| "admin.action"
| "auth.session_created"
| "system.session_cleanup";
/**

View File

@@ -1,191 +1,63 @@
import { getCookie, setCookie, type H3Event } from "vinxi/http";
import { jwtVerify } from "jose";
import type { H3Event } from "vinxi/http";
import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web";
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)
* Falls back to manual header parsing if vinxi's getCookie fails
* Check authentication status
* 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<{
isAuthenticated: boolean;
userId: string | null;
isAdmin: boolean;
}> {
const userId = await getUserID(event);
return {
isAuthenticated: !!userId,
userId
};
try {
const session = await getAuthSession(event);
if (!session || !session.userId) {
return {
isAuthenticated: false,
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({
auth_token,
userRow
@@ -196,6 +68,7 @@ export async function validateLineageRequest({
const { provider, email } = userRow;
if (provider === "email") {
try {
const { jwtVerify } = await import("jose");
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(auth_token, secret);
if (email !== payload.email) {
@@ -210,7 +83,7 @@ export async function validateLineageRequest({
return false;
}
} 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) {
console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE");
return false;

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View 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)
};
}

View 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;
}