mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
229
src/shared/lib/auth/api-token.ts
Normal file
229
src/shared/lib/auth/api-token.ts
Normal 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;
|
||||
}
|
||||
23
src/shared/lib/auth/client.ts
Normal file
23
src/shared/lib/auth/client.ts
Normal 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;
|
||||
147
src/shared/lib/auth/config.ts
Normal file
147
src/shared/lib/auth/config.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
66
src/shared/lib/auth/server.ts
Normal file
66
src/shared/lib/auth/server.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user