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:
231
express/auth/routes.ts
Normal file
231
express/auth/routes.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user