diff options
Diffstat (limited to 'src/server/routes/auth.ts')
| -rw-r--r-- | src/server/routes/auth.ts | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts new file mode 100644 index 0000000..25c959b --- /dev/null +++ b/src/server/routes/auth.ts @@ -0,0 +1,199 @@ +import { createHash, randomBytes } from "node:crypto"; +import * as argon2 from "argon2"; +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { Errors } from "../middleware/index.js"; +import { + type RefreshTokenRepository, + refreshTokenRepository, + type UserRepository, + userRepository, +} from "../repositories/index.js"; +import { + createUserSchema, + loginSchema, + refreshTokenSchema, +} from "../schemas/index.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +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"); +} + +export interface AuthDependencies { + userRepo: UserRepository; + refreshTokenRepo: RefreshTokenRepository; +} + +export function createAuthRouter(deps: AuthDependencies) { + const { userRepo, refreshTokenRepo } = deps; + const auth = new Hono(); + + auth.post("/register", async (c) => { + const body = await c.req.json(); + + const parsed = createUserSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Check if username already exists + const exists = await userRepo.existsByUsername(username); + if (exists) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } + + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); + + // Create user + const newUser = await userRepo.create({ username, passwordHash }); + + return c.json({ user: newUser }, 201); + }); + + auth.post("/login", async (c) => { + const body = await c.req.json(); + + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Find user by username + const user = await userRepo.findByUsername(username); + + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Verify password + const isPasswordValid = await argon2.verify(user.passwordHash, password); + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Generate JWT 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 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 refreshTokenRepo.create({ + 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 refreshTokenRepo.findValidToken(tokenHash); + + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } + + // Get user info + const user = await userRepo.findById(storedToken.userId); + + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } + + // Delete old refresh token (rotation) + await refreshTokenRepo.deleteById(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 refreshTokenRepo.create({ + userId: user.id, + tokenHash: newTokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + username: user.username, + }, + }); + }); + + return auth; +} + +// Default auth router with real repositories for production use +export const auth = createAuthRouter({ + userRepo: userRepository, + refreshTokenRepo: refreshTokenRepository, +}); |
