aboutsummaryrefslogtreecommitdiffhomepage
path: root/pkgs/server/src/routes/auth.ts
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/server/src/routes/auth.ts')
-rw-r--r--pkgs/server/src/routes/auth.ts116
1 files changed, 113 insertions, 3 deletions
diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts
index ed497b1..a2e6c8e 100644
--- a/pkgs/server/src/routes/auth.ts
+++ b/pkgs/server/src/routes/auth.ts
@@ -1,9 +1,14 @@
-import { createUserSchema, loginSchema } from "@kioku/shared";
+import { createHash, randomBytes } from "node:crypto";
+import {
+ createUserSchema,
+ loginSchema,
+ refreshTokenSchema,
+} from "@kioku/shared";
import * as argon2 from "argon2";
-import { eq } from "drizzle-orm";
+import { and, eq, gt } from "drizzle-orm";
import { Hono } from "hono";
import { sign } from "hono/jwt";
-import { db, users } from "../db";
+import { db, refreshTokens, users } from "../db";
import { Errors } from "../middleware";
const JWT_SECRET = process.env.JWT_SECRET;
@@ -11,6 +16,15 @@ if (!JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes
+const REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 7; // 7 days
+
+function generateRefreshToken(): string {
+ return randomBytes(32).toString("hex");
+}
+
+function hashToken(token: string): string {
+ return createHash("sha256").update(token).digest("hex");
+}
const auth = new Hono();
@@ -102,8 +116,104 @@ auth.post("/login", async (c) => {
JWT_SECRET,
);
+ // Generate refresh token
+ const refreshToken = generateRefreshToken();
+ const tokenHash = hashToken(refreshToken);
+ const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000);
+
+ // Store refresh token in database
+ await db.insert(refreshTokens).values({
+ userId: user.id,
+ tokenHash,
+ expiresAt,
+ });
+
+ return c.json({
+ accessToken,
+ refreshToken,
+ user: {
+ id: user.id,
+ username: user.username,
+ },
+ });
+});
+
+auth.post("/refresh", async (c) => {
+ const body = await c.req.json();
+
+ const parsed = refreshTokenSchema.safeParse(body);
+ if (!parsed.success) {
+ throw Errors.validationError(parsed.error.issues[0]?.message);
+ }
+
+ const { refreshToken } = parsed.data;
+ const tokenHash = hashToken(refreshToken);
+
+ // Find valid refresh token
+ const [storedToken] = await db
+ .select({
+ id: refreshTokens.id,
+ userId: refreshTokens.userId,
+ expiresAt: refreshTokens.expiresAt,
+ })
+ .from(refreshTokens)
+ .where(
+ and(
+ eq(refreshTokens.tokenHash, tokenHash),
+ gt(refreshTokens.expiresAt, new Date()),
+ ),
+ )
+ .limit(1);
+
+ if (!storedToken) {
+ throw Errors.unauthorized(
+ "Invalid or expired refresh token",
+ "INVALID_REFRESH_TOKEN",
+ );
+ }
+
+ // Get user info
+ const [user] = await db
+ .select({
+ id: users.id,
+ username: users.username,
+ })
+ .from(users)
+ .where(eq(users.id, storedToken.userId))
+ .limit(1);
+
+ if (!user) {
+ throw Errors.unauthorized("User not found", "USER_NOT_FOUND");
+ }
+
+ // Delete old refresh token (rotation)
+ await db.delete(refreshTokens).where(eq(refreshTokens.id, storedToken.id));
+
+ // Generate new access token
+ const now = Math.floor(Date.now() / 1000);
+ const accessToken = await sign(
+ {
+ sub: user.id,
+ iat: now,
+ exp: now + ACCESS_TOKEN_EXPIRES_IN,
+ },
+ JWT_SECRET,
+ );
+
+ // Generate new refresh token (rotation)
+ const newRefreshToken = generateRefreshToken();
+ const newTokenHash = hashToken(newRefreshToken);
+ const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000);
+
+ await db.insert(refreshTokens).values({
+ userId: user.id,
+ tokenHash: newTokenHash,
+ expiresAt,
+ });
+
return c.json({
accessToken,
+ refreshToken: newRefreshToken,
user: {
id: user.id,
username: user.username,