Files
Kordant/web/src/routes/api/auth/[action].ts
2026-06-03 14:05:49 -04:00

192 lines
5.4 KiB
TypeScript

import type { APIEvent } from "@solidjs/start/server";
import {
authenticateUser,
authenticateWithApple,
createUserWithPassword,
forgotPassword,
resetPassword,
refreshAccessToken,
revokeUserSessions,
} from "~/server/services/user.service";
import { verifyJWT } from "~/server/auth/jwt";
/**
* REST-style auth endpoints for mobile clients (Android/iOS).
*
* These wrap the tRPC service functions into a simple JSON API
* that OkHttp-based Android clients can call without tRPC client.
*
* POST /api/auth/login - Email/password login
* POST /api/auth/signup - Create account with password
* POST /api/auth/google - Google Sign-In token exchange
* POST /api/auth/refresh - Refresh access token
* POST /api/auth/logout - Revoke all sessions
* POST /api/auth/forgot-password - Request password reset
* POST /api/auth/reset-password - Reset password with token
*/
export async function POST(event: APIEvent) {
const action = event.params.action;
const body = await event.request.json().catch(() => ({}));
try {
switch (action) {
case "login": {
const { email, password } = body;
if (!email || !password) {
return new Response(
JSON.stringify({ message: "Email and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await authenticateUser(email, password);
return Response.json({
id: result.user.id,
name: result.user.name ?? "",
email: result.user.email,
accessToken: result.accessToken,
sessionToken: result.sessionToken,
isNewUser: false,
});
}
case "signup": {
const { name, email, password } = body;
if (!email || !password) {
return new Response(
JSON.stringify({
message: "Name, email, and password are required",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await createUserWithPassword(
name ?? email.split("@")[0],
email,
password,
);
return Response.json({
id: result.user.id,
name: result.user.name ?? "",
email: result.user.email,
accessToken: result.accessToken,
sessionToken: result.sessionToken,
isNewUser: true,
});
}
case "apple": {
const { identityToken, authorizationCode, userIdentifier } = body;
if (!identityToken || !authorizationCode) {
return new Response(
JSON.stringify({
message: "identityToken and authorizationCode are required",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await authenticateWithApple(
identityToken,
authorizationCode,
userIdentifier ?? null,
);
return Response.json({
id: result.user.id,
name: result.user.name ?? "",
email: result.user.email,
image: result.user.image,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
sessionToken: result.sessionToken,
isNewUser: result.isNewUser ?? false,
});
}
case "refresh": {
const { refreshToken } = body;
if (!refreshToken) {
return new Response(
JSON.stringify({ message: "refreshToken is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await refreshAccessToken(refreshToken);
return Response.json({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
}
case "logout": {
// Extract user from Bearer token
const authHeader = event.request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
try {
const payload = await verifyJWT<{ sub: string }>(token);
await revokeUserSessions(payload.sub);
} catch {
// Invalid token — still return success
}
}
return Response.json({ success: true });
}
case "forgot-password": {
const { email } = body;
if (!email) {
return new Response(
JSON.stringify({ message: "Email is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
await forgotPassword(email);
return Response.json({ success: true });
}
case "reset-password": {
const { code, password } = body;
if (!code || !password) {
return new Response(
JSON.stringify({ message: "Code and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// The mobile app sends "code" but the service expects "token"
// We accept both for backward compatibility
const token = code;
await resetPassword(token, password);
return Response.json({ success: true });
}
default:
return new Response(
JSON.stringify({ message: `Unknown action: ${action}` }),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
} catch (error: any) {
const statusCode =
error.code === "UNAUTHORIZED"
? 401
: error.code === "CONFLICT"
? 409
: error.code === "NOT_FOUND"
? 404
: error.code === "FORBIDDEN"
? 403
: 500;
return new Response(
JSON.stringify({
message: error.message ?? "Internal server error",
code: error.code ?? "INTERNAL_ERROR",
}),
{
status: statusCode,
headers: { "Content-Type": "application/json" },
},
);
}
}