201 lines
4.8 KiB
TypeScript
201 lines
4.8 KiB
TypeScript
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 }
|
|
});
|
|
}
|