forked from Mike/freno-dev
145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
/**
|
|
* Shared Auth Query
|
|
* Single source of truth for authentication state across the app
|
|
*
|
|
* Security Model:
|
|
* - Server query reads from httpOnly cookies (secure)
|
|
* - Client context syncs from this query (UI convenience)
|
|
* - Server endpoints always validate independently (never trust client)
|
|
*/
|
|
|
|
import { query, revalidate as revalidateKey } from "@solidjs/router";
|
|
import { getRequestEvent } from "solid-js/web";
|
|
|
|
export interface UserState {
|
|
isAuthenticated: boolean;
|
|
userId: string | null;
|
|
email: string | null;
|
|
displayName: string | null;
|
|
emailVerified: boolean;
|
|
privilegeLevel: "admin" | "user" | "anonymous";
|
|
}
|
|
|
|
/**
|
|
* Global auth state query - single source of truth
|
|
* Called on server during SSR, cached by SolidStart router
|
|
*/
|
|
export const getUserState = query(async (): Promise<UserState> => {
|
|
"use server";
|
|
const { getPrivilegeLevel, getUserID } = await import("~/server/auth");
|
|
const { ConnectionFactory } = await import("~/server/utils");
|
|
const { getCookie, setCookie } = await import("vinxi/http");
|
|
const event = getRequestEvent()!;
|
|
|
|
let privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
|
let userId = await getUserID(event.nativeEvent);
|
|
|
|
// If no userId but refresh token exists, attempt server-side token refresh
|
|
// Use a flag cookie to prevent infinite loops (only try once per request)
|
|
if (!userId) {
|
|
const refreshToken = getCookie(event.nativeEvent, "refreshToken");
|
|
const refreshAttempted = getCookie(event.nativeEvent, "_refresh_attempted");
|
|
|
|
if (refreshToken && !refreshAttempted) {
|
|
console.log(
|
|
"[Auth-Query] Access token expired but refresh token exists, attempting server-side refresh"
|
|
);
|
|
|
|
// Set flag to prevent retry loops (expires immediately, just for this request)
|
|
setCookie(event.nativeEvent, "_refresh_attempted", "1", {
|
|
maxAge: 1,
|
|
path: "/",
|
|
httpOnly: true
|
|
});
|
|
|
|
try {
|
|
// Import token rotation function
|
|
const { attemptTokenRefresh } =
|
|
await import("~/server/api/routers/auth");
|
|
|
|
// Attempt to refresh tokens server-side
|
|
const refreshed = await attemptTokenRefresh(
|
|
event.nativeEvent,
|
|
refreshToken
|
|
);
|
|
|
|
if (refreshed) {
|
|
console.log("[Auth-Query] Server-side token refresh successful");
|
|
// Re-check auth state with new tokens
|
|
privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
|
userId = await getUserID(event.nativeEvent);
|
|
} else {
|
|
console.log("[Auth-Query] Server-side token refresh failed");
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[Auth-Query] Error during server-side token refresh:",
|
|
error
|
|
);
|
|
}
|
|
} else if (refreshAttempted) {
|
|
console.log(
|
|
"[Auth-Query] Refresh already attempted this request, skipping"
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!userId) {
|
|
return {
|
|
isAuthenticated: false,
|
|
userId: null,
|
|
email: null,
|
|
displayName: null,
|
|
emailVerified: false,
|
|
privilegeLevel: "anonymous"
|
|
};
|
|
}
|
|
|
|
const conn = ConnectionFactory();
|
|
const res = await conn.execute({
|
|
sql: "SELECT email, display_name, email_verified FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
if (res.rows.length === 0) {
|
|
return {
|
|
isAuthenticated: false,
|
|
userId: null,
|
|
email: null,
|
|
displayName: null,
|
|
emailVerified: false,
|
|
privilegeLevel: "anonymous"
|
|
};
|
|
}
|
|
|
|
const user = res.rows[0] as any;
|
|
|
|
return {
|
|
isAuthenticated: true,
|
|
userId,
|
|
email: user.email ?? null,
|
|
displayName: user.display_name ?? null,
|
|
emailVerified: user.email_verified === 1,
|
|
privilegeLevel
|
|
};
|
|
}, "global-auth-state");
|
|
|
|
/**
|
|
* Revalidate auth state globally
|
|
* Call this after login, logout, token refresh, email verification
|
|
*/
|
|
export function revalidateAuth() {
|
|
revalidateKey(getUserState.key);
|
|
|
|
// Dispatch browser event to trigger UI updates (client-side only)
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new CustomEvent("auth-state-changed"));
|
|
|
|
// Reset token refresh timer when auth state changes
|
|
// This ensures the timer is synchronized with fresh tokens
|
|
import("~/lib/token-refresh").then(({ tokenRefreshManager }) => {
|
|
tokenRefreshManager.reset();
|
|
});
|
|
}
|
|
}
|