- Replace Clerk with self-hosted JWT auth (register/login/verify, bcrypt passwords, Gmail OTP with console fallback) - Add lib/db.ts pg pool + transaction helpers; seed script migrates legacy Clerk-era schema (drop clerk_id, enforce UUID ids and unique email) - Add owner-gated admin API: stats, users, drivers CRUD, rides - Add dashboard/ Vite React owner dashboard (login, overview, users, fleet, rides) with dev-server proxy to avoid Expo CORS middleware - Add scripts/set-owner.mjs for role management
101 lines
2.5 KiB
TypeScript
101 lines
2.5 KiB
TypeScript
import { createHmac, timingSafeEqual } from "crypto";
|
|
|
|
const base64Url = (input: Buffer | string): string =>
|
|
Buffer.from(input)
|
|
.toString("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
|
|
const fromBase64Url = (input: string): Buffer =>
|
|
Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
|
|
export type JwtPayload = {
|
|
sub: string;
|
|
email: string;
|
|
iat: number;
|
|
exp: number;
|
|
};
|
|
|
|
const secret = (): string => {
|
|
const value = process.env.AUTH_JWT_SECRET;
|
|
if (!value) throw new Error("Missing AUTH_JWT_SECRET.");
|
|
return value;
|
|
};
|
|
|
|
export const signJwt = (
|
|
payload: { sub: string; email: string },
|
|
expiresInSeconds = 30 * 24 * 60 * 60,
|
|
): string => {
|
|
const iat = Math.floor(Date.now() / 1000);
|
|
const body: JwtPayload = { ...payload, iat, exp: iat + expiresInSeconds };
|
|
|
|
const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
const claims = base64Url(JSON.stringify(body));
|
|
const signature = base64Url(
|
|
createHmac("sha256", secret()).update(`${header}.${claims}`).digest(),
|
|
);
|
|
|
|
return `${header}.${claims}.${signature}`;
|
|
};
|
|
|
|
export const verifyJwt = (token: string): JwtPayload | null => {
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return null;
|
|
|
|
const [header, claims, signature] = parts;
|
|
|
|
const expected = createHmac("sha256", secret())
|
|
.update(`${header}.${claims}`)
|
|
.digest();
|
|
|
|
const received = fromBase64Url(signature);
|
|
|
|
if (
|
|
expected.length !== received.length ||
|
|
!timingSafeEqual(expected, received)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(fromBase64Url(claims).toString()) as JwtPayload;
|
|
|
|
if (payload.exp * 1000 < Date.now()) return null;
|
|
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Returns the authenticated principal or a ready-to-return error Response.
|
|
export const requireAuth = (
|
|
req: Request,
|
|
): { userId: string; email: string } | { error: Response } => {
|
|
const header = req.headers.get("authorization") ?? "";
|
|
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
|
|
const payload = token ? verifyJwt(token) : null;
|
|
|
|
if (!payload) {
|
|
return {
|
|
error: Response.json({ error: "Unauthorized." }, { status: 401 }),
|
|
};
|
|
}
|
|
|
|
return { userId: payload.sub, email: payload.email };
|
|
};
|
|
|
|
export const decodeJwtExp = (token: string): number | null => {
|
|
try {
|
|
const claims = JSON.parse(
|
|
fromBase64Url(token.split(".")[1]).toString(),
|
|
) as JwtPayload;
|
|
|
|
return claims.exp ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|