Validação eager do secret no top-level do módulo causava falha no build Docker porque a env var não existe em build-time. Movido para função getJwtSecret() chamada em runtime. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
6.1 KiB
TypeScript
264 lines
6.1 KiB
TypeScript
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();
|
|
}
|
|
|
|
/**
|
|
* Generate a random API token with prefix
|
|
*/
|
|
export function generateApiToken(): string {
|
|
const randomPart = crypto.randomBytes(32).toString("base64url");
|
|
return `os_${randomPart}`;
|
|
}
|
|
|
|
/**
|
|
* Hash a token using SHA-256
|
|
*/
|
|
export function hashToken(token: string): string {
|
|
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
|
|
*/
|
|
export function extractBearerToken(authHeader: string | null): string | null {
|
|
if (!authHeader) return null;
|
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Validate an API token and return the payload
|
|
* @deprecated Use validateHashToken for os_xxx tokens
|
|
*/
|
|
export function validateApiToken(token: string): JwtPayload | null {
|
|
const payload = verifyJwt(token);
|
|
if (!payload || payload.type !== "api_access") {
|
|
return null;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Validate a hash-based API token (os_xxx format)
|
|
* Returns the token hash for database lookup
|
|
*/
|
|
export function validateHashToken(token: string): {
|
|
valid: boolean;
|
|
tokenHash?: string;
|
|
} {
|
|
if (!token || !token.startsWith("os_")) {
|
|
return { valid: false };
|
|
}
|
|
return { valid: true, tokenHash: hashToken(token) };
|
|
}
|