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

View File

@@ -1,191 +1,63 @@
import { getCookie, setCookie, type H3Event } from "vinxi/http";
import { jwtVerify } from "jose";
import type { H3Event } from "vinxi/http";
import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web";
import { env } from "~/env/server";
import { ConnectionFactory } from "./database";
import { getAuthSession } from "./session-helpers";
/**
* Extract cookie value from H3Event (works in both production and tests)
* Falls back to manual header parsing if vinxi's getCookie fails
* Check authentication status
* Consolidates getUserID, getPrivilegeLevel, and checkAuthStatus into single function
* @param event - H3Event
* @returns Object with isAuthenticated, userId, and isAdmin flags
*/
function getCookieValue(event: H3Event, name: string): string | undefined {
try {
// Try vinxi's getCookie first
return getCookie(event, name);
} catch (e) {
// Fallback for tests: parse cookie header manually
try {
const cookieHeader =
event.headers?.get("cookie") || event.node?.req?.headers?.cookie || "";
const cookies = cookieHeader
.split(";")
.map((c) => c.trim())
.reduce(
(acc, cookie) => {
const [key, value] = cookie.split("=");
if (key && value) acc[key] = value;
return acc;
},
{} as Record<string, string>
);
return cookies[name];
} catch {
return undefined;
}
}
}
/**
* Clear cookie (works in both production and tests)
*/
function clearCookie(event: H3Event, name: string): void {
try {
setCookie(event, name, "", {
maxAge: 0,
expires: new Date("2016-10-05")
});
} catch (e) {
// In tests, setCookie might fail silently
}
}
/**
* Validate session and update last_used timestamp
* @param sessionId - Session ID from JWT
* @param userId - User ID from JWT
* @returns true if session is valid, false otherwise
*/
async function validateSession(
sessionId: string,
userId: string
): Promise<boolean> {
try {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `SELECT revoked, expires_at FROM Session
WHERE id = ? AND user_id = ?`,
args: [sessionId, userId]
});
if (result.rows.length === 0) {
// Session doesn't exist
return false;
}
const session = result.rows[0];
// Check if session is revoked
if (session.revoked === 1) {
return false;
}
// Check if session is expired
const expiresAt = new Date(session.expires_at as string);
if (expiresAt < new Date()) {
return false;
}
// Update last_used timestamp (fire and forget, don't block)
conn
.execute({
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
args: [sessionId]
})
.catch((err) =>
console.error("Failed to update session last_used:", err)
);
return true;
} catch (e) {
console.error("Session validation error:", e);
return false;
}
}
export async function getPrivilegeLevel(
event: H3Event
): Promise<"anonymous" | "admin" | "user"> {
try {
const userIDToken = getCookieValue(event, "userIDToken");
if (userIDToken) {
try {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(userIDToken, secret);
if (payload.id && typeof payload.id === "string") {
// Validate session if session ID is present
if (payload.sid) {
const isValidSession = await validateSession(
payload.sid as string,
payload.id
);
if (!isValidSession) {
clearCookie(event, "userIDToken");
return "anonymous";
}
}
return payload.id === env.ADMIN_ID ? "admin" : "user";
}
} catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users)
clearCookie(event, "userIDToken");
}
}
} catch (e) {
return "anonymous";
}
return "anonymous";
}
export async function getUserID(event: H3Event): Promise<string | null> {
try {
const userIDToken = getCookieValue(event, "userIDToken");
if (userIDToken) {
try {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(userIDToken, secret);
if (payload.id && typeof payload.id === "string") {
// Validate session if session ID is present
if (payload.sid) {
const isValidSession = await validateSession(
payload.sid as string,
payload.id
);
if (!isValidSession) {
clearCookie(event, "userIDToken");
return null;
}
}
return payload.id;
}
} catch (err) {
// Silently clear invalid token (401s are expected for non-authenticated users)
clearCookie(event, "userIDToken");
}
}
} catch (e) {
return null;
}
return null;
}
export async function checkAuthStatus(event: H3Event): Promise<{
isAuthenticated: boolean;
userId: string | null;
isAdmin: boolean;
}> {
const userId = await getUserID(event);
return {
isAuthenticated: !!userId,
userId
};
try {
const session = await getAuthSession(event);
if (!session || !session.userId) {
return {
isAuthenticated: false,
userId: null,
isAdmin: false
};
}
return {
isAuthenticated: true,
userId: session.userId,
isAdmin: session.isAdmin
};
} catch (error) {
console.error("Auth check error:", error);
return {
isAuthenticated: false,
userId: null,
isAdmin: false
};
}
}
/**
* Get user ID from session
* @param event - H3Event
* @returns User ID or null if not authenticated
*/
export async function getUserID(event: H3Event): Promise<string | null> {
const auth = await checkAuthStatus(event);
return auth.userId;
}
/**
* Validate Lineage mobile app authentication request
* Supports email (JWT), Apple (user string), and Google (OAuth token) providers
* @param auth_token - Authentication token from the app
* @param userRow - User database row
* @returns true if valid, false otherwise
*/
export async function validateLineageRequest({
auth_token,
userRow
@@ -196,6 +68,7 @@ export async function validateLineageRequest({
const { provider, email } = userRow;
if (provider === "email") {
try {
const { jwtVerify } = await import("jose");
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(auth_token, secret);
if (email !== payload.email) {
@@ -210,7 +83,7 @@ export async function validateLineageRequest({
return false;
}
} else if (provider == "google") {
const CLIENT_ID = process.env().VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
if (!CLIENT_ID) {
console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE");
return false;