forked from git.gladyson/openmonetis
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)
This commit is contained in:
85
app/api/auth/device/refresh/route.ts
Normal file
85
app/api/auth/device/refresh/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/api/auth/device/token/route.ts
Normal file
79
app/api/auth/device/token/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/api/auth/device/tokens/[tokenId]/route.ts
Normal file
65
app/api/auth/device/tokens/[tokenId]/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/auth/device/tokens/route.ts
Normal file
53
app/api/auth/device/tokens/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/api/auth/device/verify/route.ts
Normal file
76
app/api/auth/device/verify/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
lib/auth/api-token.ts
Normal file
226
lib/auth/api-token.ts
Normal file
@@ -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<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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user