mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
Compare commits
7 Commits
85f6dcfc22
...
v2.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7ae0fa3d | ||
|
|
98fe6a0f4f | ||
|
|
d10eae13e5 | ||
|
|
43697b4fd2 | ||
|
|
27e3ba5f0d | ||
|
|
31485eec8f | ||
|
|
3be64aa8d0 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -7,6 +7,36 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.6] - 2026-04-09
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
|
||||||
|
|
||||||
|
## [2.3.5] - 2026-04-07
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
|
||||||
|
|
||||||
|
## [2.3.4] - 2026-04-05
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
|
||||||
|
|
||||||
|
## [2.3.3] - 2026-04-05
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
|
||||||
|
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
|
||||||
|
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
|
||||||
|
|
||||||
## [2.3.2] - 2026-04-04
|
## [2.3.2] - 2026-04-04
|
||||||
|
|
||||||
### Segurança
|
### Segurança
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
|
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md`.
|
||||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||||
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||||
|
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
echo "Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
RETRIES=5
|
RETRIES=5
|
||||||
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
||||||
RETRIES=$((RETRIES - 1))
|
RETRIES=$((RETRIES - 1))
|
||||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type { NextConfig } from "next";
|
|||||||
// Carregar variáveis de ambiente explicitamente
|
// Carregar variáveis de ambiente explicitamente
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
@@ -44,18 +42,6 @@ const nextConfig: NextConfig = {
|
|||||||
key: "X-Frame-Options",
|
key: "X-Frame-Options",
|
||||||
value: "DENY",
|
value: "DENY",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "Content-Security-Policy",
|
|
||||||
value: [
|
|
||||||
"default-src 'self'",
|
|
||||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
|
|
||||||
"font-src 'self'",
|
|
||||||
"connect-src 'self' https://umami.felipecoutinho.com",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
].join("; "),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "Referrer-Policy",
|
key: "Referrer-Policy",
|
||||||
value: "strict-origin-when-cross-origin",
|
value: "strict-origin-when-cross-origin",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.3.2",
|
"version": "2.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { apiTokens } from "@/db/schema";
|
|
||||||
import {
|
|
||||||
extractBearerToken,
|
|
||||||
refreshAccessToken,
|
|
||||||
verifyJwt,
|
|
||||||
} from "@/shared/lib/auth/api-token";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
|
|
||||||
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),
|
|
||||||
gt(apiTokens.expiresAt, new Date()),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 último uso e expiração (sem sobrescrever tokenHash,
|
|
||||||
// pois o JWT é auto-verificável por assinatura)
|
|
||||||
await db
|
|
||||||
.update(apiTokens)
|
|
||||||
.set({
|
|
||||||
lastUsedAt: new Date(),
|
|
||||||
lastUsedIp:
|
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
||||||
request.headers.get("x-real-ip") ||
|
|
||||||
null,
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { connection, NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { apiTokens } from "@/db/schema";
|
|
||||||
import {
|
|
||||||
generateTokenPair,
|
|
||||||
getTokenPrefix,
|
|
||||||
hashToken,
|
|
||||||
} from "@/shared/lib/auth/api-token";
|
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
|
||||||
import { db } from "@/shared/lib/db";
|
|
||||||
|
|
||||||
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) {
|
|
||||||
await connection();
|
|
||||||
|
|
||||||
// Verificar autenticação via sessão web
|
|
||||||
const requestHeaders = new Headers(await headers());
|
|
||||||
const session = await auth.api.getSession({ headers: requestHeaders });
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token";
|
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -17,21 +17,20 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar JWT (assinatura + expiração)
|
// Validar token opm_xxx via hash
|
||||||
const payload = verifyJwt(token);
|
if (!token.startsWith("opm_")) {
|
||||||
|
|
||||||
if (!payload || payload.type !== "api_access") {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Token inválido ou expirado" },
|
{ valid: false, error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar token no banco por tokenId para checar revogação
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
// Buscar token no banco
|
||||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.id, payload.tokenId),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
eq(apiTokens.userId, payload.sub),
|
|
||||||
isNull(apiTokens.revokedAt),
|
isNull(apiTokens.revokedAt),
|
||||||
gt(apiTokens.expiresAt, new Date()),
|
gt(apiTokens.expiresAt, new Date()),
|
||||||
),
|
),
|
||||||
@@ -39,7 +38,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Token revogado ou não encontrado" },
|
{ valid: false, error: "Token inválido ou revogado" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
|
|||||||
@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function generateSecureToken(): string {
|
function generateSecureToken(): string {
|
||||||
const prefix = "os";
|
const prefix = "opm";
|
||||||
const randomPart = randomBytes(32).toString("base64url");
|
const randomPart = randomBytes(32).toString("base64url");
|
||||||
return `${prefix}_${randomPart}`;
|
return `${prefix}_${randomPart}`;
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/proxy.ts
36
src/proxy.ts
@@ -21,6 +21,38 @@ const PROTECTED_ROUTES = [
|
|||||||
// Rotas públicas (não requerem autenticação)
|
// Rotas públicas (não requerem autenticação)
|
||||||
const PUBLIC_AUTH_ROUTES = ["/login", "/signup"];
|
const PUBLIC_AUTH_ROUTES = ["/login", "/signup"];
|
||||||
|
|
||||||
|
function buildCsp(): string {
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
const s3Origin = (() => {
|
||||||
|
try {
|
||||||
|
return process.env.S3_ENDPOINT
|
||||||
|
? new URL(process.env.S3_ENDPOINT).origin
|
||||||
|
: "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const connectExtras = ["https://umami.felipecoutinho.com", s3Origin]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const imgExtras = ["https://lh3.googleusercontent.com", s3Origin]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
`img-src 'self' ${imgExtras} data: blob:`,
|
||||||
|
"font-src 'self'",
|
||||||
|
`connect-src 'self' ${connectExtras}`,
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
export default async function proxy(request: NextRequest) {
|
export default async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
@@ -63,7 +95,9 @@ export default async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(new URL("/login", request.url));
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
const response = NextResponse.next();
|
||||||
|
response.headers.set("Content-Security-Policy", buildCsp());
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,149 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
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
|
* Hash a token using SHA-256
|
||||||
*/
|
*/
|
||||||
@@ -151,74 +7,6 @@ export function hashToken(token: string): string {
|
|||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
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
|
* Extract bearer token from Authorization header
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user