aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/routes/auth.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/routes/auth.ts')
-rw-r--r--src/server/routes/auth.ts199
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,
+});