refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,229 @@
import crypto from "node:crypto";
function getJwtSecret(): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error(
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
);
}
return secret;
}
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
// ============================================================================
// TYPES
// ============================================================================
export interface JwtPayload {
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
}
// ============================================================================
// JWT UTILITIES
// ============================================================================
/**
* Base64URL encode a string
*/
function base64UrlEncode(str: string): string {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Base64URL decode a string
*/
function base64UrlDecode(str: string): string {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
}
/**
* Create HMAC-SHA256 signature
*/
function createSignature(data: string): string {
return crypto
.createHmac("sha256", getJwtSecret())
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Create a JWT token
*/
export function createJwt(
payload: Omit<JwtPayload, "iat" | "exp">,
expiresIn: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}
/**
* Verify and decode a JWT token
* @returns The decoded payload or null if invalid
*/
export function verifyJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(
`${headerEncoded}.${payloadEncoded}`,
);
// Constant-time comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
return payload;
} catch {
return null;
}
}
// ============================================================================
// TOKEN GENERATION
// ============================================================================
/**
* Generate a random token ID
*/
export function generateTokenId(): string {
return crypto.randomUUID();
}
/**
* Hash a token using SHA-256
*/
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Get the display prefix of a token (first 8 chars after prefix)
*/
export function getTokenPrefix(token: string): string {
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
}
/**
* Generate a complete token pair (access + refresh)
*/
export function generateTokenPair(
userId: string,
deviceId?: string,
): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY,
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY,
);
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
}
/**
* Refresh an access token using a refresh token
*/
export function refreshAccessToken(
refreshToken: string,
): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
if (!payload || payload.type !== "api_refresh") {
return null;
}
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{
sub: payload.sub,
type: "api_access",
tokenId: payload.tokenId,
deviceId: payload.deviceId,
},
ACCESS_TOKEN_EXPIRY,
);
return { accessToken, expiresAt };
}
// ============================================================================
// VALIDATION HELPERS
// ============================================================================
/**
* Extract bearer token from Authorization header
*/
export function extractBearerToken(authHeader: string | null): string | null {
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
}

View File

@@ -0,0 +1,23 @@
import { passkeyClient } from "@better-auth/passkey/client";
import { createAuthClient } from "better-auth/react";
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
export const authClient = createAuthClient({
...(baseURL ? { baseURL } : {}),
plugins: [passkeyClient()],
});
/**
* Indica se o login com Google está habilitado.
* Verifica se as credenciais do Google OAuth estão configuradas.
*
* IMPORTANTE: Como variáveis de ambiente sem prefixo NEXT_PUBLIC_ não estão
* disponíveis no cliente, esta verificação deve ser feita no servidor.
* Por isso, sempre retornamos true aqui e a validação real acontece no servidor.
*
* Para desabilitar o Google OAuth, remova ou deixe vazias as variáveis:
* - GOOGLE_CLIENT_ID
* - GOOGLE_CLIENT_SECRET
*/
export const googleSignInAvailable = true;

View File

@@ -0,0 +1,147 @@
import { passkey } from "@better-auth/passkey";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
import { seedDefaultCategoriesForUser } from "@/shared/lib/categories/defaults";
import { db, schema } from "@/shared/lib/db";
import { ensureDefaultPagadorForUser } from "@/shared/lib/payers/defaults";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
// ============================================================================
// GOOGLE OAUTH CONFIGURATION
// ============================================================================
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
/**
* Extrai nome do usuário do perfil do Google com fallback hierárquico:
* 1. profile.name (nome completo)
* 2. profile.given_name + profile.family_name
* 3. Nome extraído do email
* 4. "Usuário" (fallback final)
*/
function getNameFromGoogleProfile(profile: GoogleProfile): string {
const fullName = profile.name?.trim();
if (fullName) return fullName;
const fromGivenFamily = [profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ")
.trim();
if (fromGivenFamily) return fromGivenFamily;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
return fromEmail ?? "Usuário";
}
// ============================================================================
// BETTER AUTH INSTANCE
// ============================================================================
export const auth = betterAuth({
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// Plugins
plugins: [
passkey({
rpName: "OpenMonetis",
}),
],
// Google OAuth (se configurado)
socialProviders:
googleClientId && googleClientSecret
? {
google: {
clientId: googleClientId,
clientSecret: googleClientSecret,
mapProfileToUser: (profile) => ({
name: getNameFromGoogleProfile(profile),
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified,
}),
},
}
: undefined,
// Database hooks - Executados após eventos do DB
databaseHooks: {
user: {
create: {
/**
* Após criar novo usuário, inicializa:
* 1. Categorias padrão (Receitas/Despesas)
* 2. Pagador padrão (vinculado ao usuário)
*/
after: async (user) => {
// Se falhar aqui, o usuário já foi criado - considere usar queue para retry
try {
await seedDefaultCategoriesForUser(user.id);
await ensureDefaultPagadorForUser({
id: user.id,
name: user.name ?? undefined,
email: user.email ?? undefined,
image: user.image ?? undefined,
});
} catch (error) {
console.error(
"[Auth] Falha ao criar dados padrão do usuário:",
error,
);
}
},
},
},
},
});
// Aviso em desenvolvimento se Google OAuth não estiver configurado
if (!googleClientId && process.env.NODE_ENV === "development") {
console.warn(
"[Auth] Google OAuth não configurado. Defina GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET.",
);
}

View File

@@ -0,0 +1,66 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "@/shared/lib/auth/config";
/**
* Cached session fetch - deduplicates auth calls within a single request.
* Layout + page calling getUser() will only hit auth once.
*/
const getSessionCached = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});
/**
* Gets the current authenticated user
* @returns User object
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
}
return session.user;
}
/**
* Gets the current authenticated user ID
* @returns User ID string
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
}
return session.user.id;
}
/**
* Gets the current authenticated session
* @returns Full session object including user
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
}
return session;
}
/**
* Gets the current session without requiring authentication
* @returns Session object or null if not authenticated
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return getSessionCached();
}