chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled

This commit is contained in:
Wira Basalamah
2026-04-21 09:29:29 +07:00
commit adde003fba
222 changed files with 37657 additions and 0 deletions

200
lib/auth-tokens.ts Normal file
View File

@ -0,0 +1,200 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { Prisma } from "@prisma/client";
import { AuthTokenType } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const AUTH_TOKEN_SECRET = process.env.AUTH_SECRET;
const FALLBACK_AUTH_TOKEN_SECRET = "whatsapp-inbox-dev-secret";
const RESOLVED_SECRET = AUTH_TOKEN_SECRET || (process.env.NODE_ENV === "production" ? undefined : FALLBACK_AUTH_TOKEN_SECRET);
if (!RESOLVED_SECRET) {
throw new Error("AUTH_SECRET is required in production for secure token generation.");
}
const textEncoder = new TextEncoder();
const tokenSecretBytes = textEncoder.encode(RESOLVED_SECRET);
const RESET_DEFAULT_TTL_MINUTES = 60;
const INVITE_DEFAULT_TTL_MINUTES = 72 * 60;
type HashInput = {
token: string;
};
type TokenResult = {
id: string;
userId: string;
tenantId: string;
tokenType: AuthTokenType;
tokenHash: string;
expiresAt: Date;
consumedAt: Date | null;
createdAt: Date;
metadataJson: string | null;
};
type CreateAuthTokenInput = {
userId: string;
tenantId: string;
tokenType: AuthTokenType;
ttlMinutes?: number;
createdByUserId?: string | null;
metadata?: unknown;
};
type ResolveResult =
| {
valid: true;
token: TokenResult;
}
| {
valid: false;
reason: "missing" | "expired" | "consumed";
};
function normalizeTokenMetadata(metadata: unknown) {
if (metadata === undefined) {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
function serializePayload(input: HashInput) {
const bytes = new TextEncoder().encode(`${input.token}`);
const payload = new Uint8Array(tokenSecretBytes.length + bytes.length);
payload.set(tokenSecretBytes, 0);
payload.set(bytes, tokenSecretBytes.length);
return payload;
}
function hashToken(token: string) {
if (!token) {
return "";
}
const digest = createHash("sha256")
.update(serializePayload({ token }))
.digest();
return digest.toString("base64url");
}
function newToken() {
return randomBytes(32).toString("base64url");
}
function resolveTokenTypeTTL(type: AuthTokenType) {
if (type === AuthTokenType.INVITE_ACCEPTANCE) {
return INVITE_DEFAULT_TTL_MINUTES;
}
return RESET_DEFAULT_TTL_MINUTES;
}
export function makeResetUrl(rawToken: string) {
const baseUrl = (process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/+$/, "");
return `${baseUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
}
export function makeInviteUrl(rawToken: string) {
const baseUrl = (process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/+$/, "");
return `${baseUrl}/invite/${encodeURIComponent(rawToken)}`;
}
export async function createAuthToken(input: CreateAuthTokenInput) {
const ttl = resolveTokenTypeTTL(input.tokenType);
const rawToken = newToken();
const tokenHash = hashToken(rawToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + ttl * 60 * 1000);
await prisma.authToken.deleteMany({
where: {
userId: input.userId,
tokenType: input.tokenType,
consumedAt: null
}
});
const token = await prisma.authToken.create({
data: {
userId: input.userId,
tenantId: input.tenantId,
tokenType: input.tokenType,
tokenHash,
expiresAt,
createdByUser: input.createdByUserId,
metadataJson: normalizeTokenMetadata(input.metadata)
}
});
return {
rawToken,
tokenId: token.id,
expiresAt: token.expiresAt,
ttlMinutes: ttl
};
}
export async function consumeAuthToken(raw: string, tokenType: AuthTokenType): Promise<ResolveResult> {
if (!raw) {
return { valid: false, reason: "missing" };
}
const tokenHash = hashToken(raw.trim());
const now = new Date();
const token = await prisma.authToken.findFirst({
where: {
tokenType,
tokenHash,
consumedAt: null
},
select: {
id: true,
userId: true,
tenantId: true,
tokenType: true,
tokenHash: true,
expiresAt: true,
consumedAt: true,
createdAt: true,
metadataJson: true
} satisfies Prisma.AuthTokenSelect
});
if (!token) {
return { valid: false, reason: "missing" };
}
if (token.expiresAt <= now) {
return { valid: false, reason: "expired" };
}
if (token.consumedAt) {
return { valid: false, reason: "consumed" };
}
return { valid: true, token };
}
export async function markAuthTokenConsumed(tokenId: string, now: Date) {
await prisma.authToken.update({
where: { id: tokenId },
data: { consumedAt: now }
});
}
export async function revokeUserAuthTokens(userId: string, tokenType: AuthTokenType) {
await prisma.authToken.deleteMany({
where: { userId, tokenType }
});
}