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 />
|
<LeftBar />
|
||||||
<div
|
<div
|
||||||
id="center-body"
|
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>
|
<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.
|
JavaScript is disabled. Features will be limited.
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ export function LeftBar() {
|
|||||||
const getMainNavStyles = () => {
|
const getMainNavStyles = () => {
|
||||||
const baseStyles = {
|
const baseStyles = {
|
||||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
"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-top": "env(safe-area-inset-top)",
|
||||||
"padding-bottom": "env(safe-area-inset-bottom)"
|
"padding-bottom": "env(safe-area-inset-bottom)"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export function Btop(props: BtopProps) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
|
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH);
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
|
setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH);
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
onCleanup(() => window.removeEventListener("resize", handleResize));
|
onCleanup(() => window.removeEventListener("resize", handleResize));
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ export interface PostLike {
|
|||||||
post_id: string;
|
post_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionDependantLikeProps {
|
export interface AuthenticatedLikeProps {
|
||||||
currentUserID: string | undefined | null;
|
currentUserID: string | undefined | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
likes: PostLike[];
|
likes: PostLike[];
|
||||||
projectID: number;
|
projectID: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SessionDependantLike(props: SessionDependantLikeProps) {
|
export default function AuthenticatedLike(props: AuthenticatedLikeProps) {
|
||||||
const [hovering, setHovering] = createSignal(false);
|
const [hovering, setHovering] = createSignal(false);
|
||||||
const [likes, setLikes] = createSignal(props.likes);
|
const [likes, setLikes] = createSignal(props.likes);
|
||||||
const [instantOffset, setInstantOffset] = createSignal(0);
|
const [instantOffset, setInstantOffset] = createSignal(0);
|
||||||
@@ -7,37 +7,21 @@
|
|||||||
*
|
*
|
||||||
* Security Model:
|
* Security Model:
|
||||||
* - Access tokens: Short-lived (15m), contain user identity, stored in httpOnly cookie
|
* - 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
|
* - rememberMe tokens: Long-lived (30d), issued as JWT without refresh tokens
|
||||||
* - Token rotation: Each refresh invalidates old token and issues new pair
|
|
||||||
* - Breach detection: Reusing invalidated token revokes entire token family
|
|
||||||
*
|
*
|
||||||
* Cookie Behavior:
|
* 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
|
* - rememberMe = true: Persistent cookies (with maxAge) - survive browser restart
|
||||||
*
|
*
|
||||||
* Timing Decisions:
|
* Timing Decisions:
|
||||||
* - 15m access: Balance between security (short exposure) and UX (not too frequent refreshes)
|
* - 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)
|
* - 30d remember: Extended convenience for trusted devices
|
||||||
* - 90d remember: Extended convenience for trusted devices (both DB and cookie persist)
|
|
||||||
* - 5s reuse window: Handles race conditions in distributed systems
|
|
||||||
*/
|
*/
|
||||||
export const AUTH_CONFIG = {
|
export const AUTH_CONFIG = {
|
||||||
// Access Token (JWT in cookie)
|
// Access Token (JWT in cookie)
|
||||||
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
|
ACCESS_TOKEN_EXPIRY: "15m" as const, // 15 minutes (short-lived)
|
||||||
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
|
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
|
||||||
|
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Other Auth Settings
|
// Other Auth Settings
|
||||||
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
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
|
* 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
|
* @returns Seconds as number
|
||||||
*/
|
*/
|
||||||
export function expiryToSeconds(expiry: string): number {
|
export function expiryToSeconds(expiry: string): number {
|
||||||
@@ -71,31 +55,12 @@ export function expiryToSeconds(expiry: string): number {
|
|||||||
throw new Error(`Invalid expiry format: ${expiry}`);
|
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
|
* Type helper for token expiry strings
|
||||||
*/
|
*/
|
||||||
export type TokenExpiry =
|
export type TokenExpiry =
|
||||||
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
||||||
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT
|
| typeof AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_LONG;
|
||||||
| typeof AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG;
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// RATE LIMITING
|
// RATE LIMITING
|
||||||
@@ -151,9 +116,6 @@ export const CACHE_CONFIG = {
|
|||||||
MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000,
|
MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000,
|
||||||
GIT_ACTIVITY_MAX_STALE_MS: 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 in-memory cache TTL (reduces DB reads)
|
||||||
RATE_LIMIT_CACHE_TTL_MS: 60 * 1000, // 1 minute
|
RATE_LIMIT_CACHE_TTL_MS: 60 * 1000, // 1 minute
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { createAsync, revalidate } from "@solidjs/router";
|
import { createAsync, revalidate } from "@solidjs/router";
|
||||||
import { getUserState, type UserState } from "~/lib/auth-query";
|
import { getUserState, type UserState } from "~/lib/auth-query";
|
||||||
import { tokenRefreshManager } from "~/lib/token-refresh";
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
/** Current user state (for UI display) */
|
/** 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 = {
|
const value: AuthContextType = {
|
||||||
userState: serverAuth,
|
userState: serverAuth,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Accessor, createContext, useContext } from "solid-js";
|
import { Accessor, createContext, useContext } from "solid-js";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
export const STATIC_BAR_SIZE = 250;
|
|
||||||
|
|
||||||
const BarsContext = createContext<{
|
const BarsContext = createContext<{
|
||||||
leftBarVisible: Accessor<boolean>;
|
leftBarVisible: Accessor<boolean>;
|
||||||
setLeftBarVisible: (visible: boolean) => void;
|
setLeftBarVisible: (visible: boolean) => void;
|
||||||
|
|||||||
@@ -15,38 +15,6 @@ export const model: { [key: string]: string } = {
|
|||||||
locked_until TEXT
|
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: `
|
UserProvider: `
|
||||||
CREATE TABLE UserProvider
|
CREATE TABLE UserProvider
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -17,23 +17,6 @@ export interface User {
|
|||||||
locked_until?: string | null;
|
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 {
|
export interface UserProvider {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -165,7 +148,6 @@ export interface VisitorAnalytics {
|
|||||||
device_type?: string | null;
|
device_type?: string | null;
|
||||||
browser?: string | null;
|
browser?: string | null;
|
||||||
os?: string | null;
|
os?: string | null;
|
||||||
session_id?: string | null;
|
|
||||||
duration_ms?: number | null;
|
duration_ms?: number | null;
|
||||||
fcp?: number | null;
|
fcp?: number | null;
|
||||||
lcp?: 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),
|
JWT_SECRET_KEY: z.string().min(1),
|
||||||
AWS_REGION: z.string().min(1),
|
AWS_REGION: z.string().min(1),
|
||||||
AWS_S3_BUCKET_NAME: z.string().min(1),
|
AWS_S3_BUCKET_NAME: z.string().min(1),
|
||||||
_AWS_ACCESS_KEY: z.string().min(1),
|
MY_AWS_ACCESS_KEY: z.string().min(1),
|
||||||
_AWS_SECRET_KEY: z.string().min(1),
|
MY_AWS_SECRET_KEY: z.string().min(1),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().min(1),
|
GOOGLE_CLIENT_SECRET: z.string().min(1),
|
||||||
GITHUB_CLIENT_SECRET: z.string().min(1),
|
GITHUB_CLIENT_SECRET: z.string().min(1),
|
||||||
EMAIL_SERVER: 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),
|
VITE_INFILL_ENDPOINT: z.string().min(1),
|
||||||
INFILL_BEARER_TOKEN: z.string().min(1),
|
INFILL_BEARER_TOKEN: z.string().min(1),
|
||||||
REDIS_URL: z.string().min(1),
|
REDIS_URL: z.string().min(1),
|
||||||
CAIRN_DB_URL: z.string().min(1),
|
NESSA_DB_URL: z.string().min(1),
|
||||||
CAIRN_DB_TOKEN: z.string().min(1),
|
NESSA_DB_TOKEN: z.string().min(1),
|
||||||
CAIRN_JWT_SECRET: z.string().min(1)
|
NESSA_JWT_SECRET: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
@@ -113,8 +113,8 @@ export const getMissingEnvVars = (): string[] => {
|
|||||||
"JWT_SECRET_KEY",
|
"JWT_SECRET_KEY",
|
||||||
"AWS_REGION",
|
"AWS_REGION",
|
||||||
"AWS_S3_BUCKET_NAME",
|
"AWS_S3_BUCKET_NAME",
|
||||||
"_AWS_ACCESS_KEY",
|
"MY_AWS_ACCESS_KEY",
|
||||||
"_AWS_SECRET_KEY",
|
"MY_AWS_SECRET_KEY",
|
||||||
"GOOGLE_CLIENT_SECRET",
|
"GOOGLE_CLIENT_SECRET",
|
||||||
"GITHUB_CLIENT_SECRET",
|
"GITHUB_CLIENT_SECRET",
|
||||||
"EMAIL_SERVER",
|
"EMAIL_SERVER",
|
||||||
@@ -137,9 +137,9 @@ export const getMissingEnvVars = (): string[] => {
|
|||||||
"VITE_GITHUB_CLIENT_ID",
|
"VITE_GITHUB_CLIENT_ID",
|
||||||
"VITE_WEBSOCKET",
|
"VITE_WEBSOCKET",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
"CAIRN_DB_URL",
|
"NESSA_DB_URL",
|
||||||
"CAIRN_DB_TOKEN",
|
"NESSA_DB_TOKEN",
|
||||||
"CAIRN_JWT_SECRET"
|
"NESSA_JWT_SECRET"
|
||||||
];
|
];
|
||||||
|
|
||||||
return requiredServerVars.filter((varName) => isMissingEnvVar(varName));
|
return requiredServerVars.filter((varName) => isMissingEnvVar(varName));
|
||||||
|
|||||||
@@ -86,11 +86,5 @@ export function revalidateAuth() {
|
|||||||
// Dispatch event to trigger UI updates (client-side only)
|
// Dispatch event to trigger UI updates (client-side only)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new CustomEvent("auth-state-changed"));
|
window.dispatchEvent(new CustomEvent("auth-state-changed"));
|
||||||
|
|
||||||
// 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" />
|
<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 */}
|
{/* Sign Out Section */}
|
||||||
<div class="mx-auto max-w-md py-4">
|
<div class="mx-auto max-w-md py-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -1147,156 +1135,3 @@ function LinkedProviders(props: { userId: string }) {
|
|||||||
</div>
|
</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 key = "api/Gaze/appcast.xml";
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { createServerCaller } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/utils";
|
|
||||||
|
|
||||||
export async function GET(event: APIEvent) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
@@ -31,8 +30,7 @@ export async function GET(event: APIEvent) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[GitHub OAuth Callback] Creating tRPC caller...");
|
console.log("[GitHub OAuth Callback] Creating tRPC caller...");
|
||||||
const ctx = await createTRPCContext(event);
|
const caller = await createServerCaller(event);
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
console.log("[GitHub OAuth Callback] Calling githubCallback procedure...");
|
console.log("[GitHub OAuth Callback] Calling githubCallback procedure...");
|
||||||
const result = await caller.auth.githubCallback({ code });
|
const result = await caller.auth.githubCallback({ code });
|
||||||
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
|
|||||||
result.redirectTo
|
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
|
// Just redirect - the cookies are already in the response
|
||||||
const redirectUrl = result.redirectTo || "/account";
|
const redirectUrl = result.redirectTo || "/account";
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { createServerCaller } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/utils";
|
|
||||||
|
|
||||||
export async function GET(event: APIEvent) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
@@ -31,8 +30,7 @@ export async function GET(event: APIEvent) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[Google OAuth Callback] Creating tRPC caller...");
|
console.log("[Google OAuth Callback] Creating tRPC caller...");
|
||||||
const ctx = await createTRPCContext(event);
|
const caller = await createServerCaller(event);
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
console.log("[Google OAuth Callback] Calling googleCallback procedure...");
|
console.log("[Google OAuth Callback] Calling googleCallback procedure...");
|
||||||
const result = await caller.auth.googleCallback({ code });
|
const result = await caller.auth.googleCallback({ code });
|
||||||
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
|
|||||||
result.redirectTo
|
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
|
// Just redirect - the cookies are already in the response
|
||||||
const redirectUrl = result.redirectTo || "/account";
|
const redirectUrl = result.redirectTo || "/account";
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { createServerCaller } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/utils";
|
|
||||||
|
|
||||||
export async function GET(event: APIEvent) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
@@ -27,8 +26,7 @@ export async function GET(event: APIEvent) {
|
|||||||
try {
|
try {
|
||||||
console.log("[Email Login Callback] Creating tRPC caller...");
|
console.log("[Email Login Callback] Creating tRPC caller...");
|
||||||
// Create tRPC caller to invoke the emailLogin procedure
|
// Create tRPC caller to invoke the emailLogin procedure
|
||||||
const ctx = await createTRPCContext(event);
|
const caller = await createServerCaller(event);
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
console.log("[Email Login Callback] Calling emailLogin procedure...");
|
console.log("[Email Login Callback] Calling emailLogin procedure...");
|
||||||
// Call the email login handler - rememberMe will be read from JWT payload
|
// Call the email login handler - rememberMe will be read from JWT payload
|
||||||
@@ -45,7 +43,7 @@ export async function GET(event: APIEvent) {
|
|||||||
result.redirectTo
|
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
|
// Just redirect - the cookies are already in the response
|
||||||
const redirectUrl = result.redirectTo || "/account";
|
const redirectUrl = result.redirectTo || "/account";
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { createServerCaller } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/utils";
|
|
||||||
|
|
||||||
export async function GET(event: APIEvent) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
@@ -57,20 +56,19 @@ export async function GET(event: APIEvent) {
|
|||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "text/html" },
|
headers: { "Content-Type": "text/html" }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create tRPC caller to invoke the emailVerification procedure
|
// Create tRPC caller to invoke the emailVerification procedure
|
||||||
const ctx = await createTRPCContext(event);
|
const caller = await createServerCaller(event);
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
// Call the email verification handler
|
// Call the email verification handler
|
||||||
const result = await caller.auth.emailVerification({
|
const result = await caller.auth.emailVerification({
|
||||||
email,
|
email,
|
||||||
token,
|
token
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -129,7 +127,7 @@ export async function GET(event: APIEvent) {
|
|||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "text/html" },
|
headers: { "Content-Type": "text/html" }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -139,8 +137,10 @@ export async function GET(event: APIEvent) {
|
|||||||
console.error("Email verification callback error:", error);
|
console.error("Email verification callback error:", error);
|
||||||
|
|
||||||
// Check if it's a token expiration error
|
// Check if it's a token expiration error
|
||||||
const errorMessage = error instanceof Error ? error.message : "server_error";
|
const errorMessage =
|
||||||
const isTokenError = errorMessage.includes("expired") || errorMessage.includes("invalid");
|
error instanceof Error ? error.message : "server_error";
|
||||||
|
const isTokenError =
|
||||||
|
errorMessage.includes("expired") || errorMessage.includes("invalid");
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
`
|
`
|
||||||
@@ -192,7 +192,7 @@ export async function GET(event: APIEvent) {
|
|||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
status: 400,
|
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 } from "vinxi/http";
|
||||||
import { getEvent, clearSession } from "vinxi/http";
|
import { clearAuthToken } from "~/server/auth";
|
||||||
import { sessionConfig } from "~/server/session-config";
|
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
"use server";
|
"use server";
|
||||||
const event = getEvent()!;
|
const event = getEvent()!;
|
||||||
|
|
||||||
// Clear Vinxi session
|
clearAuthToken(event);
|
||||||
await clearSession(event, sessionConfig);
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|||||||
@@ -13,96 +13,99 @@ import { env } from "~/env/server";
|
|||||||
* URL: https://freno.me/api/downloads/[filename]
|
* URL: https://freno.me/api/downloads/[filename]
|
||||||
*/
|
*/
|
||||||
export async function GET(event: APIEvent) {
|
export async function GET(event: APIEvent) {
|
||||||
const filename = event.params.filename;
|
const filename = event.params.filename;
|
||||||
|
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
return new Response("Filename required", {
|
return new Response("Filename required", {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/plain"
|
"Content-Type": "text/plain"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filename format (only allow Gaze files)
|
// Validate filename format (only allow Gaze files)
|
||||||
if (!filename.startsWith("Gaze") || (!filename.endsWith(".dmg") && !filename.endsWith(".delta"))) {
|
if (
|
||||||
return new Response("Invalid file format", {
|
!filename.startsWith("Gaze") ||
|
||||||
status: 400,
|
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
||||||
headers: {
|
) {
|
||||||
"Content-Type": "text/plain"
|
return new Response("Invalid file format", {
|
||||||
}
|
status: 400,
|
||||||
});
|
headers: {
|
||||||
}
|
"Content-Type": "text/plain"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||||
const key = `downloads/${filename}`;
|
const key = `downloads/${filename}`;
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
region: env.AWS_REGION,
|
region: env.AWS_REGION,
|
||||||
credentials: credentials
|
credentials: credentials
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key
|
Key: key
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.send(command);
|
const response = await client.send(command);
|
||||||
|
|
||||||
if (!response.Body) {
|
if (!response.Body) {
|
||||||
console.error(`File not found in S3: ${key}`);
|
console.error(`File not found in S3: ${key}`);
|
||||||
return new Response("File not found", {
|
return new Response("File not found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/plain"
|
"Content-Type": "text/plain"
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Get content type based on file extension
|
|
||||||
const contentType = filename.endsWith(".dmg")
|
|
||||||
? "application/x-apple-diskimage"
|
|
||||||
: "application/octet-stream";
|
|
||||||
|
|
||||||
// Stream the file content from S3
|
|
||||||
const body = await response.Body.transformToByteArray();
|
|
||||||
|
|
||||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": contentType,
|
|
||||||
"Content-Length": body.length.toString(),
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
|
||||||
"Access-Control-Allow-Origin": "*"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch ${filename} from S3:`, error);
|
|
||||||
|
|
||||||
// Check if it's a not found error
|
|
||||||
if (error instanceof Error && error.name === "NoSuchKey") {
|
|
||||||
return new Response("File not found in storage", {
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/plain"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Internal Server Error", {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/plain"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get content type based on file extension
|
||||||
|
const contentType = filename.endsWith(".dmg")
|
||||||
|
? "application/x-apple-diskimage"
|
||||||
|
: "application/octet-stream";
|
||||||
|
|
||||||
|
// Stream the file content from S3
|
||||||
|
const body = await response.Body.transformToByteArray();
|
||||||
|
|
||||||
|
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Length": body.length.toString(),
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch ${filename} from S3:`, error);
|
||||||
|
|
||||||
|
// Check if it's a not found error
|
||||||
|
if (error instanceof Error && error.name === "NoSuchKey") {
|
||||||
|
return new Response("File not found in storage", {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Internal Server Error", {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
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 CommentIcon from "~/components/icons/CommentIcon";
|
||||||
import { Fire } from "~/components/icons/Fire";
|
import { Fire } from "~/components/icons/Fire";
|
||||||
import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper";
|
import CommentSectionWrapper from "~/components/blog/CommentSectionWrapper";
|
||||||
@@ -433,10 +433,10 @@ export default function PostPage() {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SessionDependantLike
|
<AuthenticatedLike
|
||||||
currentUserID={postData.userID}
|
currentUserID={postData.userID}
|
||||||
isAuthenticated={postData.isAuthenticated}
|
isAuthenticated={postData.isAuthenticated}
|
||||||
likes={postData.likes as any[]}
|
likes={postData.likes}
|
||||||
projectID={p().id}
|
projectID={p().id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ const routerSections: RouterSection[] = [
|
|||||||
router: "auth",
|
router: "auth",
|
||||||
procedure: "signOut",
|
procedure: "signOut",
|
||||||
method: "mutation",
|
method: "mutation",
|
||||||
description: "Clear session cookies and sign out"
|
description: "Clear auth cookie and sign out"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GitHub Callback",
|
name: "GitHub Callback",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export interface AnalyticsEntry {
|
|||||||
deviceType?: string | null;
|
deviceType?: string | null;
|
||||||
browser?: string | null;
|
browser?: string | null;
|
||||||
os?: string | null;
|
os?: string | null;
|
||||||
sessionId?: string | null;
|
|
||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
fcp?: number | null;
|
fcp?: number | null;
|
||||||
lcp?: number | null;
|
lcp?: number | null;
|
||||||
@@ -62,9 +61,9 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
|||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO VisitorAnalytics (
|
sql: `INSERT INTO VisitorAnalytics (
|
||||||
id, user_id, path, method, referrer, user_agent, ip_address,
|
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
|
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
args: [
|
args: [
|
||||||
uuid(),
|
uuid(),
|
||||||
entry.userId || null,
|
entry.userId || null,
|
||||||
@@ -77,7 +76,6 @@ async function flushAnalyticsBuffer(): Promise<void> {
|
|||||||
entry.deviceType || null,
|
entry.deviceType || null,
|
||||||
entry.browser || null,
|
entry.browser || null,
|
||||||
entry.os || null,
|
entry.os || null,
|
||||||
entry.sessionId || null,
|
|
||||||
entry.durationMs || null,
|
entry.durationMs || null,
|
||||||
entry.fcp || null,
|
entry.fcp || null,
|
||||||
entry.lcp || null,
|
entry.lcp || null,
|
||||||
@@ -202,7 +200,6 @@ export async function queryAnalytics(
|
|||||||
device_type: row.device_type as string | null,
|
device_type: row.device_type as string | null,
|
||||||
browser: row.browser as string | null,
|
browser: row.browser as string | null,
|
||||||
os: row.os 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,
|
duration_ms: row.duration_ms as number | null,
|
||||||
created_at: row.created_at as string
|
created_at: row.created_at as string
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { postHistoryRouter } from "./routers/post-history";
|
|||||||
import { infillRouter } from "./routers/infill";
|
import { infillRouter } from "./routers/infill";
|
||||||
import { accountRouter } from "./routers/account";
|
import { accountRouter } from "./routers/account";
|
||||||
import { downloadsRouter } from "./routers/downloads";
|
import { downloadsRouter } from "./routers/downloads";
|
||||||
import { remoteDbRouter } from "./routers/remote-db";
|
import { nessaDbRouter } from "./routers/nessa";
|
||||||
import { appleNotificationsRouter } from "./routers/apple-notifications";
|
import { appleNotificationsRouter } from "./routers/apple-notifications";
|
||||||
import { createTRPCRouter, createTRPCContext } from "./utils";
|
import { createTRPCRouter, createTRPCContext, t } from "./utils";
|
||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
@@ -30,18 +31,30 @@ export const appRouter = createTRPCRouter({
|
|||||||
infill: infillRouter,
|
infill: infillRouter,
|
||||||
account: accountRouter,
|
account: accountRouter,
|
||||||
downloads: downloadsRouter,
|
downloads: downloadsRouter,
|
||||||
remoteDb: remoteDbRouter,
|
nessaDb: nessaDbRouter,
|
||||||
appleNotifications: appleNotificationsRouter
|
appleNotifications: appleNotificationsRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
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
|
* Create a server-side caller for tRPC procedures from H3Event (vinxi/http getEvent)
|
||||||
* This allows calling tRPC procedures directly on the server with proper context
|
* Used in server functions within route files
|
||||||
*/
|
*/
|
||||||
export const createCaller = async (event: H3Event) => {
|
export const createCaller = async (event: H3Event) => {
|
||||||
const apiEvent = { nativeEvent: event, request: event.node.req } as any;
|
const apiEvent = { nativeEvent: event, request: event.node.req } as any;
|
||||||
const ctx = await createTRPCContext(apiEvent);
|
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 { createTRPCRouter, protectedProcedure } from "../utils";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import {
|
import { getProviderSummary, unlinkProvider } from "~/server/provider-helpers";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const accountRouter = createTRPCRouter({
|
export const accountRouter = createTRPCRouter({
|
||||||
/**
|
/**
|
||||||
@@ -67,17 +42,6 @@ export const accountRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await unlinkProvider(userId, provider);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `${provider} authentication unlinked successfully`
|
message: `${provider} authentication unlinked successfully`
|
||||||
@@ -97,159 +61,5 @@ export const accountRouter = createTRPCRouter({
|
|||||||
message: "Failed to unlink provider"
|
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,
|
getAnalyticsSummary,
|
||||||
getPathAnalytics,
|
getPathAnalytics,
|
||||||
cleanupOldAnalytics,
|
cleanupOldAnalytics,
|
||||||
logVisit,
|
|
||||||
getPerformanceStats,
|
getPerformanceStats,
|
||||||
enrichAnalyticsEntry
|
enrichAnalyticsEntry
|
||||||
} from "~/server/analytics";
|
} from "~/server/analytics";
|
||||||
import { ConnectionFactory } from "~/server/database";
|
import { ConnectionFactory } from "~/server/database";
|
||||||
import { v4 as uuid } from "uuid";
|
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({
|
export const analyticsRouter = createTRPCRouter({
|
||||||
logPerformance: publicProcedure
|
logPerformance: publicProcedure
|
||||||
@@ -71,18 +90,13 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
} else {
|
} else {
|
||||||
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
|
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
|
||||||
const userAgent =
|
const userAgent =
|
||||||
req.headers?.["user-agent"] ||
|
getHeader(req.headers, "user-agent") ||
|
||||||
ctx.event.request?.headers?.get("user-agent") ||
|
getHeader(ctx.event.request?.headers, "user-agent");
|
||||||
undefined;
|
|
||||||
const referrer =
|
const referrer =
|
||||||
req.headers?.referer ||
|
getHeader(req.headers, "referer") ||
|
||||||
req.headers?.referrer ||
|
getHeader(req.headers, "referrer") ||
|
||||||
ctx.event.request?.headers?.get("referer") ||
|
getHeader(ctx.event.request?.headers, "referer");
|
||||||
undefined;
|
|
||||||
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
||||||
const sessionId =
|
|
||||||
getCookie(ctx.event.nativeEvent, "session_id") || undefined;
|
|
||||||
|
|
||||||
const enriched = enrichAnalyticsEntry({
|
const enriched = enrichAnalyticsEntry({
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
path: input.path,
|
path: input.path,
|
||||||
@@ -90,7 +104,6 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
userAgent,
|
userAgent,
|
||||||
referrer,
|
referrer,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
sessionId,
|
|
||||||
fcp: input.metrics.fcp,
|
fcp: input.metrics.fcp,
|
||||||
lcp: input.metrics.lcp,
|
lcp: input.metrics.lcp,
|
||||||
cls: input.metrics.cls,
|
cls: input.metrics.cls,
|
||||||
@@ -104,9 +117,9 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO VisitorAnalytics (
|
sql: `INSERT INTO VisitorAnalytics (
|
||||||
id, user_id, path, method, referrer, user_agent, ip_address,
|
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
|
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
args: [
|
args: [
|
||||||
uuid(),
|
uuid(),
|
||||||
enriched.userId || null,
|
enriched.userId || null,
|
||||||
@@ -119,7 +132,6 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
enriched.deviceType || null,
|
enriched.deviceType || null,
|
||||||
enriched.browser || null,
|
enriched.browser || null,
|
||||||
enriched.os || null,
|
enriched.os || null,
|
||||||
enriched.sessionId || null,
|
|
||||||
enriched.durationMs || null,
|
enriched.durationMs || null,
|
||||||
enriched.fcp || null,
|
enriched.fcp || null,
|
||||||
enriched.lcp || null,
|
enriched.lcp || null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
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";
|
import { createTRPCContext } from "~/server/api/utils";
|
||||||
|
|
||||||
vi.mock("~/server/apple-notification", () => ({
|
vi.mock("~/server/apple-notification", () => ({
|
||||||
@@ -15,15 +15,12 @@ vi.mock("~/server/apple-notification-store", () => ({
|
|||||||
storeAppleNotificationUser: async () => undefined
|
storeAppleNotificationUser: async () => undefined
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/server/session-helpers", () => ({
|
|
||||||
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("apple notification router", () => {
|
describe("apple notification router", () => {
|
||||||
it("verifies and stores notifications", async () => {
|
it("verifies and stores notifications", async () => {
|
||||||
const caller = appRouter.createCaller(
|
const ctx = await createTRPCContext({
|
||||||
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any)
|
nativeEvent: { node: { req: {} } }
|
||||||
);
|
} as any);
|
||||||
|
const caller = createCallerFactory(ctx);
|
||||||
|
|
||||||
const result = await caller.appleNotifications.verifyAndStore.mutate({
|
const result = await caller.appleNotifications.verifyAndStore.mutate({
|
||||||
signedPayload: "test"
|
signedPayload: "test"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
checkPassword,
|
checkPassword,
|
||||||
checkPasswordSafe
|
checkPasswordSafe
|
||||||
} from "~/server/utils";
|
} from "~/server/utils";
|
||||||
import { setCookie, getCookie } from "vinxi/http";
|
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
import {
|
import {
|
||||||
linkProvider,
|
linkProvider,
|
||||||
@@ -49,19 +48,25 @@ import {
|
|||||||
markPasswordResetTokenUsed
|
markPasswordResetTokenUsed
|
||||||
} from "~/server/security";
|
} from "~/server/security";
|
||||||
import { logAuditEvent } from "~/server/audit";
|
import { logAuditEvent } from "~/server/audit";
|
||||||
|
import { getCookie, setCookie } from "vinxi/http";
|
||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import type { Context } from "../utils";
|
import type { Context } from "../utils";
|
||||||
import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config";
|
|
||||||
import {
|
import {
|
||||||
createAuthSession,
|
AUTH_CONFIG,
|
||||||
getAuthSession,
|
NETWORK_CONFIG,
|
||||||
invalidateAuthSession,
|
COOLDOWN_TIMERS,
|
||||||
rotateAuthSession,
|
expiryToSeconds,
|
||||||
revokeTokenFamily
|
getAccessTokenExpiry
|
||||||
} from "~/server/session-helpers";
|
} from "~/config";
|
||||||
import { checkAuthStatus } from "~/server/auth";
|
import {
|
||||||
|
issueAuthToken,
|
||||||
|
clearAuthToken,
|
||||||
|
checkAuthStatus,
|
||||||
|
verifyAuthToken,
|
||||||
|
getAuthTokenFromEvent
|
||||||
|
} from "~/server/auth";
|
||||||
import { v4 as uuidV4 } from "uuid";
|
import { v4 as uuidV4 } from "uuid";
|
||||||
import { jwtVerify, SignJWT } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import {
|
import {
|
||||||
generateLoginLinkEmail,
|
generateLoginLinkEmail,
|
||||||
generatePasswordResetEmail,
|
generatePasswordResetEmail,
|
||||||
@@ -83,9 +88,6 @@ function getH3Event(ctx: Context): H3Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Zod schemas
|
// Zod schemas
|
||||||
const refreshTokenSchema = z.object({
|
|
||||||
rememberMe: z.boolean().optional().default(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
async function sendEmail(to: string, subject: string, htmlContent: string) {
|
async function sendEmail(to: string, subject: string, htmlContent: string) {
|
||||||
const apiKey = env.SENDINBLUE_KEY;
|
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({
|
export const authRouter = createTRPCRouter({
|
||||||
githubCallback: publicProcedure
|
githubCallback: publicProcedure
|
||||||
.input(z.object({ code: z.string() }))
|
.input(z.object({ code: z.string() }))
|
||||||
@@ -306,16 +269,15 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const event = getH3Event(ctx);
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const clientIP = getClientIP(event);
|
||||||
await createAuthSession(
|
const userAgent = getUserAgent(event);
|
||||||
getH3Event(ctx),
|
await issueAuthToken({
|
||||||
|
event,
|
||||||
userId,
|
userId,
|
||||||
true, // OAuth defaults to remember
|
rememberMe: true
|
||||||
clientIP,
|
});
|
||||||
userAgent
|
setCSRFToken(event);
|
||||||
);
|
|
||||||
setCSRFToken(getH3Event(ctx));
|
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
@@ -518,18 +480,17 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session with Vinxi (OAuth defaults to remember me)
|
// Issue JWT (OAuth defaults to remember me)
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const event = getH3Event(ctx);
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const clientIP = getClientIP(event);
|
||||||
await createAuthSession(
|
const userAgent = getUserAgent(event);
|
||||||
getH3Event(ctx),
|
await issueAuthToken({
|
||||||
|
event,
|
||||||
userId,
|
userId,
|
||||||
true, // OAuth defaults to remember
|
rememberMe: true
|
||||||
clientIP,
|
});
|
||||||
userAgent
|
|
||||||
);
|
|
||||||
|
|
||||||
setCSRFToken(getH3Event(ctx));
|
setCSRFToken(event);
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
@@ -642,17 +603,16 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const userId = (res.rows[0] as unknown as User).id;
|
const userId = (res.rows[0] as unknown as User).id;
|
||||||
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const event = getH3Event(ctx);
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const clientIP = getClientIP(event);
|
||||||
|
const userAgent = getUserAgent(event);
|
||||||
|
|
||||||
await createAuthSession(
|
await issueAuthToken({
|
||||||
getH3Event(ctx),
|
event,
|
||||||
userId,
|
userId,
|
||||||
rememberMe,
|
rememberMe
|
||||||
clientIP,
|
});
|
||||||
userAgent
|
setCSRFToken(event);
|
||||||
);
|
|
||||||
setCSRFToken(getH3Event(ctx));
|
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
@@ -727,14 +687,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
// Check if there's a valid JWT token with this code
|
// Check if there's a valid JWT token with this code
|
||||||
// We need to find the token that was generated for this email
|
// 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
|
// 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)
|
// Get the token from cookie (we'll store it when sending email)
|
||||||
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
|
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
@@ -777,16 +729,15 @@ export const authRouter = createTRPCRouter({
|
|||||||
const shouldRemember =
|
const shouldRemember =
|
||||||
rememberMe ?? (payload.rememberMe as boolean) ?? false;
|
rememberMe ?? (payload.rememberMe as boolean) ?? false;
|
||||||
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const event = getH3Event(ctx);
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const clientIP = getClientIP(event);
|
||||||
await createAuthSession(
|
const userAgent = getUserAgent(event);
|
||||||
getH3Event(ctx),
|
await issueAuthToken({
|
||||||
|
event,
|
||||||
userId,
|
userId,
|
||||||
shouldRemember,
|
rememberMe: shouldRemember
|
||||||
clientIP,
|
});
|
||||||
userAgent
|
setCSRFToken(event);
|
||||||
);
|
|
||||||
setCSRFToken(getH3Event(ctx));
|
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
@@ -970,20 +921,19 @@ export const authRouter = createTRPCRouter({
|
|||||||
email: email
|
email: email
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create session with client info
|
// Issue auth token with client info
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const event = getH3Event(ctx);
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const clientIP = getClientIP(event);
|
||||||
|
const userAgent = getUserAgent(event);
|
||||||
|
|
||||||
await createAuthSession(
|
await issueAuthToken({
|
||||||
getH3Event(ctx),
|
event,
|
||||||
userId,
|
userId,
|
||||||
rememberMe ?? true, // Default to persistent sessions for registration
|
rememberMe: rememberMe ?? true
|
||||||
clientIP,
|
});
|
||||||
userAgent
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set CSRF token
|
// Set CSRF token
|
||||||
setCSRFToken(getH3Event(ctx));
|
setCSRFToken(event);
|
||||||
|
|
||||||
// Log successful registration
|
// Log successful registration
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
@@ -1138,18 +1088,17 @@ export const authRouter = createTRPCRouter({
|
|||||||
// Reset rate limits on successful login
|
// Reset rate limits on successful login
|
||||||
await resetLoginRateLimits(email, clientIP);
|
await resetLoginRateLimits(email, clientIP);
|
||||||
|
|
||||||
// Create session with Vinxi
|
// Issue JWT for authenticated user
|
||||||
const userAgent = getUserAgent(getH3Event(ctx));
|
const event = getH3Event(ctx);
|
||||||
await createAuthSession(
|
const userAgent = getUserAgent(event);
|
||||||
getH3Event(ctx),
|
await issueAuthToken({
|
||||||
user.id,
|
event,
|
||||||
rememberMe ?? false, // Default to session cookie (expires on browser close)
|
userId: user.id,
|
||||||
clientIP,
|
rememberMe: rememberMe ?? false
|
||||||
userAgent
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Set CSRF token for authenticated session
|
// Set CSRF token for authenticated user
|
||||||
setCSRFToken(getH3Event(ctx));
|
setCSRFToken(event);
|
||||||
|
|
||||||
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
||||||
try {
|
try {
|
||||||
@@ -1232,7 +1181,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
const token = await new SignJWT({
|
const token = await new SignJWT({
|
||||||
email,
|
email,
|
||||||
rememberMe: rememberMe ?? false, // Default to session cookie (expires on browser close)
|
rememberMe: rememberMe ?? false, // Default to browser cookie
|
||||||
code: loginCode
|
code: loginCode
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.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
|
// Store the token in a cookie so it can be verified with the code later
|
||||||
setCookie(getH3Event(ctx), "emailLoginToken", token, {
|
setCookie(getH3Event(ctx), "emailLoginToken", token, {
|
||||||
maxAge: COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_COOKIE_MAX_AGE,
|
maxAge: expiryToSeconds(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY),
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
@@ -1624,185 +1573,81 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
refreshToken: publicProcedure
|
refreshToken: publicProcedure.mutation(async ({ ctx }) => {
|
||||||
.input(refreshTokenSchema)
|
try {
|
||||||
.mutation(async ({ ctx }) => {
|
const event = getH3Event(ctx);
|
||||||
try {
|
const authToken = getAuthTokenFromEvent(event);
|
||||||
const event = getH3Event(ctx);
|
|
||||||
|
|
||||||
// Step 1: Get current session from Vinxi
|
|
||||||
const session = await getAuthSession(event);
|
|
||||||
if (!session) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "No valid session found"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get client info for rotation
|
|
||||||
const clientIP = getClientIP(event);
|
|
||||||
const userAgent = getUserAgent(event);
|
|
||||||
|
|
||||||
// Step 3: Rotate session (includes validation, breach detection, cookie update)
|
|
||||||
const newSession = await rotateAuthSession(
|
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Token refresh error:", error);
|
|
||||||
|
|
||||||
if (error instanceof TRPCError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "UNAUTHORIZED",
|
||||||
message: "Token refresh failed"
|
message: "No valid token found"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
|
const payload = await verifyAuthToken(authToken);
|
||||||
|
|
||||||
|
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,
|
||||||
|
userId: payload.sub,
|
||||||
|
rememberMe: expiresIn > shortExpiry
|
||||||
|
});
|
||||||
|
|
||||||
|
setCSRFToken(event);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Token refreshed successfully"
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh error:", error);
|
||||||
|
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Token refresh failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
||||||
try {
|
try {
|
||||||
// Step 1: Get current session
|
const event = getH3Event(ctx);
|
||||||
const session = await getAuthSession(getH3Event(ctx));
|
const auth = await checkAuthStatus(event);
|
||||||
|
|
||||||
if (session) {
|
if (auth.userId) {
|
||||||
await revokeTokenFamily(session.tokenFamily, "user_logout");
|
const { ipAddress, userAgent } = getAuditContext(event);
|
||||||
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId: session.userId,
|
userId: auth.userId,
|
||||||
eventType: "auth.logout",
|
eventType: "auth.logout",
|
||||||
eventData: { sessionId: session.sessionId },
|
eventData: {},
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAuthToken(event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error during signout:", 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 };
|
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 { describe, it, expect, vi } from "vitest";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { createCallerFactory } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/utils";
|
import { createTRPCContext } from "~/server/api/utils";
|
||||||
|
|
||||||
// Mock the S3 client and getSignedUrl function
|
// Mock the S3 client and getSignedUrl function
|
||||||
@@ -27,15 +27,14 @@ vi.mock("@aws-sdk/s3-request-presigner", () => ({
|
|||||||
|
|
||||||
// Mock environment variables
|
// Mock environment variables
|
||||||
process.env.AWS_REGION = "us-east-1";
|
process.env.AWS_REGION = "us-east-1";
|
||||||
process.env._AWS_ACCESS_KEY = "test-access-key";
|
process.env.MY_AWS_ACCESS_KEY = "test-access-key";
|
||||||
process.env._AWS_SECRET_KEY = "test-secret-key";
|
process.env.MY_AWS_SECRET_KEY = "test-secret-key";
|
||||||
process.env.VITE_DOWNLOAD_BUCKET_STRING = "test-bucket";
|
process.env.VITE_DOWNLOAD_BUCKET_STRING = "test-bucket";
|
||||||
|
|
||||||
describe("downloads router", () => {
|
describe("downloads router", () => {
|
||||||
it("should return a signed URL for valid asset names", async () => {
|
it("should return a signed URL for valid asset names", async () => {
|
||||||
const caller = appRouter.createCaller(
|
const ctx = await createTRPCContext({ nativeEvent: {} } as any);
|
||||||
await createTRPCContext({ nativeEvent: {} } as any)
|
const caller = createCallerFactory(ctx);
|
||||||
);
|
|
||||||
|
|
||||||
const result = await caller.downloads.getDownloadUrl.query({
|
const result = await caller.downloads.getDownloadUrl.query({
|
||||||
asset_name: "lineage"
|
asset_name: "lineage"
|
||||||
@@ -46,9 +45,8 @@ describe("downloads router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NOT_FOUND for invalid asset names", async () => {
|
it("should throw NOT_FOUND for invalid asset names", async () => {
|
||||||
const caller = appRouter.createCaller(
|
const ctx = await createTRPCContext({ nativeEvent: {} } as any);
|
||||||
await createTRPCContext({ nativeEvent: {} } as any)
|
const caller = createCallerFactory(ctx);
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await caller.downloads.getDownloadUrl.query({
|
await caller.downloads.getDownloadUrl.query({
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createTRPCRouter, publicProcedure } from "../utils";
|
import { createTRPCRouter, publicProcedure } from "../utils";
|
||||||
import { z } from "zod";
|
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 { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { TRPCError } from "@trpc/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
|
* 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 {
|
try {
|
||||||
const listCommand = new ListObjectsV2Command({
|
const listCommand = new ListObjectsV2Command({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -29,13 +36,13 @@ async function getLatestGazeDMG(client: S3Client, bucket: string): Promise<strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter for .dmg files only and sort by LastModified (newest first)
|
// Filter for .dmg files only and sort by LastModified (newest first)
|
||||||
const dmgFiles = response.Contents
|
const dmgFiles = response.Contents.filter((obj) =>
|
||||||
.filter((obj) => obj.Key?.endsWith(".dmg"))
|
obj.Key?.endsWith(".dmg")
|
||||||
.sort((a, b) => {
|
).sort((a, b) => {
|
||||||
const dateA = a.LastModified?.getTime() || 0;
|
const dateA = a.LastModified?.getTime() || 0;
|
||||||
const dateB = b.LastModified?.getTime() || 0;
|
const dateB = b.LastModified?.getTime() || 0;
|
||||||
return dateB - dateA; // Descending order (newest first)
|
return dateB - dateA; // Descending order (newest first)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dmgFiles.length === 0) {
|
if (dmgFiles.length === 0) {
|
||||||
throw new Error("No .dmg files found in downloads/Gaze-* prefix");
|
throw new Error("No .dmg files found in downloads/Gaze-* prefix");
|
||||||
@@ -57,8 +64,8 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
@@ -98,7 +105,10 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
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 = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -80,8 +80,8 @@ export const miscRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -135,8 +135,8 @@ export const miscRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
@@ -195,8 +195,8 @@ export const miscRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
const s3params = {
|
const s3params = {
|
||||||
@@ -234,8 +234,8 @@ export const miscRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const credentials = {
|
const credentials = {
|
||||||
accessKeyId: env._AWS_ACCESS_KEY,
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
secretAccessKey: env._AWS_SECRET_KEY
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
const s3params = {
|
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 { createTRPCRouter, publicProcedure } from "../utils";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
||||||
import { setCookie } from "vinxi/http";
|
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
import { toUserProfile } from "~/types/user";
|
import { toUserProfile } from "~/types/user";
|
||||||
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
|
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
|
||||||
import { z } from "zod";
|
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 { generatePasswordSetEmail } from "~/server/email-templates";
|
||||||
import { formatDeviceDescription } from "~/server/device-utils";
|
import { formatDeviceDescription } from "~/server/device-utils";
|
||||||
import sendEmail from "~/server/email";
|
import sendEmail from "~/server/email";
|
||||||
@@ -405,119 +401,5 @@ export const userRouter = createTRPCRouter({
|
|||||||
await unlinkProvider(userId, input.provider);
|
await unlinkProvider(userId, input.provider);
|
||||||
|
|
||||||
return { success: true, message: "Provider unlinked" };
|
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 { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { getCookie } from "vinxi/http";
|
|
||||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||||
import { getRequestIP } from "vinxi/http";
|
import { getRequestIP } from "vinxi/http";
|
||||||
import { getAuthSession } from "~/server/session-helpers";
|
import { verifyNessaToken } from "~/server/nessa-auth";
|
||||||
import { verifyCairnToken } from "~/server/cairn-auth";
|
import { getAuthPayloadFromEvent } from "~/server/auth";
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
event: APIEvent;
|
event: APIEvent;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
isAdmin: boolean;
|
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> {
|
async function createContextInner(event: APIEvent): Promise<Context> {
|
||||||
// Get auth session from Vinxi encrypted session
|
const payload = await getAuthPayloadFromEvent(event.nativeEvent);
|
||||||
const session = await getAuthSession(event.nativeEvent);
|
|
||||||
|
|
||||||
let userId: string | null = null;
|
let userId: string | null = null;
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
|
||||||
if (session && session.userId) {
|
if (payload) {
|
||||||
userId = session.userId;
|
userId = payload.sub;
|
||||||
isAdmin = session.isAdmin;
|
isAdmin = payload.isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
||||||
const path = req.url || event.request?.url || "unknown";
|
const path = req.url || event.request?.url || "unknown";
|
||||||
const method = req.method || event.request?.method || "GET";
|
const method = req.method || event.request?.method || "GET";
|
||||||
const userAgent =
|
const userAgent =
|
||||||
req.headers?.["user-agent"] ||
|
getHeader(req.headers, "user-agent") ||
|
||||||
event.request?.headers?.get("user-agent") ||
|
getHeader(event.request?.headers, "user-agent");
|
||||||
undefined;
|
|
||||||
const referrer =
|
const referrer =
|
||||||
req.headers?.referer ||
|
getHeader(req.headers, "referer") ||
|
||||||
req.headers?.referrer ||
|
getHeader(req.headers, "referrer") ||
|
||||||
event.request?.headers?.get("referer") ||
|
getHeader(event.request?.headers, "referer");
|
||||||
undefined;
|
|
||||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||||
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
|
|
||||||
const authHeader =
|
const authHeader =
|
||||||
event.request?.headers?.get("authorization") ||
|
getHeader(req.headers, "authorization") ||
|
||||||
req.headers?.authorization ||
|
getHeader(event.request?.headers, "authorization") ||
|
||||||
req.headers?.Authorization ||
|
|
||||||
null;
|
null;
|
||||||
|
|
||||||
let cairnUserId: string | null = null;
|
let nessaUserId: string | null = null;
|
||||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||||
const token = authHeader.replace("Bearer ", "").trim();
|
const token = authHeader.replace("Bearer ", "").trim();
|
||||||
try {
|
try {
|
||||||
const payload = await verifyCairnToken(token);
|
const payload = await verifyNessaToken(token);
|
||||||
cairnUserId = payload.sub;
|
nessaUserId = payload.sub;
|
||||||
} catch (error) {
|
} 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,
|
method,
|
||||||
userAgent,
|
userAgent,
|
||||||
referrer,
|
referrer,
|
||||||
ipAddress,
|
ipAddress
|
||||||
sessionId
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +88,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
event,
|
event,
|
||||||
userId,
|
userId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
cairnUserId
|
nessaUserId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,21 +128,21 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const enforceCairnUser = t.middleware(({ ctx, next }) => {
|
const enforceNessaUser = t.middleware(({ ctx, next }) => {
|
||||||
if (!ctx.cairnUserId) {
|
if (!ctx.nessaUserId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Cairn authentication required"
|
message: "Nessa authentication required"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
...ctx,
|
||||||
cairnUserId: ctx.cairnUserId
|
nessaUserId: ctx.nessaUserId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||||
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
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.github.failed"
|
||||||
| "auth.oauth.google.success"
|
| "auth.oauth.google.success"
|
||||||
| "auth.oauth.google.failed"
|
| "auth.oauth.google.failed"
|
||||||
| "auth.session.revoke"
|
|
||||||
| "auth.session.revokeAll"
|
|
||||||
| "security.rate_limit.exceeded"
|
| "security.rate_limit.exceeded"
|
||||||
| "security.csrf.failed"
|
| "security.csrf.failed"
|
||||||
| "security.suspicious.activity"
|
| "security.suspicious.activity"
|
||||||
| "admin.action"
|
| "admin.action";
|
||||||
| "auth.session_created"
|
|
||||||
| "system.session_cleanup";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit log entry structure
|
* Audit log entry structure
|
||||||
@@ -246,7 +242,6 @@ export async function getUserSecuritySummary(
|
|||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
lastLoginIp: string | null;
|
lastLoginIp: string | null;
|
||||||
uniqueIpCount: number;
|
uniqueIpCount: number;
|
||||||
recentSessions: number;
|
|
||||||
}> {
|
}> {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
@@ -336,16 +331,6 @@ export async function getUserSecuritySummary(
|
|||||||
});
|
});
|
||||||
const uniqueIpCount = (ipResult.rows[0]?.count as number) || 0;
|
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 {
|
return {
|
||||||
totalEvents,
|
totalEvents,
|
||||||
successfulEvents,
|
successfulEvents,
|
||||||
@@ -356,8 +341,7 @@ export async function getUserSecuritySummary(
|
|||||||
failedLogins,
|
failedLogins,
|
||||||
lastLoginAt: lastLogin?.created_at as string | null,
|
lastLoginAt: lastLogin?.created_at as string | null,
|
||||||
lastLoginIp: lastLogin?.ip_address as string | null,
|
lastLoginIp: lastLogin?.ip_address as string | null,
|
||||||
uniqueIpCount,
|
uniqueIpCount
|
||||||
recentSessions
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,136 @@
|
|||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
|
import { getCookie, setCookie } from "vinxi/http";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import type { Row } from "@libsql/client/web";
|
import type { Row } from "@libsql/client/web";
|
||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import { env } from "~/env/server";
|
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
|
* Check authentication status
|
||||||
@@ -15,9 +143,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const session = await getAuthSession(event);
|
const payload = await getAuthPayloadFromEvent(event);
|
||||||
|
if (!payload) {
|
||||||
if (!session || !session.userId) {
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -27,8 +154,8 @@ export async function checkAuthStatus(event: H3Event): Promise<{
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
userId: session.userId,
|
userId: payload.sub,
|
||||||
isAdmin: session.isAdmin
|
isAdmin: payload.isAdmin
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auth check error:", 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
|
* @param event - H3Event
|
||||||
* @returns User ID or null if not authenticated
|
* @returns User ID or null if not authenticated
|
||||||
*/
|
*/
|
||||||
@@ -67,10 +194,8 @@ export async function validateLineageRequest({
|
|||||||
const { provider, email } = userRow;
|
const { provider, email } = userRow;
|
||||||
if (provider === "email") {
|
if (provider === "email") {
|
||||||
try {
|
try {
|
||||||
const { jwtVerify } = await import("jose");
|
const payload = await verifyAuthToken(auth_token);
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
if (!payload || email !== payload.email) {
|
||||||
const { payload } = await jwtVerify(auth_token, secret);
|
|
||||||
if (email !== payload.email) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 mainDBConnection: ReturnType<typeof createClient> | null = null;
|
||||||
let lineageDBConnection: 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() {
|
export function ConnectionFactory() {
|
||||||
if (!mainDBConnection) {
|
if (!mainDBConnection) {
|
||||||
@@ -38,15 +38,15 @@ export function LineageConnectionFactory() {
|
|||||||
return lineageDBConnection;
|
return lineageDBConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CairnConnectionFactory() {
|
export function NessaConnectionFactory() {
|
||||||
if (!cairnDBConnection) {
|
if (!nessaDBConnection) {
|
||||||
const config = {
|
const config = {
|
||||||
url: env.CAIRN_DB_URL,
|
url: env.NESSA_DB_URL,
|
||||||
authToken: env.CAIRN_DB_TOKEN
|
authToken: env.NESSA_DB_TOKEN
|
||||||
};
|
};
|
||||||
cairnDBConnection = createClient(config);
|
nessaDBConnection = createClient(config);
|
||||||
}
|
}
|
||||||
return cairnDBConnection;
|
return nessaDBConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function LineageDBInit() {
|
export async function LineageDBInit() {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a short device fingerprint for comparison
|
* 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
|
* @param deviceInfo - Device information
|
||||||
* @returns Short fingerprint string
|
* @returns Short fingerprint string
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -94,7 +94,6 @@
|
|||||||
color: #856404;
|
color: #856404;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<li>Revoke all active sessions</li>
|
|
||||||
<li>Change your password</li>
|
<li>Change your password</li>
|
||||||
<li>Review linked authentication providers</li>
|
<li>Review linked authentication providers</li>
|
||||||
</ul>
|
</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);
|
expect(isValid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prevent token reuse from different session", () => {
|
it("should prevent token reuse from different login", () => {
|
||||||
const token1 = generateCSRFToken();
|
const token1 = generateCSRFToken();
|
||||||
const token2 = 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