All files / src/routes sessions.ts

97.22% Statements 35/36
93.75% Branches 15/16
100% Functions 4/4
97.22% Lines 35/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95                    7x 4x   4x 1x     3x 3x   1x 1x         2x 2x 1x     1x 1x   1x       7x 7x   7x 1x     6x         6x 1x       5x 2x       2x       3x 1x       2x         2x   2x 2x   2x       7x 1x   1x         1x      
import type { FastifyInstance } from "fastify";
import { eq, and, isNull } from "drizzle-orm";
import { db } from "../db/index.js";
import { users, sessions } from "../db/schema.js";
import { verifyOtp, OtpError } from "../lib/otp.js";
import { createSession } from "../lib/sessions.js";
import { getUserAccounts } from "../lib/accounts.js";
 
export async function sessionRoutes(app: FastifyInstance) {
  // POST /sessions — validate OTP, create session for existing user
  app.post<{ Body: { email: string; code: string } }>("/", async (request, reply) => {
    const { email, code } = request.body;
 
    if (!email || !code) {
      return reply.status(400).send({ error: "Email and code are required" });
    }
 
    try {
      await verifyOtp(email, code);
    } catch (err) {
      Eif (err instanceof OtpError) {
        return reply.status(400).send({ error: err.message });
      }
      throw err;
    }
 
    const [user] = await db.select().from(users).where(eq(users.email, email));
    if (!user) {
      return reply.status(401).send({ error: "No account found. Please sign up." });
    }
 
    const tokens = await createSession(user.id);
    const userAccounts = await getUserAccounts(user.id);
 
    return { ...tokens, user, accounts: userAccounts };
  });
 
  // POST /sessions/refresh — rotate refresh token, return new tokens
  app.post<{ Body: { refreshToken: string } }>("/refresh", async (request, reply) => {
    const { refreshToken } = request.body;
 
    if (!refreshToken) {
      return reply.status(400).send({ error: "Refresh token is required" });
    }
 
    const [session] = await db
      .select()
      .from(sessions)
      .where(eq(sessions.refreshToken, refreshToken));
 
    if (!session) {
      return reply.status(401).send({ error: "Invalid refresh token" });
    }
 
    // If session was already revoked, this is a reuse attempt — revoke all sessions for this user (theft detection)
    if (session.revokedAt) {
      await db
        .update(sessions)
        .set({ revokedAt: new Date() })
        .where(and(eq(sessions.userId, session.userId), isNull(sessions.revokedAt)));
      return reply.status(401).send({ error: "Token reuse detected. All sessions revoked." });
    }
 
    // Check expiry
    if (session.expiresAt < new Date()) {
      return reply.status(401).send({ error: "Refresh token expired" });
    }
 
    // Revoke old session
    await db
      .update(sessions)
      .set({ revokedAt: new Date() })
      .where(eq(sessions.id, session.id));
 
    const tokens = await createSession(session.userId);
 
    const [user] = await db.select().from(users).where(eq(users.id, session.userId));
    const userAccounts = await getUserAccounts(session.userId);
 
    return { ...tokens, user, accounts: userAccounts };
  });
 
  // POST /sessions/logout — revoke all sessions (authenticated)
  app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => {
    const userId = request.user.sub;
 
    await db
      .update(sessions)
      .set({ revokedAt: new Date() })
      .where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));
 
    return reply.status(204).send();
  });
}