Add authentication system with session-based auth

Implements full auth flows with opaque tokens (not JWT) for easy revocation:
- Login/logout with cookie or bearer token support
- Registration with email verification
- Password reset with one-time tokens
- scrypt password hashing (no external deps)

New files in express/auth/:
- token.ts: 256-bit token generation, SHA-256 hashing
- password.ts: scrypt hashing with timing-safe verification
- types.ts: Session schemas, token types, input validation
- store.ts: AuthStore interface + InMemoryAuthStore
- service.ts: AuthService with all auth operations
- routes.ts: 6 auth endpoints

Modified:
- types.ts: Added user field to Call, requireAuth/requirePermission helpers
- app.ts: JSON body parsing, populates call.user, handles auth errors
- services.ts: Added services.auth
- routes.ts: Includes auth routes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 13:59:02 -06:00
parent 788ea2ab19
commit c246e0384f
11 changed files with 898 additions and 10 deletions

231
express/auth/routes.ts Normal file
View File

@@ -0,0 +1,231 @@
// routes.ts
//
// Authentication route handlers.
import { z } from "zod";
import { contentTypes } from "../content-types";
import { httpCodes } from "../http-codes";
import { services } from "../services";
import type { Call, Result, Route } from "../types";
import {
forgotPasswordInputParser,
loginInputParser,
registerInputParser,
resetPasswordInputParser,
} from "./types";
// Helper for JSON responses
const jsonResponse = (
code: (typeof httpCodes.success)[keyof typeof httpCodes.success],
data: object,
): Result => ({
code,
contentType: contentTypes.application.json,
result: JSON.stringify(data),
});
const errorResponse = (
code: (typeof httpCodes.clientErrors)[keyof typeof httpCodes.clientErrors],
error: string,
): Result => ({
code,
contentType: contentTypes.application.json,
result: JSON.stringify({ error }),
});
// POST /auth/login
const loginHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { email, password } = loginInputParser.parse(body);
const result = await services.auth.login(email, password, "cookie", {
userAgent: call.request.get("User-Agent"),
ipAddress: call.request.ip,
});
if (!result.success) {
return errorResponse(
httpCodes.clientErrors.Unauthorized,
result.error,
);
}
return jsonResponse(httpCodes.success.OK, {
token: result.token,
user: {
id: result.user.id,
email: result.user.email,
displayName: result.user.displayName,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// POST /auth/logout
const logoutHandler = async (call: Call): Promise<Result> => {
const token = services.auth.extractToken(call.request);
if (token) {
await services.auth.logout(token);
}
return jsonResponse(httpCodes.success.OK, { message: "Logged out" });
};
// POST /auth/register
const registerHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { email, password, displayName } =
registerInputParser.parse(body);
const result = await services.auth.register(
email,
password,
displayName,
);
if (!result.success) {
return errorResponse(httpCodes.clientErrors.Conflict, result.error);
}
// TODO: Send verification email with result.verificationToken
// For now, log it for development
console.log(
`[AUTH] Verification token for ${email}: ${result.verificationToken}`,
);
return jsonResponse(httpCodes.success.Created, {
message:
"Registration successful. Please check your email to verify your account.",
user: {
id: result.user.id,
email: result.user.email,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// POST /auth/forgot-password
const forgotPasswordHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { email } = forgotPasswordInputParser.parse(body);
const result = await services.auth.createPasswordResetToken(email);
// Always return success (don't reveal if email exists)
if (result) {
// TODO: Send password reset email
console.log(
`[AUTH] Password reset token for ${email}: ${result.token}`,
);
}
return jsonResponse(httpCodes.success.OK, {
message:
"If an account exists with that email, a password reset link has been sent.",
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// POST /auth/reset-password
const resetPasswordHandler = async (call: Call): Promise<Result> => {
try {
const body = call.request.body;
const { token, password } = resetPasswordInputParser.parse(body);
const result = await services.auth.resetPassword(token, password);
if (!result.success) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
result.error,
);
}
return jsonResponse(httpCodes.success.OK, {
message:
"Password has been reset. You can now log in with your new password.",
});
} catch (error) {
if (error instanceof z.ZodError) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Invalid input",
);
}
throw error;
}
};
// GET /auth/verify-email?token=xxx
const verifyEmailHandler = async (call: Call): Promise<Result> => {
const url = new URL(call.path, "http://localhost");
const token = url.searchParams.get("token");
if (!token) {
return errorResponse(
httpCodes.clientErrors.BadRequest,
"Missing token",
);
}
const result = await services.auth.verifyEmail(token);
if (!result.success) {
return errorResponse(httpCodes.clientErrors.BadRequest, result.error);
}
return jsonResponse(httpCodes.success.OK, {
message: "Email verified successfully. You can now log in.",
});
};
// Export routes
const authRoutes: Route[] = [
{ path: "/auth/login", methods: ["POST"], handler: loginHandler },
{ path: "/auth/logout", methods: ["POST"], handler: logoutHandler },
{ path: "/auth/register", methods: ["POST"], handler: registerHandler },
{
path: "/auth/forgot-password",
methods: ["POST"],
handler: forgotPasswordHandler,
},
{
path: "/auth/reset-password",
methods: ["POST"],
handler: resetPasswordHandler,
},
{
path: "/auth/verify-email",
methods: ["GET"],
handler: verifyEmailHandler,
},
];
export { authRoutes };