android flesh out
This commit is contained in:
178
web/src/routes/api/auth/[action].ts
Normal file
178
web/src/routes/api/auth/[action].ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import {
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
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 "google": {
|
||||
const { idToken } = body;
|
||||
if (!idToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "idToken is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateWithGoogle(idToken);
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,18 @@ import {
|
||||
RemoveMemberSchema,
|
||||
UpdateRoleSchema,
|
||||
} from "../schemas/user";
|
||||
import { getUserById, updateUser, deleteUser, createUserWithPassword, authenticateUser } from "~/server/services/user.service";
|
||||
import {
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
createUserWithPassword,
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
refreshAccessToken,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
revokeUserSessions,
|
||||
} from "~/server/services/user.service";
|
||||
import {
|
||||
getFamilyGroup,
|
||||
inviteMember,
|
||||
@@ -27,6 +38,23 @@ const SignupSchema = object({
|
||||
password: string([minLength(8)]),
|
||||
});
|
||||
|
||||
const GoogleAuthSchema = object({
|
||||
idToken: string([minLength(1)]),
|
||||
});
|
||||
|
||||
const RefreshTokenSchema = object({
|
||||
refreshToken: string([minLength(1)]),
|
||||
});
|
||||
|
||||
const ForgotPasswordSchema = object({
|
||||
email: string([emailVal()]),
|
||||
});
|
||||
|
||||
const ResetPasswordSchema = object({
|
||||
token: string([minLength(1)]),
|
||||
password: string([minLength(8)]),
|
||||
});
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
login: publicProcedure
|
||||
.input(wrap(LoginSchema))
|
||||
@@ -37,10 +65,31 @@ export const userRouter = createTRPCRouter({
|
||||
signup: publicProcedure
|
||||
.input(wrap(SignupSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
const user = await createUserWithPassword(input.name, input.email, input.password);
|
||||
const { createSession } = await import("~/server/auth/session");
|
||||
const session = await createSession(user.id);
|
||||
return { user, sessionToken: session.sessionToken };
|
||||
return createUserWithPassword(input.name, input.email, input.password);
|
||||
}),
|
||||
|
||||
googleAuth: publicProcedure
|
||||
.input(wrap(GoogleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateWithGoogle(input.idToken);
|
||||
}),
|
||||
|
||||
refreshToken: publicProcedure
|
||||
.input(wrap(RefreshTokenSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return refreshAccessToken(input.refreshToken);
|
||||
}),
|
||||
|
||||
forgotPassword: publicProcedure
|
||||
.input(wrap(ForgotPasswordSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return forgotPassword(input.email);
|
||||
}),
|
||||
|
||||
resetPassword: publicProcedure
|
||||
.input(wrap(ResetPasswordSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return resetPassword(input.token, input.password);
|
||||
}),
|
||||
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -60,6 +109,11 @@ export const userRouter = createTRPCRouter({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
logout: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await revokeUserSessions(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
listFamilyMembers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return group.members;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { users, accounts } from "~/server/db/schema/auth";
|
||||
import { hashPassword, verifyPassword } from "~/server/auth/password";
|
||||
import { createSession } from "~/server/auth/session";
|
||||
import { signJWT } from "~/server/auth/jwt";
|
||||
|
||||
export async function createUserWithPassword(
|
||||
name: string,
|
||||
@@ -28,7 +29,11 @@ export async function createUserWithPassword(
|
||||
.insert(users)
|
||||
.values({ name, email, passwordHash })
|
||||
.returning();
|
||||
return user;
|
||||
|
||||
const session = await createSession(user.id);
|
||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
}
|
||||
|
||||
export async function authenticateUser(
|
||||
@@ -57,7 +62,275 @@ export async function authenticateUser(
|
||||
}
|
||||
|
||||
const session = await createSession(user.id);
|
||||
return { user, sessionToken: session.sessionToken };
|
||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
}
|
||||
|
||||
const GOOGLE_ISSUER = "https://accounts.google.com";
|
||||
|
||||
/**
|
||||
* Verifies a Google ID token using firebase-admin and returns the user.
|
||||
* If the user does not exist, creates a new account.
|
||||
* If the user exists but has not linked Google, links the provider.
|
||||
*/
|
||||
export async function authenticateWithGoogle(idToken: string) {
|
||||
const { initializeApp, cert, getApps } = await import("firebase-admin/app");
|
||||
|
||||
// Initialize Firebase Admin if not already done
|
||||
if (getApps().length === 0) {
|
||||
// Try to load from environment or use application default credentials
|
||||
const projectId = process.env.FIREBASE_PROJECT_ID;
|
||||
const clientEmail = process.env.FIREBASE_CLIENT_EMAIL;
|
||||
const privateKey = process.env.FIREBASE_PRIVATE_KEY;
|
||||
|
||||
if (projectId && clientEmail && privateKey) {
|
||||
initializeApp({
|
||||
credential: cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey: privateKey.replace(/\\n/g, "\n"),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// Fall back to application default credentials
|
||||
initializeApp({ projectId: projectId ?? "kordant" });
|
||||
}
|
||||
}
|
||||
|
||||
let decodedToken: { uid: string; email?: string; name?: string; picture?: string };
|
||||
try {
|
||||
const authModule = await import("firebase-admin/auth");
|
||||
decodedToken = await authModule.getAuth().verifyIdToken(idToken);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token",
|
||||
});
|
||||
}
|
||||
|
||||
const googleUserId = decodedToken.uid;
|
||||
const email = decodedToken.email;
|
||||
const name = decodedToken.name ?? email?.split("@")[0] ?? "User";
|
||||
const avatarUrl = decodedToken.picture ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Google account has no email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this Google account is already linked
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, "google"),
|
||||
eq(accounts.providerAccountId, googleUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let userId: string;
|
||||
let isNewUser = false;
|
||||
|
||||
if (existingAccount) {
|
||||
// Already linked — use the existing user
|
||||
userId = existingAccount.userId;
|
||||
isNewUser = false;
|
||||
|
||||
// Update the access token if provided
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
accessToken: idToken,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, existingAccount.id));
|
||||
} else {
|
||||
// Not linked — check if a user with this email exists
|
||||
const [existingUserByEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (existingUserByEmail) {
|
||||
// Link Google to existing user
|
||||
userId = existingUserByEmail.id;
|
||||
isNewUser = false;
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "google",
|
||||
providerAccountId: googleUserId,
|
||||
accessToken: idToken,
|
||||
});
|
||||
|
||||
// Update avatar if not set
|
||||
if (!existingUserByEmail.image && avatarUrl) {
|
||||
await db.update(users).set({ image: avatarUrl }).where(eq(users.id, userId));
|
||||
}
|
||||
} else {
|
||||
// Create new user with Google
|
||||
isNewUser = true;
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name,
|
||||
email,
|
||||
image: avatarUrl,
|
||||
emailVerified: new Date(),
|
||||
})
|
||||
.returning();
|
||||
userId = newUser.id;
|
||||
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "google",
|
||||
providerAccountId: googleUserId,
|
||||
accessToken: idToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
const session = await createSession(userId);
|
||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
|
||||
}
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an access token using a valid refresh token.
|
||||
*/
|
||||
export async function refreshAccessToken(refreshToken: string) {
|
||||
const { verifyJWT, signJWT } = await import("~/server/auth/jwt");
|
||||
|
||||
let payload: { sub?: string; type?: string };
|
||||
try {
|
||||
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid or expired refresh token",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type !== "refresh") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token type",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = payload.sub!;
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
|
||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a password reset email.
|
||||
*/
|
||||
export async function forgotPassword(email: string) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal whether the email exists
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Generate a reset token (valid for 1 hour)
|
||||
const resetToken = await signJWT(
|
||||
{ sub: user.id, type: "password-reset" },
|
||||
{ expiresIn: "1h" },
|
||||
);
|
||||
|
||||
// In production, send via email service (Resend, SendGrid, etc.)
|
||||
// For now, we log it and return success
|
||||
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||
|
||||
// TODO: Send email via Resend
|
||||
// const { Resend } = await import("resend");
|
||||
// const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
// await resend.emails.send({
|
||||
// from: "Kordant <support@kordant.com>",
|
||||
// to: email,
|
||||
// subject: "Reset your password",
|
||||
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
|
||||
// });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a user's password using a valid reset token.
|
||||
*/
|
||||
export async function resetPassword(token: string, newPassword: string) {
|
||||
const { verifyJWT } = await import("~/server/auth/jwt");
|
||||
|
||||
let payload: { sub?: string; type?: string };
|
||||
try {
|
||||
payload = await verifyJWT<{ sub: string; type: string }>(token);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid or expired reset token",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type !== "password-reset") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token type",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = payload.sub!;
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all sessions for a user (logout everywhere).
|
||||
*/
|
||||
export async function revokeUserSessions(userId: string) {
|
||||
const { sessions } = await import("~/server/db/schema/auth");
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user