/** * JWT Middleware Plugin * filling.use(jwt({ secret: process.env.JWT_SECRET })) */ import type { ManduContext } from "../filling/context"; export interface JwtMiddlewareOptions { /** JWT 시크릿 키 */ secret: string; /** 허용할 알고리즘 (기본: ["HS256"]) */ algorithms?: ("HS256" | "HS384" | "HS512")[]; /** 토큰 추출 위치 (기본: Authorization 헤더) */ extractFrom?: "header" | "cookie"; /** 쿠키 이름 (extractFrom: "cookie" 일 때) */ cookieName?: string; /** ctx.set()에 저장할 키 (기본: "user") */ storeAs?: string; } /** * JWT 인증 미들웨어 * * @example * ```typescript * import { jwt } from "@mandujs/core/middleware"; * * export default Mandu.filling() * .use(jwt({ secret: process.env.JWT_SECRET! })) * .get((ctx) => { * const user = ctx.get("user"); * return ctx.ok({ user }); * }); * ``` */ export function jwt(options: JwtMiddlewareOptions) { const { secret, algorithms = ["HS256"], extractFrom = "header", cookieName = "token", storeAs = "user" } = options; return async (ctx: ManduContext): Promise => { let token: string | null = null; if (extractFrom === "header") { const auth = ctx.headers.get("Authorization"); if (auth?.startsWith("Bearer ")) { token = auth.slice(7); } } else { token = ctx.cookies.get(cookieName) ?? null; } if (!token) { return ctx.unauthorized("Missing authentication token"); } // 토큰 크기 제한 (메모리 소모 공격 방지) if (token.length > 8192) { return ctx.unauthorized("Token too large"); } try { // Bun native JWT verification const payload = await verifyJwtToken(token, secret, algorithms); ctx.set(storeAs, payload); } catch { return ctx.unauthorized("Invalid or expired token"); } }; } const ALG_MAP: Record = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512", }; /** JWT 검증 (Bun crypto 기반, HS256/HS384/HS512 지원) */ async function verifyJwtToken( token: string, secret: string, allowedAlgorithms: string[] ): Promise> { const parts = token.split("."); if (parts.length !== 3) throw new Error("Invalid JWT format"); const [headerB64, payloadB64, signatureB64] = parts; // 헤더에서 알고리즘 확인 const header = JSON.parse(atob(headerB64.replace(/-/g, "+").replace(/_/g, "/"))); const alg = header.alg as string; if (!allowedAlgorithms.includes(alg)) { throw new Error(`Algorithm "${alg}" not allowed. Allowed: ${allowedAlgorithms.join(", ")}`); } const hash = ALG_MAP[alg]; if (!hash) throw new Error(`Unsupported algorithm: ${alg}`); // 서명 검증 const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash }, false, ["verify"] ); const data = encoder.encode(`${headerB64}.${payloadB64}`); const signature = base64UrlDecode(signatureB64); const valid = await crypto.subtle.verify("HMAC", key, signature as BufferSource, data); if (!valid) throw new Error("Invalid signature"); // 페이로드 디코딩 const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/"))); // 만료 확인 if (payload.exp && payload.exp * 1000 < Date.now()) { throw new Error("Token expired"); } // nbf (not-before) 확인 — 아직 유효하지 않은 토큰 거부 if (payload.nbf && payload.nbf * 1000 > Date.now()) { throw new Error("Token not yet valid"); } return payload; } function base64UrlDecode(str: string): Uint8Array { const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); const padded = base64 + "=".repeat((4 - base64.length % 4) % 4); const binary = atob(padded); return Uint8Array.from(binary, c => c.charCodeAt(0)); }