This commit is contained in:
Michael Freno
2026-01-21 11:29:07 -05:00
parent 6b86d175e8
commit 1d8ec7a375
3 changed files with 552 additions and 47 deletions

View File

@@ -1639,54 +1639,36 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Step 2: Get client info for rotation const authToken = getCookie(event, authCookieName);
const clientIP = getClientIP(event);
const userAgent = getUserAgent(event);
// Step 3: Rotate session (includes validation, breach detection, cookie update) if (!authToken) {
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({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Token refresh failed - please login again" message: "No valid token found"
}); });
} }
// Step 4: Force response headers to be sent immediately const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
// This is critical for Safari to receive the new session cookies const { payload } = await jwtVerify(authToken, secret);
// Safari is very strict about cookie updates from fetch responses
try { if (!payload.sub) {
const headers = event.node?.res?.getHeaders?.() || {}; throw new TRPCError({
console.log( code: "UNAUTHORIZED",
"[Token Refresh] Response headers set:", message: "Invalid token"
Object.keys(headers) });
);
} catch (e) {
// Headers already sent or not available - that's OK
} }
// Step 5: Refresh CSRF token await issueAuthToken({
setCSRFToken(event); event,
userId: payload.sub as string,
rememberMe: ctx.input.rememberMe ?? false
});
// Step 6: Opportunistic cleanup (serverless-friendly) setCSRFToken(event);
import("~/server/token-cleanup")
.then((module) => module.opportunisticCleanup())
.catch((err) => console.error("Opportunistic cleanup failed:", err));
return { return {
success: true, success: true,
message: "Token refreshed successfully", 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) { } catch (error) {
console.error("Token refresh error:", error); console.error("Token refresh error:", error);
@@ -1704,17 +1686,14 @@ export const authRouter = createTRPCRouter({
signOut: publicProcedure.mutation(async ({ ctx }) => { signOut: publicProcedure.mutation(async ({ ctx }) => {
try { try {
// Step 1: Get current session const auth = await checkAuthStatus(getH3Event(ctx));
const session = await getAuthSession(getH3Event(ctx));
if (session) {
await revokeTokenFamily(session.tokenFamily, "user_logout");
if (auth.userId) {
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); 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
@@ -1722,11 +1701,9 @@ export const authRouter = createTRPCRouter({
} }
} 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) clearAuthToken(getH3Event(ctx));
await invalidateAuthSession(getH3Event(ctx), "");
return { success: true }; return { success: true };
}), }),

View File

@@ -1,8 +1,10 @@
import { createTRPCRouter, cairnProcedure } from "../utils"; import { createTRPCRouter, cairnProcedure, publicProcedure } from "../utils";
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { CairnConnectionFactory } from "~/server/database"; import { CairnConnectionFactory } from "~/server/database";
import { cache } from "~/server/cache"; import { cache } from "~/server/cache";
import { hashPassword, checkPasswordSafe } from "~/server/utils";
import { signCairnToken } from "~/server/cairn-auth";
const CAIRN_CACHE_TTL_MS = 5 * 60 * 1000; const CAIRN_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -171,6 +173,22 @@ const bulkSchema = z.object({
authProviders: z.array(providerSchema).optional() authProviders: z.array(providerSchema).optional()
}); });
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
firstName: z.string().min(1),
lastName: z.string().min(1)
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
});
const testTokenSchema = z.object({
userId: z.string().min(1)
});
export const cairnDbRouter = createTRPCRouter({ export const cairnDbRouter = createTRPCRouter({
health: cairnProcedure.query(async () => { health: cairnProcedure.query(async () => {
try { try {
@@ -186,6 +204,151 @@ export const cairnDbRouter = createTRPCRouter({
} }
}), }),
register: publicProcedure
.input(registerSchema)
.mutation(async ({ input }) => {
try {
const conn = CairnConnectionFactory();
const existing = await conn.execute({
sql: "SELECT id FROM users WHERE email = ?",
args: [input.email]
});
if (existing.rows.length) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already registered"
});
}
const userId = crypto.randomUUID();
const passwordHash = await hashPassword(input.password);
await conn.execute({
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, provider, status, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
args: [
userId,
input.email,
0,
input.firstName,
input.lastName,
`${input.firstName} ${input.lastName}`.trim(),
"email",
"active"
]
});
await conn.execute({
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
args: [crypto.randomUUID(), userId, "email", null, input.email, null, null]
});
await conn.execute({
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
args: [crypto.randomUUID(), userId, "password", passwordHash, input.email, null, null]
});
await conn.execute({
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
args: [crypto.randomUUID(), userId, "Getting Started", "strength", "beginner", "strength", 0]
});
const token = await signCairnToken(userId);
return { success: true, token, userId };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
console.error("Failed to register Cairn user:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to register user"
});
}
}),
login: publicProcedure
.input(loginSchema)
.mutation(async ({ input }) => {
try {
const conn = CairnConnectionFactory();
const result = await conn.execute({
sql: "SELECT userId, email, provider, providerUserId FROM authProviders WHERE email = ? AND provider IN ('email', 'password')",
args: [input.email]
});
if (!result.rows.length) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials"
});
}
const rows = result.rows as Array<{
userId: string;
email: string | null;
provider: string;
providerUserId: string | null;
}>;
const emailProvider = rows.find((row) => row.provider === "email");
const passwordProvider = rows.find((row) => row.provider === "password");
if (emailProvider?.userId !== passwordProvider?.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials"
});
}
if (!emailProvider || !passwordProvider) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials"
});
}
const matches = await checkPasswordSafe(
input.password,
passwordProvider.providerUserId
);
if (!matches) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials"
});
}
const token = await signCairnToken(emailProvider.userId);
await conn.execute({
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
args: [emailProvider.userId]
});
return { success: true, token, userId: emailProvider.userId };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
console.error("Failed to login Cairn user:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to login"
});
}
}),
createTestToken: publicProcedure
.input(testTokenSchema)
.mutation(async ({ input }) => {
try {
const token = await signCairnToken(input.userId);
return { success: true, token, userId: input.userId };
} catch (error) {
console.error("Failed to create Cairn test token:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create test token"
});
}
}),
getUsers: cairnProcedure getUsers: cairnProcedure
.input(paginatedQuerySchema) .input(paginatedQuerySchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
@@ -274,6 +437,171 @@ export const cairnDbRouter = createTRPCRouter({
} }
}), }),
getPlanDetails: cairnProcedure
.input(paginatedQuerySchema)
.query(async ({ input, ctx }) => {
const limit = input.limit ?? 50;
const offset = input.offset ?? 0;
const cacheKey = `cairn-plan-details:${ctx.cairnUserId}:${limit}:${offset}:${input.since ?? ""}`;
const cached = await cache.get<{
plans: Array<{
id: string;
userId: string;
name: string;
description: string | null;
category: string;
difficulty: string | null;
durationMinutes: number | null;
type: string;
isPublic: number | null;
createdAt: string;
updatedAt: string;
}>;
planExercises: Array<{
id: string;
planId: string;
exerciseId: string | null;
name: string;
category: string;
orderIndex: number;
notes: string | null;
createdAt: string;
}>;
planSets: Array<{
id: string;
planExerciseId: string;
setNumber: number;
reps: number | null;
weight: number | null;
durationSeconds: number | null;
rpe: number | null;
restAfterSeconds: number | null;
isWarmup: number | null;
isDropset: number | null;
notes: string | null;
createdAt: string;
}>;
routePoints: Array<{
id: string;
planId: string;
latitude: number;
longitude: number;
orderIndex: number;
isWaypoint: number | null;
createdAt: string;
}>;
}>(cacheKey);
if (cached) {
return cached;
}
try {
const conn = CairnConnectionFactory();
const planSql = input.since
? "SELECT id, userId, name, description, category, difficulty, durationMinutes, type, isPublic, createdAt, updatedAt FROM workoutPlans WHERE userId = ? AND updatedAt > ? ORDER BY createdAt DESC LIMIT ? OFFSET ?"
: "SELECT id, userId, name, description, category, difficulty, durationMinutes, type, isPublic, createdAt, updatedAt FROM workoutPlans WHERE userId = ? ORDER BY createdAt DESC LIMIT ? OFFSET ?";
const planArgs = input.since
? [ctx.cairnUserId, input.since, limit, offset]
: [ctx.cairnUserId, limit, offset];
const planResult = await conn.execute({
sql: planSql,
args: planArgs
});
const planExercisesSql = input.since
? "SELECT id, planId, exerciseId, name, category, orderIndex, notes, createdAt FROM planExercises WHERE planId IN (SELECT id FROM workoutPlans WHERE userId = ? AND updatedAt > ?) ORDER BY orderIndex ASC"
: "SELECT id, planId, exerciseId, name, category, orderIndex, notes, createdAt FROM planExercises WHERE planId IN (SELECT id FROM workoutPlans WHERE userId = ?) ORDER BY orderIndex ASC";
const planExercisesArgs = input.since
? [ctx.cairnUserId, input.since]
: [ctx.cairnUserId];
const planExercisesResult = await conn.execute({
sql: planExercisesSql,
args: planExercisesArgs
});
const planSetsSql = input.since
? "SELECT id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes, createdAt FROM planSets WHERE planExerciseId IN (SELECT id FROM planExercises WHERE planId IN (SELECT id FROM workoutPlans WHERE userId = ? AND updatedAt > ?)) ORDER BY setNumber ASC"
: "SELECT id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes, createdAt FROM planSets WHERE planExerciseId IN (SELECT id FROM planExercises WHERE planId IN (SELECT id FROM workoutPlans WHERE userId = ?)) ORDER BY setNumber ASC";
const planSetsArgs = input.since
? [ctx.cairnUserId, input.since]
: [ctx.cairnUserId];
const planSetsResult = await conn.execute({
sql: planSetsSql,
args: planSetsArgs
});
const routePointsSql = input.since
? "SELECT id, planId, latitude, longitude, orderIndex, isWaypoint, createdAt FROM routePoints WHERE planId IN (SELECT id FROM workoutPlans WHERE userId = ? AND updatedAt > ?) ORDER BY orderIndex ASC"
: "SELECT id, planId, latitude, longitude, orderIndex, isWaypoint, createdAt FROM routePoints WHERE planId IN (SELECT id FROM workoutPlans WHERE userId = ?) ORDER BY orderIndex ASC";
const routePointsArgs = input.since
? [ctx.cairnUserId, input.since]
: [ctx.cairnUserId];
const routePointsResult = await conn.execute({
sql: routePointsSql,
args: routePointsArgs
});
const payload = {
plans: planResult.rows as Array<{
id: string;
userId: string;
name: string;
description: string | null;
category: string;
difficulty: string | null;
durationMinutes: number | null;
type: string;
isPublic: number | null;
createdAt: string;
updatedAt: string;
}>,
planExercises: planExercisesResult.rows as Array<{
id: string;
planId: string;
exerciseId: string | null;
name: string;
category: string;
orderIndex: number;
notes: string | null;
createdAt: string;
}>,
planSets: planSetsResult.rows as Array<{
id: string;
planExerciseId: string;
setNumber: number;
reps: number | null;
weight: number | null;
durationSeconds: number | null;
rpe: number | null;
restAfterSeconds: number | null;
isWarmup: number | null;
isDropset: number | null;
notes: string | null;
createdAt: string;
}>,
routePoints: routePointsResult.rows as Array<{
id: string;
planId: string;
latitude: number;
longitude: number;
orderIndex: number;
isWaypoint: number | null;
createdAt: string;
}>
};
await cache.set(cacheKey, payload, CAIRN_CACHE_TTL_MS);
return payload;
} catch (error) {
console.error("Failed to fetch Cairn plan details:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Cairn plan details"
});
}
}),
getWorkouts: cairnProcedure getWorkouts: cairnProcedure
.input(paginatedQuerySchema) .input(paginatedQuerySchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
@@ -320,6 +648,189 @@ export const cairnDbRouter = createTRPCRouter({
} }
}), }),
getWorkoutDetails: cairnProcedure
.input(paginatedQuerySchema)
.query(async ({ input, ctx }) => {
const limit = input.limit ?? 50;
const offset = input.offset ?? 0;
const cacheKey = `cairn-workout-details:${ctx.cairnUserId}:${limit}:${offset}:${input.since ?? ""}`;
const cached = await cache.get<{
workouts: Array<{
id: string;
userId: string;
planId: string | null;
type: string;
name: string | null;
startDate: string;
endDate: string | null;
durationSeconds: number | null;
distanceMeters: number | null;
calories: number | null;
averageHeartRate: number | null;
maxHeartRate: number | null;
averagePace: number | null;
elevationGain: number | null;
status: string;
source: string;
healthKitUUID: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
syncedAt: string | null;
}>;
heartRateSamples: Array<{
id: string;
workoutId: string;
timestamp: string;
bpm: number;
source: string | null;
}>;
locationSamples: Array<{
id: string;
workoutId: string;
timestamp: string;
latitude: number;
longitude: number;
altitude: number | null;
horizontalAccuracy: number | null;
verticalAccuracy: number | null;
speed: number | null;
course: number | null;
}>;
workoutSplits: Array<{
id: string;
workoutId: string;
splitNumber: number;
distanceMeters: number;
durationSeconds: number;
startTimestamp: string;
endTimestamp: string;
averageHeartRate: number | null;
averagePace: number | null;
elevationGain: number | null;
elevationLoss: number | null;
}>;
}>(cacheKey);
if (cached) {
return cached;
}
try {
const conn = CairnConnectionFactory();
const workoutSql = input.since
? "SELECT id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes, createdAt, updatedAt, syncedAt FROM workouts WHERE userId = ? AND updatedAt > ? ORDER BY startDate DESC LIMIT ? OFFSET ?"
: "SELECT id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes, createdAt, updatedAt, syncedAt FROM workouts WHERE userId = ? ORDER BY startDate DESC LIMIT ? OFFSET ?";
const workoutArgs = input.since
? [ctx.cairnUserId, input.since, limit, offset]
: [ctx.cairnUserId, limit, offset];
const workoutResult = await conn.execute({
sql: workoutSql,
args: workoutArgs
});
const heartRateSql = input.since
? "SELECT id, workoutId, timestamp, bpm, source FROM heartRateSamples WHERE workoutId IN (SELECT id FROM workouts WHERE userId = ? AND updatedAt > ?) ORDER BY timestamp ASC"
: "SELECT id, workoutId, timestamp, bpm, source FROM heartRateSamples WHERE workoutId IN (SELECT id FROM workouts WHERE userId = ?) ORDER BY timestamp ASC";
const heartRateArgs = input.since
? [ctx.cairnUserId, input.since]
: [ctx.cairnUserId];
const heartRateResult = await conn.execute({
sql: heartRateSql,
args: heartRateArgs
});
const locationSql = input.since
? "SELECT id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course FROM locationSamples WHERE workoutId IN (SELECT id FROM workouts WHERE userId = ? AND updatedAt > ?) ORDER BY timestamp ASC"
: "SELECT id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course FROM locationSamples WHERE workoutId IN (SELECT id FROM workouts WHERE userId = ?) ORDER BY timestamp ASC";
const locationArgs = input.since
? [ctx.cairnUserId, input.since]
: [ctx.cairnUserId];
const locationResult = await conn.execute({
sql: locationSql,
args: locationArgs
});
const splitSql = input.since
? "SELECT id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss FROM workoutSplits WHERE workoutId IN (SELECT id FROM workouts WHERE userId = ? AND updatedAt > ?) ORDER BY splitNumber ASC"
: "SELECT id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss FROM workoutSplits WHERE workoutId IN (SELECT id FROM workouts WHERE userId = ?) ORDER BY splitNumber ASC";
const splitArgs = input.since
? [ctx.cairnUserId, input.since]
: [ctx.cairnUserId];
const splitResult = await conn.execute({
sql: splitSql,
args: splitArgs
});
const payload = {
workouts: workoutResult.rows as Array<{
id: string;
userId: string;
planId: string | null;
type: string;
name: string | null;
startDate: string;
endDate: string | null;
durationSeconds: number | null;
distanceMeters: number | null;
calories: number | null;
averageHeartRate: number | null;
maxHeartRate: number | null;
averagePace: number | null;
elevationGain: number | null;
status: string;
source: string;
healthKitUUID: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
syncedAt: string | null;
}>,
heartRateSamples: heartRateResult.rows as Array<{
id: string;
workoutId: string;
timestamp: string;
bpm: number;
source: string | null;
}>,
locationSamples: locationResult.rows as Array<{
id: string;
workoutId: string;
timestamp: string;
latitude: number;
longitude: number;
altitude: number | null;
horizontalAccuracy: number | null;
verticalAccuracy: number | null;
speed: number | null;
course: number | null;
}>,
workoutSplits: splitResult.rows as Array<{
id: string;
workoutId: string;
splitNumber: number;
distanceMeters: number;
durationSeconds: number;
startTimestamp: string;
endTimestamp: string;
averageHeartRate: number | null;
averagePace: number | null;
elevationGain: number | null;
elevationLoss: number | null;
}>
};
await cache.set(cacheKey, payload, CAIRN_CACHE_TTL_MS);
return payload;
} catch (error) {
console.error("Failed to fetch Cairn workout details:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Cairn workout details"
});
}
}),
createUser: cairnProcedure createUser: cairnProcedure
.input(userInputSchema) .input(userInputSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -1,6 +1,9 @@
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server"; import { env } from "~/env/server";
const CAIRN_JWT_EXPIRY = "30d";
export type CairnAuthPayload = { export type CairnAuthPayload = {
sub: string; sub: string;
exp?: number; exp?: number;
@@ -15,9 +18,23 @@ export async function verifyCairnToken(
algorithms: ["HS256"] algorithms: ["HS256"]
}); });
if (!payload.sub) {
throw new Error("Missing subject in Cairn JWT");
}
return { return {
sub: payload.sub as string, sub: payload.sub as string,
exp: payload.exp as number | undefined, exp: payload.exp as number | undefined,
iat: payload.iat as number | undefined iat: payload.iat as number | undefined
}; };
} }
export async function signCairnToken(userId: string): Promise<string> {
const secret = new TextEncoder().encode(env.CAIRN_JWT_SECRET);
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime(CAIRN_JWT_EXPIRY)
.sign(secret);
}