84 lines
2.2 KiB
TypeScript
84 lines
2.2 KiB
TypeScript
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
|
|
import { randomUUID } from "crypto";
|
|
|
|
const ALGORITHM = "HS256";
|
|
const PURPOSE = "rebreak.cooldown";
|
|
|
|
function getSecret(): Uint8Array {
|
|
const raw =
|
|
process.env.REBREAK_COOLDOWN_SECRET ||
|
|
process.env.NUXT_AUTH_SECRET ||
|
|
// Last-resort fallback — deterministic within a single process lifetime.
|
|
// A proper secret MUST be set via Infisical in production.
|
|
"rebreak-cooldown-insecure-fallback-replace-me";
|
|
return new TextEncoder().encode(raw);
|
|
}
|
|
|
|
export interface CooldownTokenPayload {
|
|
userId: string;
|
|
jti: string;
|
|
cooldownEndsAt: string; // ISO-8601
|
|
}
|
|
|
|
/**
|
|
* Signs a short-lived JWT that the iOS app can present to prove it has
|
|
* permission to disable the DNS protection (cooldown expired on the server).
|
|
*
|
|
* Lifetime: 5 minutes — short enough to prevent replay attacks.
|
|
* The `jti` ties the token to the exact CooldownRequest row.
|
|
*/
|
|
export async function signCooldownToken(
|
|
userId: string,
|
|
jti: string,
|
|
cooldownEndsAt: Date,
|
|
): Promise<string> {
|
|
const secret = getSecret();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
return new SignJWT({
|
|
sub: userId,
|
|
jti,
|
|
purpose: PURPOSE,
|
|
cooldown_ends_at: cooldownEndsAt.toISOString(),
|
|
} satisfies JWTPayload & { purpose: string; cooldown_ends_at: string })
|
|
.setProtectedHeader({ alg: ALGORITHM })
|
|
.setIssuedAt(now)
|
|
.setExpirationTime(now + 5 * 60) // 5 minutes
|
|
.sign(secret);
|
|
}
|
|
|
|
/**
|
|
* Verifies the token and returns its payload or null if invalid/expired.
|
|
*/
|
|
export async function verifyCooldownToken(
|
|
token: string,
|
|
): Promise<CooldownTokenPayload | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, getSecret(), {
|
|
algorithms: [ALGORITHM],
|
|
});
|
|
|
|
if (
|
|
typeof payload.sub !== "string" ||
|
|
typeof payload.jti !== "string" ||
|
|
payload.purpose !== PURPOSE ||
|
|
typeof payload.cooldown_ends_at !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
userId: payload.sub,
|
|
jti: payload.jti,
|
|
cooldownEndsAt: payload.cooldown_ends_at,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Convenience: generate a new JTI (UUID v4). */
|
|
export function generateJti(): string {
|
|
return randomUUID();
|
|
}
|