From 2532f2d6ada82f54341932b63d0c20a90ddb352e Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Fri, 23 Jan 2026 12:11:19 +0000 Subject: [PATCH] feat(auth): add API token authentication for OpenSheets Companion - Implement JWT-based authentication system for device access - Access tokens (7 day expiry) and refresh tokens (90 day expiry) - HMAC-SHA256 signing with timing-safe comparison - Token hashing with SHA-256 for secure storage - Add device authentication endpoints: - POST /api/auth/device/token - Login with email/password, get tokens - POST /api/auth/device/refresh - Refresh access token - POST /api/auth/device/verify - Verify token validity - GET /api/auth/device/tokens - List user's API tokens - DELETE /api/auth/device/tokens/[id] - Revoke specific token - Track token usage (last used timestamp and IP) --- app/api/auth/device/refresh/route.ts | 85 +++++++ app/api/auth/device/token/route.ts | 79 ++++++ app/api/auth/device/tokens/[tokenId]/route.ts | 65 +++++ app/api/auth/device/tokens/route.ts | 53 ++++ app/api/auth/device/verify/route.ts | 76 ++++++ lib/auth/api-token.ts | 226 ++++++++++++++++++ 6 files changed, 584 insertions(+) create mode 100644 app/api/auth/device/refresh/route.ts create mode 100644 app/api/auth/device/token/route.ts create mode 100644 app/api/auth/device/tokens/[tokenId]/route.ts create mode 100644 app/api/auth/device/tokens/route.ts create mode 100644 app/api/auth/device/verify/route.ts create mode 100644 lib/auth/api-token.ts diff --git a/app/api/auth/device/refresh/route.ts b/app/api/auth/device/refresh/route.ts new file mode 100644 index 0000000..44a148c --- /dev/null +++ b/app/api/auth/device/refresh/route.ts @@ -0,0 +1,85 @@ +/** + * POST /api/auth/device/refresh + * + * Atualiza access token usando refresh token. + * Usado pelo app Android quando o access token expira. + */ + +import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token"; +import { db } from "@/lib/db"; +import { apiTokens } from "@/db/schema"; +import { eq, and, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + // Extrair refresh token do header + const authHeader = request.headers.get("Authorization"); + const token = extractBearerToken(authHeader); + + if (!token) { + return NextResponse.json( + { error: "Refresh token não fornecido" }, + { status: 401 } + ); + } + + // Validar refresh token + const payload = verifyJwt(token); + + if (!payload || payload.type !== "api_refresh") { + return NextResponse.json( + { error: "Refresh token inválido ou expirado" }, + { status: 401 } + ); + } + + // Verificar se token não foi revogado + const tokenRecord = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.id, payload.tokenId), + eq(apiTokens.userId, payload.sub), + isNull(apiTokens.revokedAt) + ), + }); + + if (!tokenRecord) { + return NextResponse.json( + { error: "Token revogado ou não encontrado" }, + { status: 401 } + ); + } + + // Gerar novo access token + const result = refreshAccessToken(token); + + if (!result) { + return NextResponse.json( + { error: "Não foi possível renovar o token" }, + { status: 401 } + ); + } + + // Atualizar hash do token e último uso + await db + .update(apiTokens) + .set({ + tokenHash: hashToken(result.accessToken), + lastUsedAt: new Date(), + lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"), + expiresAt: result.expiresAt, + }) + .where(eq(apiTokens.id, payload.tokenId)); + + return NextResponse.json({ + accessToken: result.accessToken, + expiresAt: result.expiresAt.toISOString(), + }); + } catch (error) { + console.error("[API] Error refreshing device token:", error); + return NextResponse.json( + { error: "Erro ao renovar token" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/device/token/route.ts b/app/api/auth/device/token/route.ts new file mode 100644 index 0000000..6e7dab8 --- /dev/null +++ b/app/api/auth/device/token/route.ts @@ -0,0 +1,79 @@ +/** + * POST /api/auth/device/token + * + * Gera um novo token de API para dispositivo. + * Requer sessão web autenticada. + */ + +import { auth } from "@/lib/auth/config"; +import { generateTokenPair, hashToken, getTokenPrefix } from "@/lib/auth/api-token"; +import { db } from "@/lib/db"; +import { apiTokens } from "@/db/schema"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const createTokenSchema = z.object({ + name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"), + deviceId: z.string().optional(), +}); + +export async function POST(request: Request) { + try { + // Verificar autenticação via sessão web + const session = await auth.api.getSession({ headers: await headers() }); + + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401 } + ); + } + + // Validar body + const body = await request.json(); + const { name, deviceId } = createTokenSchema.parse(body); + + // Gerar par de tokens + const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair( + session.user.id, + deviceId + ); + + // Salvar hash do token no banco + await db.insert(apiTokens).values({ + id: tokenId, + userId: session.user.id, + name, + tokenHash: hashToken(accessToken), + tokenPrefix: getTokenPrefix(accessToken), + expiresAt, + }); + + // Retornar tokens (mostrados apenas uma vez) + return NextResponse.json( + { + accessToken, + refreshToken, + tokenId, + name, + expiresAt: expiresAt.toISOString(), + message: "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.", + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message ?? "Dados inválidos" }, + { status: 400 } + ); + } + + console.error("[API] Error creating device token:", error); + return NextResponse.json( + { error: "Erro ao criar token" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/device/tokens/[tokenId]/route.ts b/app/api/auth/device/tokens/[tokenId]/route.ts new file mode 100644 index 0000000..c6c64e9 --- /dev/null +++ b/app/api/auth/device/tokens/[tokenId]/route.ts @@ -0,0 +1,65 @@ +/** + * DELETE /api/auth/device/tokens/[tokenId] + * + * Revoga um token de API específico. + * Requer sessão web autenticada. + */ + +import { auth } from "@/lib/auth/config"; +import { db } from "@/lib/db"; +import { apiTokens } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface RouteParams { + params: Promise<{ tokenId: string }>; +} + +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const { tokenId } = await params; + + // Verificar autenticação via sessão web + const session = await auth.api.getSession({ headers: await headers() }); + + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401 } + ); + } + + // Verificar se token pertence ao usuário + const token = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.id, tokenId), + eq(apiTokens.userId, session.user.id) + ), + }); + + if (!token) { + return NextResponse.json( + { error: "Token não encontrado" }, + { status: 404 } + ); + } + + // Revogar token (soft delete) + await db + .update(apiTokens) + .set({ revokedAt: new Date() }) + .where(eq(apiTokens.id, tokenId)); + + return NextResponse.json({ + message: "Token revogado com sucesso", + tokenId, + }); + } catch (error) { + console.error("[API] Error revoking device token:", error); + return NextResponse.json( + { error: "Erro ao revogar token" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/device/tokens/route.ts b/app/api/auth/device/tokens/route.ts new file mode 100644 index 0000000..340ed9b --- /dev/null +++ b/app/api/auth/device/tokens/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/auth/device/tokens + * + * Lista todos os tokens de API do usuário. + * Requer sessão web autenticada. + */ + +import { auth } from "@/lib/auth/config"; +import { db } from "@/lib/db"; +import { apiTokens } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + try { + // Verificar autenticação via sessão web + const session = await auth.api.getSession({ headers: await headers() }); + + if (!session?.user) { + return NextResponse.json( + { error: "Não autenticado" }, + { status: 401 } + ); + } + + // Buscar tokens ativos do usuário + const tokens = await db + .select({ + id: apiTokens.id, + name: apiTokens.name, + tokenPrefix: apiTokens.tokenPrefix, + lastUsedAt: apiTokens.lastUsedAt, + lastUsedIp: apiTokens.lastUsedIp, + expiresAt: apiTokens.expiresAt, + createdAt: apiTokens.createdAt, + }) + .from(apiTokens) + .where(eq(apiTokens.userId, session.user.id)) + .orderBy(desc(apiTokens.createdAt)); + + // Separar tokens ativos e revogados + const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date()); + + return NextResponse.json({ tokens: activeTokens }); + } catch (error) { + console.error("[API] Error listing device tokens:", error); + return NextResponse.json( + { error: "Erro ao listar tokens" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/device/verify/route.ts b/app/api/auth/device/verify/route.ts new file mode 100644 index 0000000..a862542 --- /dev/null +++ b/app/api/auth/device/verify/route.ts @@ -0,0 +1,76 @@ +/** + * POST /api/auth/device/verify + * + * Valida se um token de API é válido. + * Usado pelo app Android durante o setup. + */ + +import { validateApiToken, extractBearerToken } from "@/lib/auth/api-token"; +import { db } from "@/lib/db"; +import { apiTokens } from "@/db/schema"; +import { eq, and, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + // Extrair token do header + const authHeader = request.headers.get("Authorization"); + const token = extractBearerToken(authHeader); + + if (!token) { + return NextResponse.json( + { valid: false, error: "Token não fornecido" }, + { status: 401 } + ); + } + + // Validar JWT + const payload = validateApiToken(token); + + if (!payload) { + return NextResponse.json( + { valid: false, error: "Token inválido ou expirado" }, + { status: 401 } + ); + } + + // Verificar se token não foi revogado + const tokenRecord = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.id, payload.tokenId), + eq(apiTokens.userId, payload.sub), + isNull(apiTokens.revokedAt) + ), + }); + + if (!tokenRecord) { + return NextResponse.json( + { valid: false, error: "Token revogado ou não encontrado" }, + { status: 401 } + ); + } + + // Atualizar último uso + await db + .update(apiTokens) + .set({ + lastUsedAt: new Date(), + lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"), + }) + .where(eq(apiTokens.id, payload.tokenId)); + + return NextResponse.json({ + valid: true, + userId: payload.sub, + tokenId: payload.tokenId, + tokenName: tokenRecord.name, + expiresAt: new Date(payload.exp * 1000).toISOString(), + }); + } catch (error) { + console.error("[API] Error verifying device token:", error); + return NextResponse.json( + { valid: false, error: "Erro ao validar token" }, + { status: 500 } + ); + } +} diff --git a/lib/auth/api-token.ts b/lib/auth/api-token.ts new file mode 100644 index 0000000..977dc42 --- /dev/null +++ b/lib/auth/api-token.ts @@ -0,0 +1,226 @@ +/** + * API Token utilities for OpenSheets Companion + * + * Handles JWT generation, validation, and token hashing for device authentication. + */ + +import crypto from "crypto"; + +const JWT_SECRET = process.env.BETTER_AUTH_SECRET || "opensheets-secret-change-me"; +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", JWT_SECRET) + .update(data) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +/** + * Create a JWT token + */ +export function createJwt(payload: Omit, 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(); +} + +/** + * Generate a random API token with prefix + */ +export function generateApiToken(): string { + const randomPart = crypto.randomBytes(32).toString("base64url"); + return `os_${randomPart}`; +} + +/** + * 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; +} + +/** + * Validate an API token and return the payload + */ +export function validateApiToken(token: string): JwtPayload | null { + const payload = verifyJwt(token); + if (!payload || payload.type !== "api_access") { + return null; + } + return payload; +}