Files
freno-dev/src/server/auth.ts

283 lines
7.8 KiB
TypeScript

import type { H3Event } from "vinxi/http";
import { getCookie, setCookie, getHeader } from "vinxi/http";
import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web";
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server";
import { ConnectionFactory } from "./db-connections";
import {
AUTH_CONFIG,
LINEAGE_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 = getHeader(event, "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) {
// Signature mismatch, expired token, malformed JWT — these are expected
// when a user has a stale/invalid cookie and are NOT server errors. Only
// log unexpected failures to keep prod console noise-free.
const code = (error as { code?: string }).code;
if (
code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" &&
code !== "ERR_JWT_EXPIRED" &&
code !== "ERR_JWT_MALFORMED"
) {
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);
}
/**
* Verify a Lineage game (mobile-app) email JWT.
*
* p8-005: Lineage tokens are signed with the dedicated `LINEAGE_JWT_SECRET`
* (NOT the web `JWT_SECRET_KEY`) and carry distinct `iss: "lineage"` /
* `aud: "lineage-app"` claims. Enforcing the issuer + audience here guarantees
* that a token minted by the web app (which uses a different secret and no
* lineage claims) can never authenticate against a Lineage-protected endpoint,
* even if the two secrets were accidentally shared.
*/
export async function verifyLineageAuthToken(
token: string
): Promise<LineageAuthTokenPayload | null> {
try {
const secret = new TextEncoder().encode(env.LINEAGE_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
issuer: LINEAGE_CONFIG.JWT_ISSUER,
audience: LINEAGE_CONFIG.JWT_AUDIENCE
});
if (!payload.userId) {
return null;
}
return {
userId: payload.userId as string,
email: (payload.email as string | null) ?? null
};
} catch (error) {
console.error("Lineage auth token verification failed:", error);
return null;
}
}
type LineageAuthTokenPayload = {
userId: string;
email: string | null;
};
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
* @param event - H3Event
* @returns Object with isAuthenticated, userId, and isAdmin flags
*/
export async function checkAuthStatus(event: H3Event): Promise<{
isAuthenticated: boolean;
userId: string | null;
isAdmin: boolean;
}> {
try {
const payload = await getAuthPayloadFromEvent(event);
if (!payload) {
return {
isAuthenticated: false,
userId: null,
isAdmin: false
};
}
return {
isAuthenticated: true,
userId: payload.sub,
isAdmin: payload.isAdmin
};
} catch (error) {
console.error("Auth check error:", error);
return {
isAuthenticated: false,
userId: null,
isAdmin: false
};
}
}
/**
* Get user ID from auth token
* @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
}: {
auth_token: string;
userRow: Row;
}): Promise<boolean> {
const { provider, email } = userRow;
if (provider === "email") {
try {
// p8-005: Lineage email JWTs are signed with the dedicated
// LINEAGE_JWT_SECRET and enforce lineage issuer/audience claims.
const payload = await verifyLineageAuthToken(auth_token);
if (!payload || email !== payload.email) {
return false;
}
} catch (err) {
console.error("Failed to verify email auth token:", err);
return false;
}
} else if (provider == "apple") {
const { apple_user_string } = userRow;
if (apple_user_string !== auth_token) {
return false;
}
} else if (provider == "google") {
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
if (!CLIENT_ID) {
console.error("Missing VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE");
return false;
}
const client = new OAuth2Client(CLIENT_ID);
const ticket = await client.verifyIdToken({
idToken: auth_token,
audience: CLIENT_ID
});
if (ticket.getPayload()?.email !== email) {
return false;
}
} else {
return false;
}
return true;
}