Compare commits
11 Commits
0abe064afd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 80daaa29dc | |||
|
|
cda7784298 | ||
|
|
b25fc50156 | ||
|
|
d7c91ac6c5 | ||
|
|
e6d5b40acd | ||
|
|
3845c768e2 | ||
|
|
955c856a85 | ||
|
|
7b60494d6d | ||
|
|
58d48dac70 | ||
|
|
1d8ec7a375 | ||
|
|
6b86d175e8 |
@@ -160,10 +160,10 @@ function AppLayout(props: { children: any }) {
|
||||
<LeftBar />
|
||||
<div
|
||||
id="center-body"
|
||||
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-62.5 md:w-[calc(100vw-500px)]"
|
||||
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-62.5 md:w-[calc(100vw-500px)] 2xl:ml-72 2xl:w-[calc(100vw-576px)]"
|
||||
>
|
||||
<noscript>
|
||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
|
||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)] xl:ml-72 xl:w-[calc(100vw-576px)]">
|
||||
JavaScript is disabled. Features will be limited.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
@@ -366,7 +366,7 @@ export function LeftBar() {
|
||||
const getMainNavStyles = () => {
|
||||
const baseStyles = {
|
||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
width: "250px",
|
||||
width: windowWidth() < 1536 ? "250px" : "288px",
|
||||
"padding-top": "env(safe-area-inset-top)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom)"
|
||||
};
|
||||
|
||||
@@ -39,10 +39,10 @@ export function Btop(props: BtopProps) {
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
|
||||
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH);
|
||||
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
|
||||
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize));
|
||||
|
||||
@@ -8,14 +8,14 @@ export interface PostLike {
|
||||
post_id: string;
|
||||
}
|
||||
|
||||
export interface SessionDependantLikeProps {
|
||||
export interface AuthenticatedLikeProps {
|
||||
currentUserID: string | undefined | null;
|
||||
isAuthenticated: boolean;
|
||||
likes: PostLike[];
|
||||
projectID: number;
|
||||
}
|
||||
|
||||
export default function SessionDependantLike(props: SessionDependantLikeProps) {
|
||||
export default function AuthenticatedLike(props: AuthenticatedLikeProps) {
|
||||
const [hovering, setHovering] = createSignal(false);
|
||||
const [likes, setLikes] = createSignal(props.likes);
|
||||
const [instantOffset, setInstantOffset] = createSignal(0);
|
||||
@@ -7,37 +7,21 @@
|
||||
*
|
||||
* Security Model:
|
||||
* - Access tokens: Short-lived (15m), contain user identity, stored in httpOnly cookie
|
||||
* - Refresh tokens: Long-lived (7-90d), opaque tokens for getting new access tokens
|
||||
* - Token rotation: Each refresh invalidates old token and issues new pair
|
||||
* - Breach detection: Reusing invalidated token revokes entire token family
|
||||
* - rememberMe tokens: Long-lived (30d), issued as JWT without refresh tokens
|
||||
*
|
||||
* Cookie Behavior:
|
||||
* - rememberMe = false: Session cookies (no maxAge) - expire when browser closes
|
||||
* - rememberMe = false: Browser-session cookies (no maxAge) - expire when browser closes
|
||||
* - rememberMe = true: Persistent cookies (with maxAge) - survive browser restart
|
||||
*
|
||||
* Timing Decisions:
|
||||
* - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes)
|
||||
* - 7d session: DB expiry for non-remember-me (cookie is session-only but accommodates users who keep browser open)
|
||||
* - 90d remember: Extended convenience for trusted devices (both DB and cookie persist)
|
||||
* - 5s reuse window: Handles race conditions in distributed systems
|
||||
* - 30d remember: Extended convenience for trusted devices
|
||||
*/
|
||||
export const AUTH_CONFIG = {
|
||||
// Access Token (JWT in cookie)
|
||||
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
|
||||
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
|
||||
|
||||
// Refresh Token (opaque token in separate cookie)
|
||||
REFRESH_TOKEN_EXPIRY_SHORT: "7d" as const, // 7 days (DB expiry for non-remember me - accommodates users who keep browser open)
|
||||
REFRESH_TOKEN_EXPIRY_LONG: "90d" as const, // 90 days (remember me - both DB and cookie persist)
|
||||
|
||||
// Security Settings
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: true, // Enable token rotation
|
||||
MAX_ROTATION_COUNT: 1000, // Max rotations before forcing re-login (1000 * 15m = 10.4 days in prod, 1000 * 2m = 33 hours in dev)
|
||||
REFRESH_TOKEN_REUSE_WINDOW_MS: 5000, // 5s grace period for race conditions
|
||||
|
||||
// Session Cleanup (serverless-friendly opportunistic cleanup)
|
||||
SESSION_CLEANUP_INTERVAL_HOURS: 24, // Check for cleanup every 24 hours
|
||||
SESSION_CLEANUP_RETENTION_DAYS: 90, // Keep revoked sessions for 90 days (audit)
|
||||
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
|
||||
|
||||
// Other Auth Settings
|
||||
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
||||
@@ -57,7 +41,7 @@ export function getAccessTokenExpiry(): string {
|
||||
|
||||
/**
|
||||
* Convert expiry string to seconds for cookie Max-Age
|
||||
* @param expiry - Expiry string like "15m", "7d", "90d"
|
||||
* @param expiry - Expiry string like "15m", "30d"
|
||||
* @returns Seconds as number
|
||||
*/
|
||||
export function expiryToSeconds(expiry: string): number {
|
||||
@@ -71,31 +55,12 @@ export function expiryToSeconds(expiry: string): number {
|
||||
throw new Error(`Invalid expiry format: ${expiry}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access cookie maxAge based on environment (in seconds)
|
||||
*/
|
||||
export function getAccessCookieMaxAge(): number {
|
||||
return expiryToSeconds(getAccessTokenExpiry());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh cookie maxAge based on rememberMe preference (in seconds)
|
||||
*/
|
||||
export function getRefreshCookieMaxAge(rememberMe: boolean): number {
|
||||
return expiryToSeconds(
|
||||
rememberMe
|
||||
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
|
||||
: AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for token expiry strings
|
||||
*/
|
||||
export type TokenExpiry =
|
||||
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
||||
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT
|
||||
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG;
|
||||
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG;
|
||||
|
||||
// ============================================================
|
||||
// RATE LIMITING
|
||||
@@ -151,9 +116,6 @@ export const CACHE_CONFIG = {
|
||||
MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000,
|
||||
GIT_ACTIVITY_MAX_STALE_MS: 24 * 60 * 60 * 1000,
|
||||
|
||||
// Session activity tracking - only update DB if last update was > threshold
|
||||
SESSION_ACTIVITY_UPDATE_THRESHOLD_MS: 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
// Rate limit in-memory cache TTL (reduces DB reads)
|
||||
RATE_LIMIT_CACHE_TTL_MS: 60 * 1000, // 1 minute
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "solid-js";
|
||||
import { createAsync, revalidate } from "@solidjs/router";
|
||||
import { getUserState, type UserState } from "~/lib/auth-query";
|
||||
import { tokenRefreshManager } from "~/lib/token-refresh";
|
||||
|
||||
interface AuthContextType {
|
||||
/** Current user state (for UI display) */
|
||||
@@ -87,41 +86,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Start/stop token refresh manager based on auth state
|
||||
let previousAuth: boolean | undefined = undefined;
|
||||
createEffect(() => {
|
||||
const authenticated = isAuthenticated();
|
||||
|
||||
console.log(
|
||||
`[AuthContext] createEffect triggered - authenticated: ${authenticated}, previousAuth: ${previousAuth}`
|
||||
);
|
||||
|
||||
// Only act if auth state actually changed
|
||||
if (authenticated === previousAuth) {
|
||||
console.log("[AuthContext] Auth state unchanged, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
previousAuth = authenticated;
|
||||
|
||||
if (authenticated) {
|
||||
console.log(
|
||||
"[AuthContext] User authenticated, starting token refresh manager"
|
||||
);
|
||||
tokenRefreshManager.start(true);
|
||||
} else {
|
||||
console.log(
|
||||
"[AuthContext] User not authenticated, stopping token refresh manager"
|
||||
);
|
||||
tokenRefreshManager.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
tokenRefreshManager.stop();
|
||||
});
|
||||
|
||||
const value: AuthContextType = {
|
||||
userState: serverAuth,
|
||||
isAuthenticated,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Accessor, createContext, useContext } from "solid-js";
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
export const STATIC_BAR_SIZE = 250;
|
||||
|
||||
const BarsContext = createContext<{
|
||||
leftBarVisible: Accessor<boolean>;
|
||||
setLeftBarVisible: (visible: boolean) => void;
|
||||
|
||||
@@ -15,38 +15,6 @@ export const model: { [key: string]: string } = {
|
||||
locked_until TEXT
|
||||
);
|
||||
`,
|
||||
Session: `
|
||||
CREATE TABLE Session
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token_family TEXT NOT NULL,
|
||||
refresh_token_hash TEXT NOT NULL,
|
||||
parent_session_id TEXT,
|
||||
rotation_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
access_token_expires_at TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
revoked INTEGER DEFAULT 0,
|
||||
device_name TEXT,
|
||||
device_type TEXT,
|
||||
browser TEXT,
|
||||
os TEXT,
|
||||
last_active_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES Session(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_user_id ON Session (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_expires_at ON Session (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_token_family ON Session (token_family);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_refresh_token_hash ON Session (refresh_token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_revoked ON Session (revoked);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at);
|
||||
`,
|
||||
UserProvider: `
|
||||
CREATE TABLE UserProvider
|
||||
(
|
||||
|
||||
@@ -17,23 +17,6 @@ export interface User {
|
||||
locked_until?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
token_family: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
last_used: string;
|
||||
ip_address?: string | null;
|
||||
user_agent?: string | null;
|
||||
revoked: number;
|
||||
device_name?: string | null;
|
||||
device_type?: string | null;
|
||||
browser?: string | null;
|
||||
os?: string | null;
|
||||
last_active_at?: string | null;
|
||||
}
|
||||
|
||||
export interface UserProvider {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -165,7 +148,6 @@ export interface VisitorAnalytics {
|
||||
device_type?: string | null;
|
||||
browser?: string | null;
|
||||
os?: string | null;
|
||||
session_id?: string | null;
|
||||
duration_ms?: number | null;
|
||||
fcp?: number | null;
|
||||
lcp?: number | null;
|
||||
|
||||
20
src/env/server.ts
vendored
20
src/env/server.ts
vendored
@@ -5,8 +5,8 @@ const serverEnvSchema = z.object({
|
||||
JWT_SECRET_KEY: z.string().min(1),
|
||||
AWS_REGION: z.string().min(1),
|
||||
AWS_S3_BUCKET_NAME: z.string().min(1),
|
||||
_AWS_ACCESS_KEY: z.string().min(1),
|
||||
_AWS_SECRET_KEY: z.string().min(1),
|
||||
MY_AWS_ACCESS_KEY: z.string().min(1),
|
||||
MY_AWS_SECRET_KEY: z.string().min(1),
|
||||
GOOGLE_CLIENT_SECRET: z.string().min(1),
|
||||
GITHUB_CLIENT_SECRET: z.string().min(1),
|
||||
EMAIL_SERVER: z.string().min(1),
|
||||
@@ -31,9 +31,9 @@ const serverEnvSchema = z.object({
|
||||
VITE_INFILL_ENDPOINT: z.string().min(1),
|
||||
INFILL_BEARER_TOKEN: z.string().min(1),
|
||||
REDIS_URL: z.string().min(1),
|
||||
CAIRN_DB_URL: z.string().min(1),
|
||||
CAIRN_DB_TOKEN: z.string().min(1),
|
||||
CAIRN_JWT_SECRET: z.string().min(1)
|
||||
NESSA_DB_URL: z.string().min(1),
|
||||
NESSA_DB_TOKEN: z.string().min(1),
|
||||
NESSA_JWT_SECRET: z.string().min(1)
|
||||
});
|
||||
|
||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
@@ -113,8 +113,8 @@ export const getMissingEnvVars = (): string[] => {
|
||||
"JWT_SECRET_KEY",
|
||||
"AWS_REGION",
|
||||
"AWS_S3_BUCKET_NAME",
|
||||
"_AWS_ACCESS_KEY",
|
||||
"_AWS_SECRET_KEY",
|
||||
"MY_AWS_ACCESS_KEY",
|
||||
"MY_AWS_SECRET_KEY",
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"GITHUB_CLIENT_SECRET",
|
||||
"EMAIL_SERVER",
|
||||
@@ -137,9 +137,9 @@ export const getMissingEnvVars = (): string[] => {
|
||||
"VITE_GITHUB_CLIENT_ID",
|
||||
"VITE_WEBSOCKET",
|
||||
"REDIS_URL",
|
||||
"CAIRN_DB_URL",
|
||||
"CAIRN_DB_TOKEN",
|
||||
"CAIRN_JWT_SECRET"
|
||||
"NESSA_DB_URL",
|
||||
"NESSA_DB_TOKEN",
|
||||
"NESSA_JWT_SECRET"
|
||||
];
|
||||
|
||||
return requiredServerVars.filter((varName) => isMissingEnvVar(varName));
|
||||
|
||||
@@ -86,11 +86,5 @@ export function revalidateAuth() {
|
||||
// Dispatch event to trigger UI updates (client-side only)
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth-state-changed"));
|
||||
|
||||
// Reset token refresh timer when auth state changes
|
||||
// This ensures the timer is synchronized with fresh tokens
|
||||
import("~/lib/token-refresh").then(({ tokenRefreshManager }) => {
|
||||
tokenRefreshManager.reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
/**
|
||||
* Token Refresh Manager
|
||||
* Handles automatic token refresh before expiry
|
||||
*
|
||||
* Note: Since access tokens are httpOnly cookies, we can't read them from client JS.
|
||||
* Instead, we schedule refresh based on a fixed interval that aligns with token expiry.
|
||||
*/
|
||||
|
||||
import { api } from "~/lib/api";
|
||||
import { revalidateAuth } from "~/lib/auth-query";
|
||||
|
||||
// Token expiry durations (must match server config)
|
||||
const ACCESS_TOKEN_EXPIRY_MS = import.meta.env.PROD
|
||||
? 15 * 60 * 1000
|
||||
: 2 * 60 * 1000; // 15m prod, 2m dev
|
||||
const REFRESH_THRESHOLD_MS = import.meta.env.PROD ? 2 * 60 * 1000 : 30 * 1000; // 2m prod, 30s dev
|
||||
|
||||
class TokenRefreshManager {
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshing = false;
|
||||
private isStarted = false;
|
||||
private visibilityChangeHandler: (() => void) | null = null;
|
||||
private onlineHandler: (() => void) | null = null;
|
||||
private focusHandler: (() => void) | null = null;
|
||||
private lastRefreshTime: number | null = null;
|
||||
private lastCheckTime: number = 0;
|
||||
|
||||
/**
|
||||
* Start monitoring and auto-refresh
|
||||
* @param isAuthenticated - Whether user is currently authenticated (from server state)
|
||||
*/
|
||||
start(isAuthenticated: boolean = true): void {
|
||||
console.log(
|
||||
`[Token Refresh] start() called - isStarted: ${this.isStarted}, isAuthenticated: ${isAuthenticated}, lastRefreshTime: ${this.lastRefreshTime}`
|
||||
);
|
||||
|
||||
if (typeof window === "undefined") return; // Server-side bail
|
||||
|
||||
if (this.isStarted) {
|
||||
console.log(
|
||||
"[Token Refresh] Already started, skipping duplicate start()"
|
||||
);
|
||||
return; // Already started, prevent duplicate listeners
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log("[Token Refresh] Not authenticated, skipping start()");
|
||||
return; // No need to refresh if not authenticated
|
||||
}
|
||||
|
||||
this.isStarted = true;
|
||||
this.lastRefreshTime = Date.now(); // Assume token was just issued
|
||||
console.log(
|
||||
`[Token Refresh] Manager started, lastRefreshTime set to ${this.lastRefreshTime}`
|
||||
);
|
||||
this.scheduleNextRefresh();
|
||||
|
||||
// Re-check on visibility change (user returns to tab)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
console.log(
|
||||
"[Token Refresh] Tab became visible, checking token status"
|
||||
);
|
||||
this.checkAndRefreshIfNeeded();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
|
||||
|
||||
// Re-check on network reconnection (device was offline)
|
||||
this.onlineHandler = () => {
|
||||
console.log("[Token Refresh] Network reconnected, checking token status");
|
||||
this.checkAndRefreshIfNeeded();
|
||||
};
|
||||
window.addEventListener("online", this.onlineHandler);
|
||||
|
||||
// Re-check on window focus (device was asleep or user switched apps)
|
||||
// Debounce to prevent Safari from firing this too frequently
|
||||
this.focusHandler = () => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCheck = now - this.lastCheckTime;
|
||||
|
||||
// Debounce: only check if last check was >1s ago (prevents Safari spam)
|
||||
if (timeSinceLastCheck < 1000) {
|
||||
console.log("[Token Refresh] Window focused but debouncing (Safari)");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastCheckTime = now;
|
||||
console.log("[Token Refresh] Window focused, checking token status");
|
||||
this.checkAndRefreshIfNeeded();
|
||||
};
|
||||
window.addEventListener("focus", this.focusHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring and clear timers
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (this.visibilityChangeHandler) {
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.visibilityChangeHandler
|
||||
);
|
||||
this.visibilityChangeHandler = null;
|
||||
}
|
||||
|
||||
if (this.onlineHandler) {
|
||||
window.removeEventListener("online", this.onlineHandler);
|
||||
this.onlineHandler = null;
|
||||
}
|
||||
|
||||
if (this.focusHandler) {
|
||||
window.removeEventListener("focus", this.focusHandler);
|
||||
this.focusHandler = null;
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
this.lastRefreshTime = null; // Reset refresh time on stop
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the last refresh time (call after login or successful refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
console.log(
|
||||
`[Token Refresh] reset() called - isRefreshing: ${this.isRefreshing}`,
|
||||
new Error().stack?.split("\n").slice(1, 4).join("\n") // Show caller
|
||||
);
|
||||
|
||||
// Don't reset if we're currently refreshing (prevents infinite loop)
|
||||
if (this.isRefreshing) {
|
||||
console.log("[Token Refresh] Skipping reset during active refresh");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Token Refresh] Resetting refresh timer, old lastRefreshTime: ${this.lastRefreshTime}`
|
||||
);
|
||||
this.lastRefreshTime = Date.now();
|
||||
console.log(`[Token Refresh] New lastRefreshTime: ${this.lastRefreshTime}`);
|
||||
|
||||
if (this.isStarted) {
|
||||
this.scheduleNextRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token needs refresh based on last refresh time
|
||||
*/
|
||||
private checkAndRefreshIfNeeded(): void {
|
||||
if (!this.lastRefreshTime) {
|
||||
console.log("[Token Refresh] No refresh history, refreshing now");
|
||||
this.refreshNow();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceRefresh = Date.now() - this.lastRefreshTime;
|
||||
const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh;
|
||||
|
||||
if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) {
|
||||
// Token expired or about to expire - refresh immediately
|
||||
console.log(
|
||||
`[Token Refresh] Token likely expired (${Math.round(timeSinceRefresh / 1000)}s since last refresh), refreshing now`
|
||||
);
|
||||
this.refreshNow();
|
||||
} else {
|
||||
// Token still valid - reschedule
|
||||
console.log(
|
||||
`[Token Refresh] Token still valid (~${Math.round(timeUntilExpiry / 1000)}s remaining), rescheduling refresh`
|
||||
);
|
||||
this.scheduleNextRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next refresh based on token expiry
|
||||
*/
|
||||
private scheduleNextRefresh(): void {
|
||||
// Clear existing timer but don't stop the manager
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (!this.lastRefreshTime) {
|
||||
console.log("[Token Refresh] No refresh history, cannot schedule");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceRefresh = Date.now() - this.lastRefreshTime;
|
||||
const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh;
|
||||
|
||||
if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) {
|
||||
console.warn(
|
||||
"[Token Refresh] Token likely expired, attempting refresh now"
|
||||
);
|
||||
this.refreshNow();
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule refresh before expiry
|
||||
const timeUntilRefresh = Math.max(
|
||||
0,
|
||||
timeUntilExpiry - REFRESH_THRESHOLD_MS
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Token Refresh] Scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s ` +
|
||||
`(~${Math.round(timeUntilExpiry / 1000)}s until expiry)`
|
||||
);
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshNow();
|
||||
}, timeUntilRefresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rememberMe preference
|
||||
* Since we can't read httpOnly cookies, we default to true and let the server
|
||||
* determine the correct expiry based on the existing session
|
||||
*/
|
||||
private getRememberMePreference(): boolean {
|
||||
// Default to true - server will use the correct expiry from the existing session
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform token refresh immediately
|
||||
*/
|
||||
async refreshNow(): Promise<boolean> {
|
||||
if (this.isRefreshing) {
|
||||
console.log("[Token Refresh] Refresh already in progress, skipping");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
console.log("[Token Refresh] Refreshing access token...");
|
||||
|
||||
// Preserve rememberMe state from existing session
|
||||
const rememberMe = this.getRememberMePreference();
|
||||
console.log(
|
||||
`[Token Refresh] Using rememberMe: ${rememberMe} (from refresh token cookie existence)`
|
||||
);
|
||||
|
||||
const result = await Promise.race([
|
||||
api.auth.refreshToken.mutate({
|
||||
rememberMe
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Token refresh timeout")), 10000)
|
||||
)
|
||||
]);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Token Refresh] Token refreshed successfully");
|
||||
this.lastRefreshTime = Date.now(); // Update refresh time
|
||||
this.scheduleNextRefresh(); // Schedule next refresh
|
||||
|
||||
// Revalidate auth AFTER scheduling to avoid race condition
|
||||
revalidateAuth(); // Refresh auth state after token refresh
|
||||
return true;
|
||||
} else {
|
||||
console.error("[Token Refresh] Token refresh failed:", result);
|
||||
this.handleRefreshFailure();
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Token Refresh] Token refresh error:", error);
|
||||
|
||||
// Don't redirect on timeout - might be deployment in progress
|
||||
const isTimeout =
|
||||
error instanceof Error && error.message.includes("timeout");
|
||||
if (isTimeout) {
|
||||
console.warn(
|
||||
"[Token Refresh] Timeout - server might be deploying, will retry on schedule"
|
||||
);
|
||||
this.scheduleNextRefresh();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.handleRefreshFailure();
|
||||
return false;
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh failure (redirect to login)
|
||||
*/
|
||||
private handleRefreshFailure(): void {
|
||||
console.warn("[Token Refresh] Token refresh failed, redirecting to login");
|
||||
|
||||
// Store current URL for redirect after login
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
if (currentPath !== "/login") {
|
||||
sessionStorage.setItem("redirectAfterLogin", currentPath);
|
||||
}
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt immediate refresh (for page load when access token expired)
|
||||
* Always attempts refresh - server will reject if no refresh token exists
|
||||
* Returns true if refresh succeeded, false otherwise
|
||||
*
|
||||
* Note: We can't check for httpOnly refresh token from client JavaScript,
|
||||
* so we always attempt and let the server decide if token exists
|
||||
*/
|
||||
async attemptInitialRefresh(): Promise<boolean> {
|
||||
console.log(
|
||||
"[Token Refresh] Attempting initial refresh (server will check for refresh token)"
|
||||
);
|
||||
|
||||
// refreshNow() already calls revalidateAuth() on success
|
||||
return await this.refreshNow();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tokenRefreshManager = new TokenRefreshManager();
|
||||
@@ -923,18 +923,6 @@ export default function AccountPage() {
|
||||
|
||||
<hr class="mt-8 mb-8" />
|
||||
|
||||
{/* Active Sessions Section */}
|
||||
<div class="mx-auto max-w-2xl py-8">
|
||||
<div class="mb-6 text-center text-2xl font-semibold">
|
||||
Active Sessions
|
||||
</div>
|
||||
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
||||
<ActiveSessions userId={userProfile().id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="mt-8 mb-8" />
|
||||
|
||||
{/* Sign Out Section */}
|
||||
<div class="mx-auto max-w-md py-4">
|
||||
<Button
|
||||
@@ -1147,156 +1135,3 @@ function LinkedProviders(props: { userId: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveSessions(props: { userId: string }) {
|
||||
const [sessions, setSessions] = createSignal<any[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [revokeLoading, setRevokeLoading] = createSignal<string | null>(null);
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/trpc/user.getSessions");
|
||||
const result = await response.json();
|
||||
if (response.ok && result.result?.data) {
|
||||
setSessions(result.result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
loadSessions();
|
||||
});
|
||||
|
||||
const handleRevoke = async (sessionId: string, isCurrent: boolean) => {
|
||||
if (isCurrent) {
|
||||
if (
|
||||
!confirm(
|
||||
"This will sign you out of this device. Are you sure you want to continue?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!confirm("Are you sure you want to revoke this session?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRevokeLoading(sessionId);
|
||||
try {
|
||||
const response = await fetch("/api/trpc/user.revokeSession", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok && result.result?.data?.success) {
|
||||
if (isCurrent) {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
await loadSessions();
|
||||
alert("Session revoked successfully");
|
||||
}
|
||||
} else {
|
||||
alert(result.error?.message || "Failed to revoke session");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to revoke session:", err);
|
||||
alert("Failed to revoke session");
|
||||
} finally {
|
||||
setRevokeLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
// Database stores UTC time, convert to local timezone
|
||||
const date = new Date(dateStr + (dateStr.includes("Z") ? "" : "Z"));
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
};
|
||||
|
||||
const parseUserAgent = (ua: string) => {
|
||||
const browser =
|
||||
ua.match(/(Chrome|Firefox|Safari|Edge)\/[\d.]+/)?.[0] ||
|
||||
"Unknown browser";
|
||||
const os = ua.match(/(Windows|Mac|Linux|Android|iOS)/)?.[0] || "Unknown OS";
|
||||
return { browser, os };
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<Show when={loading()}>
|
||||
<div class="text-center text-sm">Loading sessions...</div>
|
||||
</Show>
|
||||
<Show when={!loading() && sessions().length === 0}>
|
||||
<div class="text-center text-sm">No active sessions found</div>
|
||||
</Show>
|
||||
<For each={sessions()}>
|
||||
{(session) => {
|
||||
const { browser, os } = parseUserAgent(session.userAgent || "");
|
||||
return (
|
||||
<div class="bg-surface1 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold">{browser}</div>
|
||||
<Show when={session.isCurrent}>
|
||||
<span class="text-green bg-green/20 rounded px-2 py-0.5 text-xs font-semibold">
|
||||
Current
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-subtext0 mt-1 space-y-1 text-sm">
|
||||
<div>{os}</div>
|
||||
<Show when={session.clientIp}>
|
||||
<div>IP: {session.clientIp}</div>
|
||||
</Show>
|
||||
<div>
|
||||
Last active:{" "}
|
||||
{formatDate(session.lastActiveAt || session.createdAt)}
|
||||
</div>
|
||||
<Show when={session.expiresAt}>
|
||||
<div class="text-xs">
|
||||
Expires: {formatDate(session.expiresAt)}
|
||||
{session.rememberMe !== undefined && (
|
||||
<span class="text-subtext1 ml-2">
|
||||
(
|
||||
{session.rememberMe
|
||||
? "Remember me"
|
||||
: "Session-only"}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRevoke(session.sessionId, session.isCurrent)
|
||||
}
|
||||
disabled={revokeLoading() === session.sessionId}
|
||||
class="text-red hover:text-red rounded px-3 py-1 text-sm transition-all hover:brightness-125 disabled:opacity-50"
|
||||
>
|
||||
{revokeLoading() === session.sessionId
|
||||
? "Revoking..."
|
||||
: "Revoke"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ export async function GET(event: APIEvent) {
|
||||
const key = "api/Gaze/appcast.xml";
|
||||
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
import { createServerCaller } from "~/server/api/root";
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
@@ -31,8 +30,7 @@ export async function GET(event: APIEvent) {
|
||||
|
||||
try {
|
||||
console.log("[GitHub OAuth Callback] Creating tRPC caller...");
|
||||
const ctx = await createTRPCContext(event);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const caller = await createServerCaller(event);
|
||||
|
||||
console.log("[GitHub OAuth Callback] Calling githubCallback procedure...");
|
||||
const result = await caller.auth.githubCallback({ code });
|
||||
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
|
||||
result.redirectTo
|
||||
);
|
||||
|
||||
// Vinxi's updateSession already set the cookie headers automatically
|
||||
// Auth handler already set cookie headers
|
||||
// Just redirect - the cookies are already in the response
|
||||
const redirectUrl = result.redirectTo || "/account";
|
||||
return new Response(null, {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
import { createServerCaller } from "~/server/api/root";
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
@@ -31,8 +30,7 @@ export async function GET(event: APIEvent) {
|
||||
|
||||
try {
|
||||
console.log("[Google OAuth Callback] Creating tRPC caller...");
|
||||
const ctx = await createTRPCContext(event);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const caller = await createServerCaller(event);
|
||||
|
||||
console.log("[Google OAuth Callback] Calling googleCallback procedure...");
|
||||
const result = await caller.auth.googleCallback({ code });
|
||||
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
|
||||
result.redirectTo
|
||||
);
|
||||
|
||||
// Vinxi's updateSession already set the cookie headers automatically
|
||||
// Auth handler already set cookie headers
|
||||
// Just redirect - the cookies are already in the response
|
||||
const redirectUrl = result.redirectTo || "/account";
|
||||
return new Response(null, {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
import { createServerCaller } from "~/server/api/root";
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
@@ -27,8 +26,7 @@ export async function GET(event: APIEvent) {
|
||||
try {
|
||||
console.log("[Email Login Callback] Creating tRPC caller...");
|
||||
// Create tRPC caller to invoke the emailLogin procedure
|
||||
const ctx = await createTRPCContext(event);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const caller = await createServerCaller(event);
|
||||
|
||||
console.log("[Email Login Callback] Calling emailLogin procedure...");
|
||||
// Call the email login handler - rememberMe will be read from JWT payload
|
||||
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
|
||||
result.redirectTo
|
||||
);
|
||||
|
||||
// Vinxi's updateSession already set the cookie headers automatically
|
||||
// Auth handler already set cookie headers
|
||||
// Just redirect - the cookies are already in the response
|
||||
const redirectUrl = result.redirectTo || "/account";
|
||||
return new Response(null, {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
import { createServerCaller } from "~/server/api/root";
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
@@ -57,20 +56,19 @@ export async function GET(event: APIEvent) {
|
||||
`,
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
headers: { "Content-Type": "text/html" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create tRPC caller to invoke the emailVerification procedure
|
||||
const ctx = await createTRPCContext(event);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const caller = await createServerCaller(event);
|
||||
|
||||
// Call the email verification handler
|
||||
const result = await caller.auth.emailVerification({
|
||||
email,
|
||||
token,
|
||||
token
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@@ -129,7 +127,7 @@ export async function GET(event: APIEvent) {
|
||||
`,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
headers: { "Content-Type": "text/html" }
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -139,8 +137,10 @@ export async function GET(event: APIEvent) {
|
||||
console.error("Email verification callback error:", error);
|
||||
|
||||
// Check if it's a token expiration error
|
||||
const errorMessage = error instanceof Error ? error.message : "server_error";
|
||||
const isTokenError = errorMessage.includes("expired") || errorMessage.includes("invalid");
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "server_error";
|
||||
const isTokenError =
|
||||
errorMessage.includes("expired") || errorMessage.includes("invalid");
|
||||
|
||||
return new Response(
|
||||
`
|
||||
@@ -192,7 +192,7 @@ export async function GET(event: APIEvent) {
|
||||
`,
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
headers: { "Content-Type": "text/html" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getEvent, clearSession } from "vinxi/http";
|
||||
import { sessionConfig } from "~/server/session-config";
|
||||
import { getEvent } from "vinxi/http";
|
||||
import { clearAuthToken } from "~/server/auth";
|
||||
|
||||
export async function POST() {
|
||||
"use server";
|
||||
const event = getEvent()!;
|
||||
|
||||
// Clear Vinxi session
|
||||
await clearSession(event, sessionConfig);
|
||||
clearAuthToken(event);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
|
||||
@@ -25,7 +25,10 @@ export async function GET(event: APIEvent) {
|
||||
}
|
||||
|
||||
// Validate filename format (only allow Gaze files)
|
||||
if (!filename.startsWith("Gaze") || (!filename.endsWith(".dmg") && !filename.endsWith(".delta"))) {
|
||||
if (
|
||||
!filename.startsWith("Gaze") ||
|
||||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
||||
) {
|
||||
return new Response("Invalid file format", {
|
||||
status: 400,
|
||||
headers: {
|
||||
@@ -38,8 +41,8 @@ export async function GET(event: APIEvent) {
|
||||
const key = `downloads/${filename}`;
|
||||
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { createAsync } from "@solidjs/router";
|
||||
import { getRequestEvent } from "solid-js/web";
|
||||
import SessionDependantLike from "~/components/blog/SessionDependantLike";
|
||||
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
|
||||
import CommentIcon from "~/components/icons/CommentIcon";
|
||||
import { Fire } from "~/components/icons/Fire";
|
||||
import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper";
|
||||
@@ -433,10 +433,10 @@ export default function PostPage() {
|
||||
</a>
|
||||
|
||||
<div>
|
||||
<SessionDependantLike
|
||||
<AuthenticatedLike
|
||||
currentUserID={postData.userID}
|
||||
isAuthenticated={postData.isAuthenticated}
|
||||
likes={postData.likes as any[]}
|
||||
likes={postData.likes}
|
||||
projectID={p().id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +160,7 @@ const routerSections: RouterSection[] = [
|
||||
router: "auth",
|
||||
procedure: "signOut",
|
||||
method: "mutation",
|
||||
description: "Clear session cookies and sign out"
|
||||
description: "Clear auth cookie and sign out"
|
||||
},
|
||||
{
|
||||
name: "GitHub Callback",
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface AnalyticsEntry {
|
||||
deviceType?: string | null;
|
||||
browser?: string | null;
|
||||
os?: string | null;
|
||||
sessionId?: string | null;
|
||||
durationMs?: number | null;
|
||||
fcp?: number | null;
|
||||
lcp?: number | null;
|
||||
@@ -62,9 +61,9 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO VisitorAnalytics (
|
||||
id, user_id, path, method, referrer, user_agent, ip_address,
|
||||
country, device_type, browser, os, session_id, duration_ms,
|
||||
country, device_type, browser, os, duration_ms,
|
||||
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
uuid(),
|
||||
entry.userId || null,
|
||||
@@ -77,7 +76,6 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
||||
entry.deviceType || null,
|
||||
entry.browser || null,
|
||||
entry.os || null,
|
||||
entry.sessionId || null,
|
||||
entry.durationMs || null,
|
||||
entry.fcp || null,
|
||||
entry.lcp || null,
|
||||
@@ -202,7 +200,6 @@ export async function queryAnalytics(
|
||||
device_type: row.device_type as string | null,
|
||||
browser: row.browser as string | null,
|
||||
os: row.os as string | null,
|
||||
session_id: row.session_id as string | null,
|
||||
duration_ms: row.duration_ms as number | null,
|
||||
created_at: row.created_at as string
|
||||
}));
|
||||
|
||||
@@ -11,10 +11,11 @@ import { postHistoryRouter } from "./routers/post-history";
|
||||
import { infillRouter } from "./routers/infill";
|
||||
import { accountRouter } from "./routers/account";
|
||||
import { downloadsRouter } from "./routers/downloads";
|
||||
import { remoteDbRouter } from "./routers/remote-db";
|
||||
import { nessaDbRouter } from "./routers/nessa";
|
||||
import { appleNotificationsRouter } from "./routers/apple-notifications";
|
||||
import { createTRPCRouter, createTRPCContext } from "./utils";
|
||||
import { createTRPCRouter, createTRPCContext, t } from "./utils";
|
||||
import type { H3Event } from "h3";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
auth: authRouter,
|
||||
@@ -30,18 +31,30 @@ export const appRouter = createTRPCRouter({
|
||||
infill: infillRouter,
|
||||
account: accountRouter,
|
||||
downloads: downloadsRouter,
|
||||
remoteDb: remoteDbRouter,
|
||||
nessaDb: nessaDbRouter,
|
||||
appleNotifications: appleNotificationsRouter
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
/** Server-side caller factory using the modern tRPC pattern */
|
||||
export const createCallerFactory = t.createCallerFactory(appRouter);
|
||||
|
||||
/**
|
||||
* Create a server-side caller for tRPC procedures
|
||||
* This allows calling tRPC procedures directly on the server with proper context
|
||||
* Create a server-side caller for tRPC procedures from H3Event (vinxi/http getEvent)
|
||||
* Used in server functions within route files
|
||||
*/
|
||||
export const createCaller = async (event: H3Event) => {
|
||||
const apiEvent = { nativeEvent: event, request: event.node.req } as any;
|
||||
const ctx = await createTRPCContext(apiEvent);
|
||||
return appRouter.createCaller(ctx);
|
||||
return createCallerFactory(ctx);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a server-side caller for tRPC procedures from APIEvent
|
||||
* Used in API route handlers
|
||||
*/
|
||||
export const createServerCaller = async (event: APIEvent) => {
|
||||
const ctx = await createTRPCContext(event);
|
||||
return createCallerFactory(ctx);
|
||||
};
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
getUserProviders,
|
||||
unlinkProvider,
|
||||
getProviderSummary
|
||||
} from "~/server/provider-helpers";
|
||||
import {
|
||||
getUserActiveSessions,
|
||||
revokeUserSession,
|
||||
revokeOtherUserSessions,
|
||||
getSessionCountByDevice
|
||||
} from "~/server/session-management";
|
||||
import { getAuthSession } from "~/server/session-helpers";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { getAuditContext } from "~/server/security";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import type { Context } from "../utils";
|
||||
|
||||
/**
|
||||
* Extract H3Event from Context
|
||||
*/
|
||||
function getH3Event(ctx: Context): H3Event {
|
||||
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
||||
return ctx.event.nativeEvent as H3Event;
|
||||
}
|
||||
return ctx.event as unknown as H3Event;
|
||||
}
|
||||
import { getProviderSummary, unlinkProvider } from "~/server/provider-helpers";
|
||||
|
||||
export const accountRouter = createTRPCRouter({
|
||||
/**
|
||||
@@ -67,17 +42,6 @@ export const accountRouter = createTRPCRouter({
|
||||
|
||||
await unlinkProvider(userId, provider);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.unlinked",
|
||||
eventData: { provider },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${provider} authentication unlinked successfully`
|
||||
@@ -97,159 +61,5 @@ export const accountRouter = createTRPCRouter({
|
||||
message: "Failed to unlink provider"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all active sessions for current user
|
||||
*/
|
||||
getActiveSessions: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const sessions = await getUserActiveSessions(userId);
|
||||
|
||||
// Mark current session
|
||||
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||
const currentSessionId = currentSession?.sessionId;
|
||||
|
||||
const sessionsWithCurrent = sessions.map((session) => ({
|
||||
...session,
|
||||
current: session.sessionId === currentSessionId
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessions: sessionsWithCurrent
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching active sessions:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch active sessions"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get session statistics by device type
|
||||
*/
|
||||
getSessionStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const stats = await getSessionCountByDevice(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching session stats:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch session stats"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke a specific session
|
||||
*/
|
||||
revokeSession: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const { sessionId } = input;
|
||||
|
||||
await revokeUserSession(userId, sessionId);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.session_revoked",
|
||||
eventData: { sessionId, reason: "user_request" },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Session revoked successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error revoking session:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to revoke session"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke all other sessions (keep current session active)
|
||||
*/
|
||||
revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
|
||||
// Get current session
|
||||
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||
if (!currentSession) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "No active session found"
|
||||
});
|
||||
}
|
||||
|
||||
const revokedCount = await revokeOtherUserSessions(
|
||||
userId,
|
||||
currentSession.sessionId
|
||||
);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.sessions_bulk_revoked",
|
||||
eventData: {
|
||||
revokedCount,
|
||||
keptSession: currentSession.sessionId,
|
||||
reason: "user_request"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${revokedCount} session(s) revoked successfully`,
|
||||
revokedCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error revoking other sessions:", error);
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to revoke sessions"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -5,13 +5,32 @@ import {
|
||||
getAnalyticsSummary,
|
||||
getPathAnalytics,
|
||||
cleanupOldAnalytics,
|
||||
logVisit,
|
||||
getPerformanceStats,
|
||||
enrichAnalyticsEntry
|
||||
} from "~/server/analytics";
|
||||
import { ConnectionFactory } from "~/server/database";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { getRequestIP, getCookie } from "vinxi/http";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
|
||||
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
|
||||
function getHeader(
|
||||
headers: Record<string, string | string[] | undefined> | Headers | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
// Check if it's a Fetch API Headers object (has .get method)
|
||||
if (typeof (headers as Headers).get === "function") {
|
||||
return (headers as Headers).get(name) || undefined;
|
||||
}
|
||||
|
||||
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
|
||||
const value = (headers as Record<string, string | string[] | undefined>)[
|
||||
name.toLowerCase()
|
||||
];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
}
|
||||
|
||||
export const analyticsRouter = createTRPCRouter({
|
||||
logPerformance: publicProcedure
|
||||
@@ -71,18 +90,13 @@ export const analyticsRouter = createTRPCRouter({
|
||||
} else {
|
||||
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
|
||||
const userAgent =
|
||||
req.headers?.["user-agent"] ||
|
||||
ctx.event.request?.headers?.get("user-agent") ||
|
||||
undefined;
|
||||
getHeader(req.headers, "user-agent") ||
|
||||
getHeader(ctx.event.request?.headers, "user-agent");
|
||||
const referrer =
|
||||
req.headers?.referer ||
|
||||
req.headers?.referrer ||
|
||||
ctx.event.request?.headers?.get("referer") ||
|
||||
undefined;
|
||||
getHeader(req.headers, "referer") ||
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(ctx.event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
||||
const sessionId =
|
||||
getCookie(ctx.event.nativeEvent, "session_id") || undefined;
|
||||
|
||||
const enriched = enrichAnalyticsEntry({
|
||||
userId: ctx.userId,
|
||||
path: input.path,
|
||||
@@ -90,7 +104,6 @@ export const analyticsRouter = createTRPCRouter({
|
||||
userAgent,
|
||||
referrer,
|
||||
ipAddress,
|
||||
sessionId,
|
||||
fcp: input.metrics.fcp,
|
||||
lcp: input.metrics.lcp,
|
||||
cls: input.metrics.cls,
|
||||
@@ -104,9 +117,9 @@ export const analyticsRouter = createTRPCRouter({
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO VisitorAnalytics (
|
||||
id, user_id, path, method, referrer, user_agent, ip_address,
|
||||
country, device_type, browser, os, session_id, duration_ms,
|
||||
country, device_type, browser, os, duration_ms,
|
||||
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
uuid(),
|
||||
enriched.userId || null,
|
||||
@@ -119,7 +132,6 @@ export const analyticsRouter = createTRPCRouter({
|
||||
enriched.deviceType || null,
|
||||
enriched.browser || null,
|
||||
enriched.os || null,
|
||||
enriched.sessionId || null,
|
||||
enriched.durationMs || null,
|
||||
enriched.fcp || null,
|
||||
enriched.lcp || null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createCallerFactory, appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
|
||||
vi.mock("~/server/apple-notification", () => ({
|
||||
@@ -15,15 +15,12 @@ vi.mock("~/server/apple-notification-store", () => ({
|
||||
storeAppleNotificationUser: async () => undefined
|
||||
}));
|
||||
|
||||
vi.mock("~/server/session-helpers", () => ({
|
||||
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
|
||||
}));
|
||||
|
||||
describe("apple notification router", () => {
|
||||
it("verifies and stores notifications", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any)
|
||||
);
|
||||
const ctx = await createTRPCContext({
|
||||
nativeEvent: { node: { req: {} } }
|
||||
} as any);
|
||||
const caller = createCallerFactory(ctx);
|
||||
|
||||
const result = await caller.appleNotifications.verifyAndStore.mutate({
|
||||
signedPayload: "test"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
checkPassword,
|
||||
checkPasswordSafe
|
||||
} from "~/server/utils";
|
||||
import { setCookie, getCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
import {
|
||||
linkProvider,
|
||||
@@ -49,19 +48,25 @@ import {
|
||||
markPasswordResetTokenUsed
|
||||
} from "~/server/security";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { getCookie, setCookie } from "vinxi/http";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import type { Context } from "../utils";
|
||||
import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config";
|
||||
import {
|
||||
createAuthSession,
|
||||
getAuthSession,
|
||||
invalidateAuthSession,
|
||||
rotateAuthSession,
|
||||
revokeTokenFamily
|
||||
} from "~/server/session-helpers";
|
||||
import { checkAuthStatus } from "~/server/auth";
|
||||
AUTH_CONFIG,
|
||||
NETWORK_CONFIG,
|
||||
COOLDOWN_TIMERS,
|
||||
expiryToSeconds,
|
||||
getAccessTokenExpiry
|
||||
} from "~/config";
|
||||
import {
|
||||
issueAuthToken,
|
||||
clearAuthToken,
|
||||
checkAuthStatus,
|
||||
verifyAuthToken,
|
||||
getAuthTokenFromEvent
|
||||
} from "~/server/auth";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import {
|
||||
generateLoginLinkEmail,
|
||||
generatePasswordResetEmail,
|
||||
@@ -83,9 +88,6 @@ function getH3Event(ctx: Context): H3Event {
|
||||
}
|
||||
|
||||
// Zod schemas
|
||||
const refreshTokenSchema = z.object({
|
||||
rememberMe: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
async function sendEmail(to: string, subject: string, htmlContent: string) {
|
||||
const apiKey = env.SENDINBLUE_KEY;
|
||||
@@ -124,45 +126,6 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt server-side token refresh for SSR
|
||||
* Called from getUserState() when access token is expired but refresh token exists
|
||||
* @param event - H3Event from SSR
|
||||
* @param refreshToken - Refresh token from httpOnly cookie (unused, kept for API compatibility)
|
||||
* @returns userId if refresh succeeded, null otherwise
|
||||
*/
|
||||
export async function attemptTokenRefresh(
|
||||
event: H3Event,
|
||||
refreshToken: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Step 1: Get current session from Vinxi
|
||||
const session = await getAuthSession(event);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Get client info for rotation
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
|
||||
const newSession = await rotateAuthSession(
|
||||
event,
|
||||
session,
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
if (!newSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return newSession.userId;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
githubCallback: publicProcedure
|
||||
.input(z.object({ code: z.string() }))
|
||||
@@ -306,16 +269,15 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
true, // OAuth defaults to remember
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
rememberMe: true
|
||||
});
|
||||
setCSRFToken(event);
|
||||
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
@@ -518,18 +480,17 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Create session with Vinxi (OAuth defaults to remember me)
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
// Issue JWT (OAuth defaults to remember me)
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
true, // OAuth defaults to remember
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
rememberMe: true
|
||||
});
|
||||
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
setCSRFToken(event);
|
||||
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
@@ -642,17 +603,16 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
const userId = (res.rows[0] as unknown as User).id;
|
||||
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
rememberMe,
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
rememberMe
|
||||
});
|
||||
setCSRFToken(event);
|
||||
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
@@ -727,14 +687,6 @@ export const authRouter = createTRPCRouter({
|
||||
// Check if there's a valid JWT token with this code
|
||||
// We need to find the token that was generated for this email
|
||||
// Since we can't store tokens in DB efficiently, we'll verify against the cookie
|
||||
const requested = getCookie(getH3Event(ctx), "emailLoginLinkRequested");
|
||||
if (!requested) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "No login request found. Please request a new code."
|
||||
});
|
||||
}
|
||||
|
||||
// Get the token from cookie (we'll store it when sending email)
|
||||
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
|
||||
if (!storedToken) {
|
||||
@@ -777,16 +729,15 @@ export const authRouter = createTRPCRouter({
|
||||
const shouldRemember =
|
||||
rememberMe ?? (payload.rememberMe as boolean) ?? false;
|
||||
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
shouldRemember,
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
rememberMe: shouldRemember
|
||||
});
|
||||
setCSRFToken(event);
|
||||
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
@@ -970,20 +921,19 @@ export const authRouter = createTRPCRouter({
|
||||
email: email
|
||||
});
|
||||
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
// Issue auth token with client info
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
rememberMe ?? true, // Default to persistent sessions for registration
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
rememberMe: rememberMe ?? true
|
||||
});
|
||||
|
||||
// Set CSRF token
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
setCSRFToken(event);
|
||||
|
||||
// Log successful registration
|
||||
await logAuditEvent({
|
||||
@@ -1138,18 +1088,17 @@ export const authRouter = createTRPCRouter({
|
||||
// Reset rate limits on successful login
|
||||
await resetLoginRateLimits(email, clientIP);
|
||||
|
||||
// Create session with Vinxi
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
user.id,
|
||||
rememberMe ?? false, // Default to session cookie (expires on browser close)
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
// Issue JWT for authenticated user
|
||||
const event = getH3Event(ctx);
|
||||
const userAgent = getUserAgent(event);
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId: user.id,
|
||||
rememberMe: rememberMe ?? false
|
||||
});
|
||||
|
||||
// Set CSRF token for authenticated session
|
||||
setCSRFToken(getH3Event(ctx));
|
||||
// Set CSRF token for authenticated user
|
||||
setCSRFToken(event);
|
||||
|
||||
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
||||
try {
|
||||
@@ -1232,7 +1181,7 @@ export const authRouter = createTRPCRouter({
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const token = await new SignJWT({
|
||||
email,
|
||||
rememberMe: rememberMe ?? false, // Default to session cookie (expires on browser close)
|
||||
rememberMe: rememberMe ?? false, // Default to browser cookie
|
||||
code: loginCode
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
@@ -1263,7 +1212,7 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
// Store the token in a cookie so it can be verified with the code later
|
||||
setCookie(getH3Event(ctx), "emailLoginToken", token, {
|
||||
maxAge: COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_COOKIE_MAX_AGE,
|
||||
maxAge: expiryToSeconds(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY),
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
@@ -1624,69 +1573,44 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
refreshToken: publicProcedure
|
||||
.input(refreshTokenSchema)
|
||||
.mutation(async ({ ctx }) => {
|
||||
refreshToken: publicProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
const event = getH3Event(ctx);
|
||||
const authToken = getAuthTokenFromEvent(event);
|
||||
|
||||
// Step 1: Get current session from Vinxi
|
||||
const session = await getAuthSession(event);
|
||||
if (!session) {
|
||||
if (!authToken) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "No valid session found"
|
||||
message: "No valid token found"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Get client info for rotation
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
const payload = await verifyAuthToken(authToken);
|
||||
|
||||
// Step 3: Rotate session (includes validation, breach detection, cookie update)
|
||||
const newSession = await rotateAuthSession(
|
||||
if (!payload) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token"
|
||||
});
|
||||
}
|
||||
|
||||
const expiresIn = payload.exp
|
||||
? payload.exp - Math.floor(Date.now() / 1000)
|
||||
: 0;
|
||||
|
||||
const shortExpiry = expiryToSeconds(getAccessTokenExpiry());
|
||||
|
||||
await issueAuthToken({
|
||||
event,
|
||||
session,
|
||||
clientIP,
|
||||
userAgent
|
||||
);
|
||||
|
||||
if (!newSession) {
|
||||
// Rotation failed - session invalid, reuse detected, or max rotations reached
|
||||
await invalidateAuthSession(event, session.sessionId);
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Token refresh failed - please login again"
|
||||
userId: payload.sub,
|
||||
rememberMe: expiresIn > shortExpiry
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Force response headers to be sent immediately
|
||||
// This is critical for Safari to receive the new session cookies
|
||||
// Safari is very strict about cookie updates from fetch responses
|
||||
try {
|
||||
const headers = event.node?.res?.getHeaders?.() || {};
|
||||
console.log(
|
||||
"[Token Refresh] Response headers set:",
|
||||
Object.keys(headers)
|
||||
);
|
||||
} catch (e) {
|
||||
// Headers already sent or not available - that's OK
|
||||
}
|
||||
|
||||
// Step 5: Refresh CSRF token
|
||||
setCSRFToken(event);
|
||||
|
||||
// Step 6: Opportunistic cleanup (serverless-friendly)
|
||||
import("~/server/token-cleanup")
|
||||
.then((module) => module.opportunisticCleanup())
|
||||
.catch((err) => console.error("Opportunistic cleanup failed:", err));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Token refreshed successfully",
|
||||
// Return new session ID for Safari fallback
|
||||
// If Safari doesn't apply cookies, client can use this to restore
|
||||
sessionId: newSession.sessionId
|
||||
message: "Token refreshed successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Token refresh error:", error);
|
||||
@@ -1704,105 +1628,26 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
// Step 1: Get current session
|
||||
const session = await getAuthSession(getH3Event(ctx));
|
||||
const event = getH3Event(ctx);
|
||||
const auth = await checkAuthStatus(event);
|
||||
|
||||
if (session) {
|
||||
await revokeTokenFamily(session.tokenFamily, "user_logout");
|
||||
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
if (auth.userId) {
|
||||
const { ipAddress, userAgent } = getAuditContext(event);
|
||||
await logAuditEvent({
|
||||
userId: session.userId,
|
||||
userId: auth.userId,
|
||||
eventType: "auth.logout",
|
||||
eventData: { sessionId: session.sessionId },
|
||||
eventData: {},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
clearAuthToken(event);
|
||||
} catch (e) {
|
||||
console.error("Error during signout:", e);
|
||||
// Continue with session clearing even if revocation fails
|
||||
}
|
||||
|
||||
// Step 4: Clear Vinxi session (clears encrypted cookie)
|
||||
await invalidateAuthSession(getH3Event(ctx), "");
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Admin endpoints for session management
|
||||
cleanupSessions: publicProcedure.mutation(async ({ ctx }) => {
|
||||
// Get user ID to check admin status
|
||||
const userId = ctx.userId;
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required"
|
||||
});
|
||||
}
|
||||
|
||||
// Import cleanup functions
|
||||
const { cleanupExpiredSessions, cleanupOrphanedReferences } =
|
||||
await import("~/server/token-cleanup");
|
||||
|
||||
try {
|
||||
// Run cleanup
|
||||
const stats = await cleanupExpiredSessions();
|
||||
const orphansFixed = await cleanupOrphanedReferences();
|
||||
|
||||
// Log admin action
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "admin.session_cleanup",
|
||||
eventData: {
|
||||
sessionsDeleted: stats.totalDeleted,
|
||||
orphansFixed
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionsDeleted: stats.totalDeleted,
|
||||
expiredDeleted: stats.expiredDeleted,
|
||||
revokedDeleted: stats.revokedDeleted,
|
||||
orphansFixed
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Manual cleanup failed:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Cleanup failed"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getSessionStats: publicProcedure.query(async ({ ctx }) => {
|
||||
// Get user ID to check admin status
|
||||
const userId = ctx.userId;
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required"
|
||||
});
|
||||
}
|
||||
|
||||
// Import stats function
|
||||
const { getSessionStats } = await import("~/server/token-cleanup");
|
||||
|
||||
try {
|
||||
const stats = await getSessionStats();
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error("Failed to get session stats:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to retrieve stats"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createCallerFactory } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
|
||||
// Mock the S3 client and getSignedUrl function
|
||||
@@ -27,15 +27,14 @@ vi.mock("@aws-sdk/s3-request-presigner", () => ({
|
||||
|
||||
// Mock environment variables
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env._AWS_ACCESS_KEY = "test-access-key";
|
||||
process.env._AWS_SECRET_KEY = "test-secret-key";
|
||||
process.env.MY_AWS_ACCESS_KEY = "test-access-key";
|
||||
process.env.MY_AWS_SECRET_KEY = "test-secret-key";
|
||||
process.env.VITE_DOWNLOAD_BUCKET_STRING = "test-bucket";
|
||||
|
||||
describe("downloads router", () => {
|
||||
it("should return a signed URL for valid asset names", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
await createTRPCContext({ nativeEvent: {} } as any)
|
||||
);
|
||||
const ctx = await createTRPCContext({ nativeEvent: {} } as any);
|
||||
const caller = createCallerFactory(ctx);
|
||||
|
||||
const result = await caller.downloads.getDownloadUrl.query({
|
||||
asset_name: "lineage"
|
||||
@@ -46,9 +45,8 @@ describe("downloads router", () => {
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND for invalid asset names", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
await createTRPCContext({ nativeEvent: {} } as any)
|
||||
);
|
||||
const ctx = await createTRPCContext({ nativeEvent: {} } as any);
|
||||
const caller = createCallerFactory(ctx);
|
||||
|
||||
try {
|
||||
await caller.downloads.getDownloadUrl.query({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createTRPCRouter, publicProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { S3Client, GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -14,7 +18,10 @@ const assets: Record<string, string> = {
|
||||
/**
|
||||
* Get the latest Gaze DMG from S3 by finding the most recent file in downloads/ folder
|
||||
*/
|
||||
async function getLatestGazeDMG(client: S3Client, bucket: string): Promise<string> {
|
||||
async function getLatestGazeDMG(
|
||||
client: S3Client,
|
||||
bucket: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
@@ -29,9 +36,9 @@ async function getLatestGazeDMG(client: S3Client, bucket: string): Promise<strin
|
||||
}
|
||||
|
||||
// Filter for .dmg files only and sort by LastModified (newest first)
|
||||
const dmgFiles = response.Contents
|
||||
.filter((obj) => obj.Key?.endsWith(".dmg"))
|
||||
.sort((a, b) => {
|
||||
const dmgFiles = response.Contents.filter((obj) =>
|
||||
obj.Key?.endsWith(".dmg")
|
||||
).sort((a, b) => {
|
||||
const dateA = a.LastModified?.getTime() || 0;
|
||||
const dateB = b.LastModified?.getTime() || 0;
|
||||
return dateB - dateA; // Descending order (newest first)
|
||||
@@ -57,8 +64,8 @@ export const downloadsRouter = createTRPCRouter({
|
||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
const client = new S3Client({
|
||||
@@ -98,7 +105,10 @@ export const downloadsRouter = createTRPCRouter({
|
||||
console.error(error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : "Failed to generate download URL"
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate download URL"
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ export const miscRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -80,8 +80,8 @@ export const miscRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -135,8 +135,8 @@ export const miscRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
const client = new S3Client({
|
||||
@@ -195,8 +195,8 @@ export const miscRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
const s3params = {
|
||||
@@ -234,8 +234,8 @@ export const miscRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const credentials = {
|
||||
accessKeyId: env._AWS_ACCESS_KEY,
|
||||
secretAccessKey: env._AWS_SECRET_KEY
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
};
|
||||
|
||||
const s3params = {
|
||||
|
||||
2658
src/server/api/routers/nessa.ts
Normal file
2658
src/server/api/routers/nessa.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/utils";
|
||||
|
||||
vi.mock("~/server/database", () => ({
|
||||
CairnConnectionFactory: () => ({
|
||||
execute: async () => ({ rows: [{ id: "1", email: "test@cairn.app" }] })
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock("~/server/cache", () => ({
|
||||
cache: {
|
||||
get: async () => null,
|
||||
set: async () => undefined
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("~/server/session-helpers", () => ({
|
||||
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
|
||||
}));
|
||||
|
||||
describe("remoteDb router", () => {
|
||||
it("returns users from remote database", async () => {
|
||||
const caller = appRouter.createCaller(
|
||||
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any)
|
||||
);
|
||||
|
||||
const result = await caller.remoteDb.getUsers.query({ limit: 1, offset: 0 });
|
||||
|
||||
expect(result.users.length).toBe(1);
|
||||
expect(result.users[0].email).toBe("test@cairn.app");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,10 @@
|
||||
import { createTRPCRouter, publicProcedure } from "../utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
||||
import { setCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
import { toUserProfile } from "~/types/user";
|
||||
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
|
||||
import { z } from "zod";
|
||||
import { getAuthSession } from "~/server/session-helpers";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { getClientIP, getUserAgent } from "~/server/security";
|
||||
import { generatePasswordSetEmail } from "~/server/email-templates";
|
||||
import { formatDeviceDescription } from "~/server/device-utils";
|
||||
import sendEmail from "~/server/email";
|
||||
@@ -405,119 +401,5 @@ export const userRouter = createTRPCRouter({
|
||||
await unlinkProvider(userId, input.provider);
|
||||
|
||||
return { success: true, message: "Provider unlinked" };
|
||||
}),
|
||||
|
||||
getSessions: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authenticated"
|
||||
});
|
||||
}
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
const res = await conn.execute({
|
||||
sql: `SELECT id, token_family, created_at, expires_at, last_active_at,
|
||||
rotation_count, ip_address, user_agent
|
||||
FROM Session
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||
ORDER BY last_active_at DESC`,
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
// Get current session to mark it
|
||||
const currentSession = await getAuthSession(ctx.event as any);
|
||||
|
||||
return res.rows.map((row: any) => {
|
||||
// Infer rememberMe from expires_at duration
|
||||
// If expires_at is > 2 days from creation, it's a remember-me session
|
||||
const createdAt = new Date(row.created_at);
|
||||
const expiresAt = new Date(row.expires_at);
|
||||
const durationMs = expiresAt.getTime() - createdAt.getTime();
|
||||
const rememberMe = durationMs > 2 * 24 * 60 * 60 * 1000; // > 2 days
|
||||
|
||||
return {
|
||||
sessionId: row.id,
|
||||
tokenFamily: row.token_family,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastActiveAt: row.last_active_at,
|
||||
rotationCount: row.rotation_count,
|
||||
clientIp: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
rememberMe,
|
||||
isCurrent: currentSession?.sessionId === row.id
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
revokeSession: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authenticated"
|
||||
});
|
||||
}
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Verify session belongs to this user
|
||||
const sessionCheck = await conn.execute({
|
||||
sql: "SELECT user_id, token_family FROM Session WHERE id = ?",
|
||||
args: [input.sessionId]
|
||||
});
|
||||
|
||||
if (sessionCheck.rows.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Session not found"
|
||||
});
|
||||
}
|
||||
|
||||
const session = sessionCheck.rows[0] as any;
|
||||
if (session.user_id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Cannot revoke another user's session"
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke the entire token family (all sessions on this device)
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
|
||||
args: [session.token_family]
|
||||
});
|
||||
|
||||
// Log audit event
|
||||
const h3Event = ctx.event.nativeEvent
|
||||
? ctx.event.nativeEvent
|
||||
: (ctx.event as any);
|
||||
const clientIP = getClientIP(h3Event);
|
||||
const userAgent = getUserAgent(h3Event);
|
||||
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.session_revoked",
|
||||
eventData: {
|
||||
sessionId: input.sessionId,
|
||||
tokenFamily: session.token_family,
|
||||
reason: "user_revoked"
|
||||
},
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return { success: true, message: "Session revoked" };
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,58 +1,72 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getCookie } from "vinxi/http";
|
||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { getAuthSession } from "~/server/session-helpers";
|
||||
import { verifyCairnToken } from "~/server/cairn-auth";
|
||||
import { verifyNessaToken } from "~/server/nessa-auth";
|
||||
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||
|
||||
export type Context = {
|
||||
event: APIEvent;
|
||||
userId: string | null;
|
||||
isAdmin: boolean;
|
||||
cairnUserId: string | null;
|
||||
nessaUserId: string | null;
|
||||
};
|
||||
|
||||
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
|
||||
function getHeader(
|
||||
headers: Record<string, string | string[] | undefined> | Headers | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
// Check if it's a Fetch API Headers object (has .get method)
|
||||
if (typeof (headers as Headers).get === "function") {
|
||||
return (headers as Headers).get(name) || undefined;
|
||||
}
|
||||
|
||||
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
|
||||
const value = (headers as Record<string, string | string[] | undefined>)[
|
||||
name.toLowerCase()
|
||||
];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
}
|
||||
|
||||
async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
// Get auth session from Vinxi encrypted session
|
||||
const session = await getAuthSession(event.nativeEvent);
|
||||
const payload = await getAuthPayloadFromEvent(event.nativeEvent);
|
||||
|
||||
let userId: string | null = null;
|
||||
let isAdmin = false;
|
||||
|
||||
if (session && session.userId) {
|
||||
userId = session.userId;
|
||||
isAdmin = session.isAdmin;
|
||||
if (payload) {
|
||||
userId = payload.sub;
|
||||
isAdmin = payload.isAdmin;
|
||||
}
|
||||
|
||||
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
||||
const path = req.url || event.request?.url || "unknown";
|
||||
const method = req.method || event.request?.method || "GET";
|
||||
const userAgent =
|
||||
req.headers?.["user-agent"] ||
|
||||
event.request?.headers?.get("user-agent") ||
|
||||
undefined;
|
||||
getHeader(req.headers, "user-agent") ||
|
||||
getHeader(event.request?.headers, "user-agent");
|
||||
const referrer =
|
||||
req.headers?.referer ||
|
||||
req.headers?.referrer ||
|
||||
event.request?.headers?.get("referer") ||
|
||||
undefined;
|
||||
getHeader(req.headers, "referer") ||
|
||||
getHeader(req.headers, "referrer") ||
|
||||
getHeader(event.request?.headers, "referer");
|
||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
|
||||
const authHeader =
|
||||
event.request?.headers?.get("authorization") ||
|
||||
req.headers?.authorization ||
|
||||
req.headers?.Authorization ||
|
||||
getHeader(req.headers, "authorization") ||
|
||||
getHeader(event.request?.headers, "authorization") ||
|
||||
null;
|
||||
|
||||
let cairnUserId: string | null = null;
|
||||
let nessaUserId: string | null = null;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.replace("Bearer ", "").trim();
|
||||
try {
|
||||
const payload = await verifyCairnToken(token);
|
||||
cairnUserId = payload.sub;
|
||||
const payload = await verifyNessaToken(token);
|
||||
nessaUserId = payload.sub;
|
||||
} catch (error) {
|
||||
console.error("Cairn JWT verification failed:", error);
|
||||
console.error("Nessa JWT verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +79,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
method,
|
||||
userAgent,
|
||||
referrer,
|
||||
ipAddress,
|
||||
sessionId
|
||||
ipAddress
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -75,7 +88,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
event,
|
||||
userId,
|
||||
isAdmin,
|
||||
cairnUserId
|
||||
nessaUserId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,21 +128,21 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
const enforceCairnUser = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.cairnUserId) {
|
||||
const enforceNessaUser = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Cairn authentication required"
|
||||
message: "Nessa authentication required"
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
cairnUserId: ctx.cairnUserId
|
||||
nessaUserId: ctx.nessaUserId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
||||
export const cairnProcedure = t.procedure.use(enforceCairnUser);
|
||||
export const nessaProcedure = t.procedure.use(enforceNessaUser);
|
||||
|
||||
@@ -24,14 +24,10 @@ export type AuditEventType =
|
||||
| "auth.oauth.github.failed"
|
||||
| "auth.oauth.google.success"
|
||||
| "auth.oauth.google.failed"
|
||||
| "auth.session.revoke"
|
||||
| "auth.session.revokeAll"
|
||||
| "security.rate_limit.exceeded"
|
||||
| "security.csrf.failed"
|
||||
| "security.suspicious.activity"
|
||||
| "admin.action"
|
||||
| "auth.session_created"
|
||||
| "system.session_cleanup";
|
||||
| "admin.action";
|
||||
|
||||
/**
|
||||
* Audit log entry structure
|
||||
@@ -246,7 +242,6 @@ export async function getUserSecuritySummary(
|
||||
lastLoginAt: string | null;
|
||||
lastLoginIp: string | null;
|
||||
uniqueIpCount: number;
|
||||
recentSessions: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
@@ -336,16 +331,6 @@ export async function getUserSecuritySummary(
|
||||
});
|
||||
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
|
||||
|
||||
const sessionResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
AND event_type = 'auth.login.success'
|
||||
AND success = 1
|
||||
AND created_at >= datetime('now', '-1 day')`,
|
||||
args: [userId]
|
||||
});
|
||||
const recentSessions = (sessionResult.rows[0]?.count as number) || 0;
|
||||
|
||||
return {
|
||||
totalEvents,
|
||||
successfulEvents,
|
||||
@@ -356,8 +341,7 @@ export async function getUserSecuritySummary(
|
||||
failedLogins,
|
||||
lastLoginAt: lastLogin?.created_at as string | null,
|
||||
lastLoginIp: lastLogin?.ip_address as string | null,
|
||||
uniqueIpCount,
|
||||
recentSessions
|
||||
uniqueIpCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,136 @@
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import { getCookie, setCookie } from "vinxi/http";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import type { Row } from "@libsql/client/web";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
import { getAuthSession } from "./session-helpers";
|
||||
import { ConnectionFactory } from "./database";
|
||||
import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
|
||||
|
||||
export const authCookieName = "auth_token";
|
||||
|
||||
type AuthTokenPayload = {
|
||||
sub: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
};
|
||||
|
||||
function getAuthCookieOptions(rememberMe: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: rememberMe
|
||||
? expiryToSeconds(AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getAuthHeaderToken(event: H3Event): string | null {
|
||||
const requestHeader = event.request?.headers?.get?.("authorization") || null;
|
||||
const eventHeader = event.headers
|
||||
? typeof (event.headers as any).get === "function"
|
||||
? (event.headers as any).get("authorization")
|
||||
: (event.headers as any).authorization
|
||||
: null;
|
||||
const nodeHeader = event.node?.req?.headers?.authorization || null;
|
||||
const header = requestHeader || eventHeader || nodeHeader || null;
|
||||
if (!header) return null;
|
||||
const normalized = header.trim();
|
||||
if (!normalized.toLowerCase().startsWith("bearer ")) return null;
|
||||
return normalized.slice("Bearer ".length).trim();
|
||||
}
|
||||
|
||||
export function getAuthTokenFromEvent(event: H3Event): string | null {
|
||||
return getCookie(event, authCookieName) || getAuthHeaderToken(event);
|
||||
}
|
||||
|
||||
export async function verifyAuthToken(
|
||||
token: string
|
||||
): Promise<AuthTokenPayload | null> {
|
||||
try {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
if (!payload.sub) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sub: payload.sub as string,
|
||||
email: (payload.email as string | null) ?? null,
|
||||
isAdmin: (payload.isAdmin as boolean) ?? false,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Auth token verification failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthPayloadFromEvent(
|
||||
event: H3Event
|
||||
): Promise<AuthTokenPayload | null> {
|
||||
const token = getAuthTokenFromEvent(event);
|
||||
if (!token) return null;
|
||||
return verifyAuthToken(token);
|
||||
}
|
||||
|
||||
export async function issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
rememberMe
|
||||
}: {
|
||||
event: H3Event;
|
||||
userId: string;
|
||||
rememberMe: boolean;
|
||||
}): Promise<string> {
|
||||
const conn = ConnectionFactory();
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT email, is_admin FROM User WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const row = result.rows[0] as { email?: string | null; is_admin?: number };
|
||||
const isAdmin = row.is_admin === 1;
|
||||
const email = row.email ?? null;
|
||||
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const expiry = rememberMe
|
||||
? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG
|
||||
: getAccessTokenExpiry();
|
||||
|
||||
const token = await new SignJWT({ email, isAdmin })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiry)
|
||||
.sign(secret);
|
||||
|
||||
setCookie(event, authCookieName, token, getAuthCookieOptions(rememberMe));
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function clearAuthToken(event: H3Event): void {
|
||||
setCookie(event, authCookieName, "", {
|
||||
...getAuthCookieOptions(true),
|
||||
maxAge: 0
|
||||
});
|
||||
setCookie(event, "csrf-token", "", {
|
||||
httpOnly: false,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
@@ -15,9 +143,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
|
||||
isAdmin: boolean;
|
||||
}> {
|
||||
try {
|
||||
const session = await getAuthSession(event);
|
||||
|
||||
if (!session || !session.userId) {
|
||||
const payload = await getAuthPayloadFromEvent(event);
|
||||
if (!payload) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
userId: null,
|
||||
@@ -27,8 +154,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
|
||||
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
userId: session.userId,
|
||||
isAdmin: session.isAdmin
|
||||
userId: payload.sub,
|
||||
isAdmin: payload.isAdmin
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Auth check error:", error);
|
||||
@@ -41,7 +168,7 @@ export async function checkAuthStatus(event: H3Event): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID from session
|
||||
* Get user ID from auth token
|
||||
* @param event - H3Event
|
||||
* @returns User ID or null if not authenticated
|
||||
*/
|
||||
@@ -67,10 +194,8 @@ 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) {
|
||||
const payload = await verifyAuthToken(auth_token);
|
||||
if (!payload || email !== payload.email) {
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { jwtVerify } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
export type CairnAuthPayload = {
|
||||
sub: string;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
};
|
||||
|
||||
export async function verifyCairnToken(
|
||||
token: string
|
||||
): Promise<CairnAuthPayload> {
|
||||
const secret = new TextEncoder().encode(env.CAIRN_JWT_SECRET);
|
||||
const { payload } = await jwtVerify(token, secret, {
|
||||
algorithms: ["HS256"]
|
||||
});
|
||||
|
||||
return {
|
||||
sub: payload.sub as string,
|
||||
exp: payload.exp as number | undefined,
|
||||
iat: payload.iat as number | undefined
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
let mainDBConnection: ReturnType<typeof createClient> | null = null;
|
||||
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
|
||||
let cairnDBConnection: ReturnType<typeof createClient> | null = null;
|
||||
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
|
||||
|
||||
export function ConnectionFactory() {
|
||||
if (!mainDBConnection) {
|
||||
@@ -38,15 +38,15 @@ export function LineageConnectionFactory() {
|
||||
return lineageDBConnection;
|
||||
}
|
||||
|
||||
export function CairnConnectionFactory() {
|
||||
if (!cairnDBConnection) {
|
||||
export function NessaConnectionFactory() {
|
||||
if (!nessaDBConnection) {
|
||||
const config = {
|
||||
url: env.CAIRN_DB_URL,
|
||||
authToken: env.CAIRN_DB_TOKEN
|
||||
url: env.NESSA_DB_URL,
|
||||
authToken: env.NESSA_DB_TOKEN
|
||||
};
|
||||
cairnDBConnection = createClient(config);
|
||||
nessaDBConnection = createClient(config);
|
||||
}
|
||||
return cairnDBConnection;
|
||||
return nessaDBConnection;
|
||||
}
|
||||
|
||||
export async function LineageDBInit() {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
|
||||
|
||||
/**
|
||||
* Create a short device fingerprint for comparison
|
||||
* Not cryptographic, just for grouping similar sessions
|
||||
* Not cryptographic, just for grouping similar logins
|
||||
* @param deviceInfo - Device information
|
||||
* @returns Short fingerprint string
|
||||
*/
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
color: #856404;
|
||||
"
|
||||
>
|
||||
<li>Revoke all active sessions</li>
|
||||
<li>Change your password</li>
|
||||
<li>Review linked authentication providers</li>
|
||||
</ul>
|
||||
|
||||
39
src/server/nessa-auth.ts
Normal file
39
src/server/nessa-auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
const NESSA_JWT_EXPIRY = "30d";
|
||||
|
||||
export type NessaAuthPayload = {
|
||||
sub: string;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
};
|
||||
|
||||
export async function verifyNessaToken(
|
||||
token: string
|
||||
): Promise<NessaAuthPayload> {
|
||||
const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET);
|
||||
const { payload } = await jwtVerify(token, secret, {
|
||||
algorithms: ["HS256"]
|
||||
});
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new Error("Missing subject in Nessa JWT");
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub as string,
|
||||
exp: payload.exp as number | undefined,
|
||||
iat: payload.iat as number | undefined
|
||||
};
|
||||
}
|
||||
|
||||
export async function signNessaToken(userId: string): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.NESSA_JWT_SECRET);
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(NESSA_JWT_EXPIRY)
|
||||
.sign(secret);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ describe("CSRF Protection", () => {
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent token reuse from different session", () => {
|
||||
it("should prevent token reuse from different login", () => {
|
||||
const token1 = generateCSRFToken();
|
||||
const token2 = generateCSRFToken();
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { SessionConfig } from "vinxi/http";
|
||||
import { AUTH_CONFIG, expiryToSeconds } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session password directly from process.env
|
||||
* This avoids any bundler-time substitution issues with the validated env object
|
||||
*/
|
||||
function getSessionPassword(): string {
|
||||
// Read directly from process.env at runtime, not from bundled env object
|
||||
const password = process.env.JWT_SECRET_KEY;
|
||||
if (!password || password.trim() === "") {
|
||||
console.error(
|
||||
`[SessionConfig] JWT_SECRET_KEY missing from process.env! Keys available:`,
|
||||
Object.keys(process.env)
|
||||
.filter((k) => k.includes("JWT") || k.includes("SECRET"))
|
||||
.join(", ") || "none matching JWT/SECRET"
|
||||
);
|
||||
throw new Error(
|
||||
`JWT_SECRET_KEY is empty at runtime. Ensure it is set as a runtime environment variable in Vercel (not just build-time).`
|
||||
);
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session config with runtime password validation
|
||||
* Returns a fresh config each time to ensure env vars are read at call time,
|
||||
* not at module load time (important for serverless cold starts)
|
||||
*/
|
||||
export function getSessionConfig(): SessionConfig {
|
||||
return {
|
||||
password: getSessionPassword(),
|
||||
name: "session",
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vinxi session configuration
|
||||
* Using a getter ensures password is evaluated at access time, not module load time
|
||||
*/
|
||||
export const sessionConfig: SessionConfig = {
|
||||
get password() {
|
||||
return getSessionPassword();
|
||||
},
|
||||
name: "session",
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/"
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get session cookie options with appropriate maxAge
|
||||
* @param rememberMe - Whether to use extended session duration
|
||||
*/
|
||||
export function getSessionCookieOptions(rememberMe: boolean) {
|
||||
return {
|
||||
...sessionConfig.cookie,
|
||||
maxAge: rememberMe
|
||||
? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG)
|
||||
: undefined // Session cookie (expires on browser close)
|
||||
};
|
||||
}
|
||||
@@ -1,873 +0,0 @@
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import {
|
||||
clearSession,
|
||||
getSession,
|
||||
getCookie,
|
||||
setCookie,
|
||||
updateSession
|
||||
} from "vinxi/http";
|
||||
import { ConnectionFactory } from "./database";
|
||||
import { env } from "~/env/server";
|
||||
import { AUTH_CONFIG, expiryToSeconds, CACHE_CONFIG } from "~/config";
|
||||
import { logAuditEvent } from "./audit";
|
||||
import type { SessionData } from "./session-config";
|
||||
import { sessionConfig, getSessionConfig } from "./session-config";
|
||||
import { getDeviceInfo } from "./device-utils";
|
||||
import { cache } from "./cache";
|
||||
|
||||
/**
|
||||
* In-memory throttle for session activity updates
|
||||
* Tracks last update time per session to avoid excessive DB writes
|
||||
* In serverless, this is per-instance, but that's fine - updates are best-effort
|
||||
*/
|
||||
const sessionUpdateTimestamps = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Update session activity (last_used, last_active_at) with throttling
|
||||
* Only updates DB if > SESSION_ACTIVITY_UPDATE_THRESHOLD_MS since last update
|
||||
* Reduces 6,210 writes/period to ~60-100 writes (95%+ reduction)
|
||||
*
|
||||
* Security: Still secure - session validation happens every request (DB read)
|
||||
* UX: Session activity timestamps within 5min accuracy is acceptable
|
||||
*
|
||||
* @param sessionId - Session ID to update
|
||||
*/
|
||||
async function updateSessionActivityThrottled(
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const lastUpdate = sessionUpdateTimestamps.get(sessionId) || 0;
|
||||
const timeSinceLastUpdate = now - lastUpdate;
|
||||
|
||||
// Skip DB update if we updated recently
|
||||
if (timeSinceLastUpdate < CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update timestamp tracker
|
||||
sessionUpdateTimestamps.set(sessionId, now);
|
||||
|
||||
// Cleanup old entries (prevent memory leak in long-running instances)
|
||||
if (sessionUpdateTimestamps.size > 1000) {
|
||||
const oldestAllowed =
|
||||
now - 2 * CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS;
|
||||
for (const [sid, timestamp] of sessionUpdateTimestamps.entries()) {
|
||||
if (timestamp < oldestAllowed) {
|
||||
sessionUpdateTimestamps.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform DB update
|
||||
const conn = ConnectionFactory();
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
rememberMe: boolean,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
parentSessionId: string | null = null,
|
||||
tokenFamily: string | null = null
|
||||
): Promise<SessionData> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Fetch is_admin from database
|
||||
const userResult = await conn.execute({
|
||||
sql: "SELECT is_admin FROM User WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const isAdmin = userResult.rows[0].is_admin === 1;
|
||||
|
||||
const sessionId = uuidV4();
|
||||
const family = tokenFamily || uuidV4();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const tokenHash = hashRefreshToken(refreshToken);
|
||||
|
||||
// Parse device information
|
||||
const deviceInfo = getDeviceInfo(event);
|
||||
|
||||
// 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 with device metadata
|
||||
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,
|
||||
device_name, device_type, browser, os, last_active_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
sessionId,
|
||||
userId,
|
||||
family,
|
||||
tokenHash,
|
||||
parentSessionId,
|
||||
rotationCount,
|
||||
expiresAt.toISOString(),
|
||||
accessExpiresAt.toISOString(),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceInfo.deviceName || null,
|
||||
deviceInfo.deviceType || null,
|
||||
deviceInfo.browser || null,
|
||||
deviceInfo.os || null
|
||||
]
|
||||
});
|
||||
|
||||
// Create session data
|
||||
const sessionData: SessionData = {
|
||||
userId,
|
||||
sessionId,
|
||||
tokenFamily: family,
|
||||
isAdmin,
|
||||
refreshToken,
|
||||
rememberMe
|
||||
};
|
||||
|
||||
console.log("[Session Create] Creating session with data:", {
|
||||
userId,
|
||||
sessionId,
|
||||
isAdmin,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
rememberMe
|
||||
});
|
||||
|
||||
// Update Vinxi session with dynamic maxAge based on rememberMe
|
||||
// Use getSessionConfig() to ensure password is read at runtime
|
||||
const baseConfig = getSessionConfig();
|
||||
const configWithMaxAge = {
|
||||
...baseConfig,
|
||||
maxAge: rememberMe
|
||||
? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG)
|
||||
: undefined // Session cookie (expires on browser close)
|
||||
};
|
||||
|
||||
const session = await updateSession(event, configWithMaxAge, sessionData);
|
||||
|
||||
// Explicitly seal/flush the session to ensure cookie is written
|
||||
// This is important in serverless environments where response might stream early
|
||||
const { sealSession } = await import("vinxi/http");
|
||||
await sealSession(event, configWithMaxAge);
|
||||
|
||||
setCookie(event, "session_id", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: configWithMaxAge.maxAge
|
||||
});
|
||||
|
||||
try {
|
||||
const cookieName = sessionConfig.name || "session";
|
||||
const cookieValue = getCookie(event, cookieName);
|
||||
|
||||
const verifySession = await getSession<SessionData>(
|
||||
event,
|
||||
configWithMaxAge
|
||||
);
|
||||
} catch (verifyError) {
|
||||
console.error("[Session Create] Failed to verify session:", verifyError);
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.session_created",
|
||||
eventData: {
|
||||
sessionId,
|
||||
tokenFamily: family,
|
||||
rememberMe,
|
||||
parentSessionId,
|
||||
deviceName: deviceInfo.deviceName,
|
||||
deviceType: deviceInfo.deviceType
|
||||
},
|
||||
success: true
|
||||
});
|
||||
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session from Vinxi and validate against database
|
||||
* @param event - H3Event
|
||||
* @param skipUpdate - If true, don't update the session cookie (for SSR contexts)
|
||||
* @returns Session data or null if invalid/expired
|
||||
*/
|
||||
export async function getAuthSession(
|
||||
event: H3Event,
|
||||
skipUpdate = false
|
||||
): Promise<SessionData | null> {
|
||||
try {
|
||||
// In SSR contexts where headers may already be sent, use unsealSession directly
|
||||
if (skipUpdate) {
|
||||
const { unsealSession } = await import("vinxi/http");
|
||||
const cookieName = sessionConfig.name || "session";
|
||||
const cookieValue = getCookie(event, cookieName);
|
||||
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// unsealSession returns Partial<Session<T>>, not T directly
|
||||
const session = await unsealSession(event, sessionConfig, cookieValue);
|
||||
|
||||
if (!session?.data || typeof session.data !== "object") {
|
||||
const sessionIdCookie = getCookie(event, "session_id");
|
||||
if (sessionIdCookie) {
|
||||
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
||||
if (restored) {
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = session.data as SessionData;
|
||||
|
||||
if (!data.userId || !data.sessionId) {
|
||||
const sessionIdCookie = getCookie(event, "session_id");
|
||||
|
||||
if (sessionIdCookie) {
|
||||
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
||||
if (restored) {
|
||||
return restored;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate session against database
|
||||
const isValid = await validateSessionInDB(
|
||||
data.sessionId,
|
||||
data.userId,
|
||||
data.refreshToken
|
||||
);
|
||||
|
||||
return isValid ? data : null;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Session Get] Error in skipUpdate path (likely decryption failure):",
|
||||
err
|
||||
);
|
||||
// If decryption failed (after server restart), try DB restoration
|
||||
const sessionIdCookie = getCookie(event, "session_id");
|
||||
if (sessionIdCookie) {
|
||||
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
||||
if (restored) {
|
||||
return restored;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal path - allow session updates
|
||||
|
||||
const session = await getSession<SessionData>(event, sessionConfig);
|
||||
|
||||
if (!session.data || !session.data.userId || !session.data.sessionId) {
|
||||
// Fallback: Try to restore from DB using session_id cookie
|
||||
const sessionIdCookie = getCookie(event, "session_id");
|
||||
|
||||
if (sessionIdCookie) {
|
||||
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
||||
if (restored) {
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate session against database
|
||||
const isValid = await validateSessionInDB(
|
||||
session.data.sessionId,
|
||||
session.data.userId,
|
||||
session.data.refreshToken
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
// Clear invalid session - wrap in try/catch for headers-sent error
|
||||
try {
|
||||
await clearSession(event, sessionConfig);
|
||||
} catch (clearError: any) {
|
||||
// If headers already sent, we can't clear the cookie, but that's OK
|
||||
// The session is invalid in DB anyway
|
||||
if (clearError?.code !== "ERR_HTTP_HEADERS_SENT") {
|
||||
throw clearError;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.data;
|
||||
} catch (error: any) {
|
||||
// If headers already sent, we can't read the session cookie properly
|
||||
// This can happen in SSR when response streaming has started
|
||||
if (error?.code === "ERR_HTTP_HEADERS_SENT") {
|
||||
// Retry with skipUpdate
|
||||
return getAuthSession(event, true);
|
||||
}
|
||||
console.error("Error getting auth session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest valid session in a rotation chain
|
||||
* Recursively follows child sessions until finding the most recent one
|
||||
* @param conn - Database connection
|
||||
* @param sessionId - Starting session ID
|
||||
* @param maxDepth - Maximum depth to traverse (prevents infinite loops)
|
||||
* @returns Latest session row or null if chain is invalid
|
||||
*/
|
||||
async function findLatestSessionInChain(
|
||||
conn: ReturnType<typeof ConnectionFactory>,
|
||||
sessionId: string,
|
||||
maxDepth: number = 100
|
||||
): Promise<any | null> {
|
||||
if (maxDepth <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the current session
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT id, user_id, token_family, revoked, expires_at
|
||||
FROM Session
|
||||
WHERE id = ?`,
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentSession = result.rows[0];
|
||||
|
||||
// Check if this session has been rotated (has a child)
|
||||
const childCheck = await conn.execute({
|
||||
sql: `SELECT id FROM Session
|
||||
WHERE parent_session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
if (childCheck.rows.length > 0) {
|
||||
return findLatestSessionInChain(
|
||||
conn,
|
||||
childCheck.rows[0].id as string,
|
||||
maxDepth - 1
|
||||
);
|
||||
}
|
||||
|
||||
if (currentSession.revoked === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(currentSession.expires_at as string);
|
||||
if (expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session from database when cookie data is empty/corrupt
|
||||
* This provides a fallback mechanism for session recovery
|
||||
* @param event - H3Event
|
||||
* @param sessionId - Session ID from fallback cookie
|
||||
* @returns Session data or null if cannot restore
|
||||
*/
|
||||
async function restoreSessionFromDB(
|
||||
event: H3Event,
|
||||
sessionId: string
|
||||
): Promise<SessionData | null> {
|
||||
try {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const { getRequestIP } = await import("vinxi/http");
|
||||
const ipAddress = getRequestIP(event) || "unknown";
|
||||
const userAgent = event.node?.req?.headers["user-agent"] || "unknown";
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash,
|
||||
s.revoked, s.expires_at, u.is_admin
|
||||
FROM Session s
|
||||
JOIN User u ON s.user_id = u.id
|
||||
WHERE s.id = ?`,
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dbSession = result.rows[0];
|
||||
|
||||
const expiresAt = new Date(dbSession.expires_at as string);
|
||||
if (expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this session has already been rotated (has a child session)
|
||||
// If so, follow the chain to find the latest valid session
|
||||
// We check this BEFORE checking if revoked because revoked parents can have valid children
|
||||
const childCheck = await conn.execute({
|
||||
sql: `SELECT id, revoked, expires_at, refresh_token_hash
|
||||
FROM Session
|
||||
WHERE parent_session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
if (childCheck.rows.length > 0) {
|
||||
const latestSession = await findLatestSessionInChain(
|
||||
conn,
|
||||
childCheck.rows[0].id as string
|
||||
);
|
||||
|
||||
if (!latestSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newSession = await createAuthSession(
|
||||
event,
|
||||
latestSession.user_id as string,
|
||||
true, // Assume rememberMe=true for restoration
|
||||
ipAddress,
|
||||
userAgent,
|
||||
latestSession.id as string, // Parent is the latest session
|
||||
latestSession.token_family as string // Reuse family
|
||||
);
|
||||
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||
args: [latestSession.id]
|
||||
});
|
||||
|
||||
return newSession;
|
||||
}
|
||||
|
||||
// No children - this is the current session
|
||||
// Validate it's not revoked (if no children, revoked = invalid)
|
||||
if (dbSession.revoked === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We can't restore the refresh token (it's hashed in DB)
|
||||
|
||||
const newSession = await createAuthSession(
|
||||
event,
|
||||
dbSession.user_id as string,
|
||||
true, // Assume rememberMe=true for restoration
|
||||
ipAddress,
|
||||
userAgent,
|
||||
sessionId, // Parent session
|
||||
dbSession.token_family as string // Reuse family
|
||||
);
|
||||
|
||||
// Mark parent session as revoked now that we've rotated it
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
return newSession;
|
||||
} catch (error) {
|
||||
console.error("[Session Restore] Error restoring session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session against database
|
||||
* Checks if session exists, not revoked, not expired, and refresh token matches
|
||||
* Also validates that no child sessions exist (indicating this session was rotated)
|
||||
* @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, token_family
|
||||
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;
|
||||
}
|
||||
|
||||
// CRITICAL: Check if this session has been rotated (has a child session)
|
||||
// If a child exists, check if we're within the grace period for cookie propagation
|
||||
// This handles SSR/serverless cases where client may not have received new cookies yet
|
||||
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) {
|
||||
// This session has been rotated - check grace period
|
||||
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 allows client to receive and use new cookies from rotation
|
||||
// This is critical for SSR/serverless where response cookies may be delayed
|
||||
if (timeSinceRotation >= AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_used and last_active_at timestamps (throttled)
|
||||
// Only update DB if last update was > 5 minutes ago (reduces writes by 95%+)
|
||||
updateSessionActivityThrottled(sessionId).catch((err) =>
|
||||
console.error("Failed to update session timestamps:", 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();
|
||||
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
await clearSession(event, sessionConfig);
|
||||
|
||||
// Also clear the session_id fallback cookie
|
||||
setCookie(event, "session_id", "", {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0 // Expire immediately
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reuse detected outside grace period - this is a breach!
|
||||
|
||||
// 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> {
|
||||
// Validate old session exists in DB
|
||||
const isValid = await validateSessionInDB(
|
||||
oldSessionData.sessionId,
|
||||
oldSessionData.userId,
|
||||
oldSessionData.refreshToken
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detect token reuse (breach detection)
|
||||
const reuseDetected = await detectTokenReuse(oldSessionData.sessionId);
|
||||
if (reuseDetected) {
|
||||
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) {
|
||||
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.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
|
||||
});
|
||||
|
||||
return newSessionData;
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { ConnectionFactory } from "./database";
|
||||
import type { Session } from "~/db/types";
|
||||
import { formatDeviceDescription } from "./device-utils";
|
||||
|
||||
/**
|
||||
* Get all active sessions for a user
|
||||
* @param userId - User ID
|
||||
* @returns Array of active sessions with formatted device info
|
||||
*/
|
||||
export async function getUserActiveSessions(userId: string): Promise<
|
||||
Array<{
|
||||
sessionId: string;
|
||||
deviceDescription: string;
|
||||
deviceType?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
ipAddress?: string;
|
||||
lastActive: string;
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
}>
|
||||
> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT
|
||||
id, device_name, device_type, browser, os,
|
||||
ip_address, last_active_at, created_at, token_family
|
||||
FROM Session
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||
ORDER BY last_active_at DESC`,
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
return result.rows.map((row: any) => {
|
||||
const deviceInfo = {
|
||||
deviceName: row.device_name,
|
||||
deviceType: row.device_type,
|
||||
browser: row.browser,
|
||||
os: row.os
|
||||
};
|
||||
|
||||
return {
|
||||
sessionId: row.id,
|
||||
deviceDescription: formatDeviceDescription(deviceInfo),
|
||||
deviceType: row.device_type,
|
||||
browser: row.browser,
|
||||
os: row.os,
|
||||
ipAddress: row.ip_address,
|
||||
lastActive: row.last_active_at,
|
||||
createdAt: row.created_at,
|
||||
current: false // Will be set by caller if needed
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific session (not entire token family)
|
||||
* Useful for "logout from this device" functionality
|
||||
* @param userId - User ID (for verification)
|
||||
* @param sessionId - Session ID to revoke
|
||||
* @throws Error if session not found or doesn't belong to user
|
||||
*/
|
||||
export async function revokeUserSession(
|
||||
userId: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Verify session belongs to user
|
||||
const verifyResult = await conn.execute({
|
||||
sql: "SELECT user_id FROM Session WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
if (verifyResult.rows.length === 0) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const sessionUserId = (verifyResult.rows[0] as any).user_id;
|
||||
if (sessionUserId !== userId) {
|
||||
throw new Error("Session does not belong to this user");
|
||||
}
|
||||
|
||||
// Revoke the session
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all sessions for a user EXCEPT the current one
|
||||
* Useful for "logout from all other devices"
|
||||
* @param userId - User ID
|
||||
* @param currentSessionId - Current session ID to keep active
|
||||
* @returns Number of sessions revoked
|
||||
*/
|
||||
export async function revokeOtherUserSessions(
|
||||
userId: string,
|
||||
currentSessionId: string
|
||||
): Promise<number> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0",
|
||||
args: [userId, currentSessionId]
|
||||
});
|
||||
|
||||
return (result as any).rowsAffected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session count by device type for a user
|
||||
* @param userId - User ID
|
||||
* @returns Object with counts by device type
|
||||
*/
|
||||
export async function getSessionCountByDevice(userId: string): Promise<{
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
tablet: number;
|
||||
unknown: number;
|
||||
total: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT
|
||||
device_type,
|
||||
COUNT(*) as count
|
||||
FROM Session
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||
GROUP BY device_type`,
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
const counts = {
|
||||
desktop: 0,
|
||||
mobile: 0,
|
||||
tablet: 0,
|
||||
unknown: 0,
|
||||
total: 0
|
||||
};
|
||||
|
||||
for (const row of result.rows) {
|
||||
const deviceType = (row as any).device_type;
|
||||
const count = (row as any).count;
|
||||
|
||||
if (deviceType === "desktop") {
|
||||
counts.desktop = count;
|
||||
} else if (deviceType === "mobile") {
|
||||
counts.mobile = count;
|
||||
} else if (deviceType === "tablet") {
|
||||
counts.tablet = count;
|
||||
} else {
|
||||
counts.unknown = count;
|
||||
}
|
||||
|
||||
counts.total += count;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific device fingerprint already has an active session
|
||||
* Can be used to show "You're already logged in on this device" messages
|
||||
* @param userId - User ID
|
||||
* @param deviceType - Device type
|
||||
* @param browser - Browser name
|
||||
* @param os - OS name
|
||||
* @returns true if device has active session
|
||||
*/
|
||||
export async function hasActiveSessionOnDevice(
|
||||
userId: string,
|
||||
deviceType?: string,
|
||||
browser?: string,
|
||||
os?: string
|
||||
): Promise<boolean> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT id FROM Session
|
||||
WHERE user_id = ?
|
||||
AND device_type = ?
|
||||
AND browser = ?
|
||||
AND os = ?
|
||||
AND revoked = 0
|
||||
AND expires_at > datetime('now')
|
||||
LIMIT 1`,
|
||||
args: [userId, deviceType || null, browser || null, os || null]
|
||||
});
|
||||
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { ConnectionFactory } from "~/server/utils";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { AUTH_CONFIG } from "~/config";
|
||||
|
||||
/**
|
||||
* Cleanup expired and revoked sessions
|
||||
* Keeps sessions for audit purposes up to retention limit
|
||||
* @param retentionDays - How long to keep revoked sessions (default 90)
|
||||
* @returns Cleanup statistics
|
||||
*/
|
||||
export async function cleanupExpiredSessions(
|
||||
retentionDays: number = AUTH_CONFIG.SESSION_CLEANUP_RETENTION_DAYS
|
||||
): Promise<{
|
||||
expiredDeleted: number;
|
||||
revokedDeleted: number;
|
||||
totalDeleted: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - retentionDays);
|
||||
|
||||
try {
|
||||
// Step 1: Delete expired sessions (hard delete)
|
||||
const expiredResult = await conn.execute({
|
||||
sql: `DELETE FROM Session
|
||||
WHERE expires_at < datetime('now')
|
||||
AND created_at < ?`,
|
||||
args: [retentionDate.toISOString()]
|
||||
});
|
||||
|
||||
// Step 2: Delete old revoked sessions (keep recent for audit)
|
||||
const revokedResult = await conn.execute({
|
||||
sql: `DELETE FROM Session
|
||||
WHERE revoked = 1
|
||||
AND created_at < ?`,
|
||||
args: [retentionDate.toISOString()]
|
||||
});
|
||||
|
||||
const stats = {
|
||||
expiredDeleted: Number(expiredResult.rowsAffected) || 0,
|
||||
revokedDeleted: Number(revokedResult.rowsAffected) || 0,
|
||||
totalDeleted:
|
||||
(Number(expiredResult.rowsAffected) || 0) +
|
||||
(Number(revokedResult.rowsAffected) || 0)
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Session cleanup completed: ${stats.totalDeleted} sessions deleted ` +
|
||||
`(${stats.expiredDeleted} expired, ${stats.revokedDeleted} revoked)`
|
||||
);
|
||||
|
||||
// Log cleanup event
|
||||
await logAuditEvent({
|
||||
eventType: "system.session_cleanup",
|
||||
eventData: stats,
|
||||
success: true
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error("Session cleanup failed:", error);
|
||||
|
||||
await logAuditEvent({
|
||||
eventType: "system.session_cleanup",
|
||||
eventData: { error: String(error) },
|
||||
success: false
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup orphaned parent session references
|
||||
* Remove parent_session_id references to deleted sessions
|
||||
*/
|
||||
export async function cleanupOrphanedReferences(): Promise<number> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `UPDATE Session
|
||||
SET parent_session_id = NULL
|
||||
WHERE parent_session_id IS NOT NULL
|
||||
AND parent_session_id NOT IN (
|
||||
SELECT id FROM Session
|
||||
)`
|
||||
});
|
||||
|
||||
const orphansFixed = Number(result.rowsAffected) || 0;
|
||||
if (orphansFixed > 0) {
|
||||
console.log(`Fixed ${orphansFixed} orphaned parent_session_id references`);
|
||||
}
|
||||
|
||||
return orphansFixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics for monitoring
|
||||
*/
|
||||
export async function getSessionStats(): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
expired: number;
|
||||
revoked: number;
|
||||
avgRotationCount: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const totalResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM Session"
|
||||
});
|
||||
|
||||
const activeResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM Session
|
||||
WHERE revoked = 0 AND expires_at > datetime('now')`
|
||||
});
|
||||
|
||||
const expiredResult = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM Session
|
||||
WHERE expires_at < datetime('now')`
|
||||
});
|
||||
|
||||
const revokedResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM Session WHERE revoked = 1"
|
||||
});
|
||||
|
||||
const rotationResult = await conn.execute({
|
||||
sql: "SELECT AVG(rotation_count) as avg FROM Session WHERE revoked = 0"
|
||||
});
|
||||
|
||||
return {
|
||||
total: Number(totalResult.rows[0]?.count) || 0,
|
||||
active: Number(activeResult.rows[0]?.count) || 0,
|
||||
expired: Number(expiredResult.rows[0]?.count) || 0,
|
||||
revoked: Number(revokedResult.rows[0]?.count) || 0,
|
||||
avgRotationCount: Number(rotationResult.rows[0]?.avg) || 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunistic cleanup trigger
|
||||
* Runs cleanup if it hasn't been run recently (serverless-friendly)
|
||||
* Uses a simple timestamp check to avoid running too frequently
|
||||
*/
|
||||
let lastCleanupTime = 0;
|
||||
|
||||
export async function opportunisticCleanup(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const minIntervalMs =
|
||||
AUTH_CONFIG.SESSION_CLEANUP_INTERVAL_HOURS * 60 * 60 * 1000;
|
||||
|
||||
// Only run if enough time has passed since last cleanup
|
||||
if (now - lastCleanupTime < minIntervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update timestamp immediately to prevent concurrent runs
|
||||
lastCleanupTime = now;
|
||||
|
||||
try {
|
||||
console.log("Running opportunistic session cleanup...");
|
||||
|
||||
// Run cleanup asynchronously (don't block the request)
|
||||
Promise.all([cleanupExpiredSessions(), cleanupOrphanedReferences()])
|
||||
.then(([stats, orphansFixed]) => {
|
||||
console.log(
|
||||
`Opportunistic cleanup completed: ${stats.totalDeleted} sessions deleted, ` +
|
||||
`${orphansFixed} orphaned references fixed`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Opportunistic cleanup error:", error);
|
||||
// Reset timer on failure so we can retry sooner
|
||||
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000; // Retry in 5 minutes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Opportunistic cleanup trigger error:", error);
|
||||
// Reset timer on failure
|
||||
lastCleanupTime = now - minIntervalMs + 5 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user