chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
200
lib/auth-tokens.ts
Normal file
200
lib/auth-tokens.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user