diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-03 05:45:41 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-04 23:26:30 +0900 |
| commit | 0763153865e2157e0d06c946993dd8b235b06c83 (patch) | |
| tree | 8da68ed2e9c16bf121d59eae02e19b99f7f11fdc /pkgs/server/src/routes/auth.ts | |
| parent | f44390286378860b535e37ad045cb374a07aff5c (diff) | |
| download | kioku-0763153865e2157e0d06c946993dd8b235b06c83.tar.gz kioku-0763153865e2157e0d06c946993dd8b235b06c83.tar.zst kioku-0763153865e2157e0d06c946993dd8b235b06c83.zip | |
feat(auth): add refresh token endpoint
Implement refresh token functionality for authentication:
- Add refresh_tokens table to database schema with user reference
- Generate migration for the new table
- Login endpoint now returns both access token and refresh token
- Add POST /api/auth/refresh endpoint with token rotation
- Refresh tokens are hashed (SHA256) before storage for security
- Tokens expire after 7 days, access tokens after 15 minutes
- Update tests to cover new functionality
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs/server/src/routes/auth.ts')
| -rw-r--r-- | pkgs/server/src/routes/auth.ts | 116 |
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, |
