Periksa konfigurasi NextAuth
Periksa konfigurasi NextAuth
Section titled “Periksa konfigurasi NextAuth”proxy.js, basePath, .env, dan dependensi.
Berkas: alat/hari-3/Praktik 10/tools/periksa-konfigurasi.mjs
#!/usr/bin/env node// =====================================================================// Praktik 10 - periksa-konfigurasi.mjs//// Pemeriksa khusus berkas Praktik 10 (NextAuth). Melengkapi audit-api.mjs// dengan hal-hal yang hanya relevan untuk NextAuth dan Next.js.//// Yang diperiksa:// 1. Nama berkas middleware Next.js 16 (proxy.js) dan lokasinya// 2. Awalan basePath aplikasi konsisten antara providers.js, proxy.js,// dan NEXTAUTH_URL// 3. NEXTAUTH_SECRET / NEXTAUTH_URL ada di .env dan panjang secret cukup// 4. accessToken tidak ikut dikembalikan dari callback session// 5. Setiap paket yang diimpor sudah terdaftar di package.json// 6. Paket yang sudah tidak dipakai lagi (mis. "crypto" dari npm)//// Pakai:// node periksa-konfigurasi.mjs "<folder File Latihan>" [--env <path .env>]// =====================================================================
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';import { join, basename, relative } from 'node:path';
const argv = process.argv.slice(2);const idxEnv = argv.indexOf('--env');const jalurEnv = idxEnv > -1 ? argv[idxEnv + 1] : null;// Buang nama opsi beserta nilainya; sisanya adalah argumen posisional.const buang = new Set();if (idxEnv > -1) { buang.add(idxEnv); buang.add(idxEnv + 1); }const posisi = argv.filter((a, i) => !a.startsWith('--') && !buang.has(i));const target = posisi[0] ?? '.';
if (!existsSync(target)) { console.error(`Folder tidak ditemukan: ${target}`); process.exit(2);}
const temuan = [];const catat = (severity, judul, detail) => temuan.push({ severity, judul, detail });const baca = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null);
function cariBerkas(direktori, nama) { const hasil = []; for (const n of readdirSync(direktori)) { const p = join(direktori, n); if (statSync(p).isDirectory()) hasil.push(...cariBerkas(p, nama)); else if (n === nama) hasil.push(p); } return hasil;}
console.log(`Folder : ${target}`);console.log('');
// ------------------------------------------------------------------ 1// Next.js 16 mengganti konvensi berkas `middleware` menjadi `proxy`.// Berkas harus berada di akar proyek, atau di dalam src/ bila proyek// memakai direktori src.const adaProxy = cariBerkas(target, 'proxy.js');const adaMiddleware = cariBerkas(target, 'middleware.js');if (adaProxy.length) { console.log(`1. proxy.js : ada di ${adaProxy.map((p) => relative(target, p)).join(', ')}`); catat('info', 'proxy.js adalah nama yang benar untuk Next.js 16', 'Konvensi lama middleware.js sudah deprecated. Pastikan berkas ini diletakkan di akar proyek atau di dalam src/.');} else if (adaMiddleware.length) { catat('sedang', 'Hanya ada middleware.js, tidak ada proxy.js', 'Next.js 16 memakai konvensi proxy. middleware.js masih jalan tetapi memunculkan peringatan deprecated.');} else { catat('sedang', 'Tidak ada proxy.js maupun middleware.js', 'Proteksi rute /internal yang dimaksud Praktik 10 tidak akan berjalan tanpa berkas ini.');}
// ------------------------------------------------------------------ 2const berkasProxy = adaProxy[0] ?? adaMiddleware[0];const berkasProviders = cariBerkas(target, 'providers.js')[0];const berkasRoute = cariBerkas(target, 'route.js').find((p) => p.includes('auth'));
// Ada DUA basePath yang berbeda dan keduanya benar:// providers.js -> "/portal/api/auth" : letak endpoint NextAuth// proxy.js -> "/portal" : letak halaman aplikasi// Karena itu keduanya tidak dibandingkan satu sama lain. Yang dibandingkan// adalah: apakah basePath aplikasi di proxy.js benar-benar awalan dari// basePath NextAuth di providers.js.let basePathAuth = null; // basePath NextAuthlet basePathApp = null; // basePath aplikasi
if (berkasProviders) { const isi = readFileSync(berkasProviders, 'utf8'); basePathAuth = isi.match(/basePath\s*=\s*["']([^"']+)["']/)?.[1] ?? null; console.log(`2. basePath NextAuth : ${basePathAuth ?? '(tidak ditemukan di providers.js)'}`); if (!basePathAuth) { catat('sedang', 'basePath tidak ditemukan di providers.js', 'Tanpa basePath, aplikasi yang dilayani di /portal akan memanggil /api/auth dan gagal.'); }}
if (berkasProxy) { const isi = readFileSync(berkasProxy, 'utf8'); basePathApp = isi.match(/basePath\s*=\s*["']([^"']+)["']/)?.[1] ?? null; console.log(` basePath aplikasi : ${basePathApp ?? '(tidak ditemukan di proxy.js)'}`);}
if (basePathAuth && basePathApp) { if (!basePathAuth.startsWith(basePathApp)) { catat('tinggi', `basePath NextAuth "${basePathAuth}" tidak berada di bawah basePath aplikasi "${basePathApp}"`, 'Endpoint NextAuth harus dilayani di bawah awalan aplikasi, kalau tidak cookie sesi tidak akan terkirim.'); } else if (basePathAuth !== basePathApp) { // Bukan temuan. Hanya dicatat karena mudah disalahpahami sebagai bug. console.log(' -> dua nilai berbeda dan memang seharusnya begitu'); catat('info', 'Dua basePath berbeda dipakai dan itu wajar', `Endpoint NextAuth di "${basePathAuth}", halaman aplikasi di "${basePathApp}".\n` + 'Perlu diperhatikan: pages.signIn "/login" akan menjadi ' + basePathApp + '/login.\n' + 'Pastikan NEXTAUTH_URL juga memuat ' + basePathApp + ', supaya pengalihan login tidak keluar dari awalan itu.'); }}
if (jalurEnv && existsSync(jalurEnv)) { const env = Object.fromEntries( readFileSync(jalurEnv, 'utf8') .split(/\r?\n/) .filter((b) => b.trim() && !b.trim().startsWith('#') && b.includes('=')) .map((b) => { const i = b.indexOf('='); return [b.slice(0, i).trim(), b.slice(i + 1).trim().replace(/^["']|["']$/g, '')]; }), );
// ------------------------------------------------------------------ 3 for (const wajib of ['NEXTAUTH_SECRET', 'NEXTAUTH_URL']) { if (!env[wajib]) { catat('kritis', `${wajib} kosong di ${jalurEnv}`, wajib === 'NEXTAUTH_SECRET' ? 'Tanpa secret, NextAuth menolak start di produksi.' : 'Tanpa URL, callbackUrl dan cookie secure tidak bisa ditentukan dengan benar.'); } } const secret = env.NEXTAUTH_SECRET ?? ''; if (secret && secret.length < 32) { catat('tinggi', `NEXTAUTH_SECRET hanya ${secret.length} karakter`, 'Modul menyarankan openssl rand -hex 32 (64 karakter). Secret pendek lebih mudah ditebak.'); } if (secret && /^(secret|changeme|rahasia|test|123)/i.test(secret)) { catat('kritis', 'NEXTAUTH_SECRET memakai nilai contoh', 'Ganti dengan hasil openssl rand -hex 32.'); }
const nextauthUrl = env.NEXTAUTH_URL ?? ''; if (nextauthUrl && basePath && !nextauthUrl.includes(basePath)) { catat('sedang', `NEXTAUTH_URL "${nextauthUrl}" tidak memuat basePath "${basePath}"`, 'Aplikasi dilayani di ' + basePath + ', jadi NEXTAUTH_URL seharusnya berakhir dengan ' + basePath + '.'); } console.log(`3. .env : ${Object.keys(env).length} variabel dibaca`);}
// ------------------------------------------------------------------ 4if (berkasRoute) { const isi = readFileSync(berkasRoute, 'utf8');
// accessToken ikut ke session? const blokSession = isi.match(/async\s+session\s*\(\s*\{[^}]*\}\s*\)\s*\{([\s\S]*?)\n\s{8}\}/); if (blokSession && /accessToken/.test(blokSession[1])) { catat('tinggi', 'accessToken dikembalikan dari callback session', 'Nilai dari callback session ikut terkirim ke browser dan bisa dibaca lewat /api/auth/session.\n' + 'Bearer token aplikasi sebaiknya tetap di sisi server.'); }
// verifyAccessToken: dipakai tetapi tidak diimpor? const dipakaiVerify = /verifyAccessToken\s*\(/.test(isi); const imporVerify = /import\s*\{[^}]*\bverifyAccessToken\b[^}]*\}\s*from/.test(isi); if (dipakaiVerify && !imporVerify) { catat('kritis', 'verifyAccessToken dipanggil tetapi tidak pernah diimpor', 'Setiap permintaan sesi akan melempar ReferenceError, tertangkap catch, lalu accessToken dibuat ulang terus-menerus.'); }
// token.user_id diisi? const bacaUserId = /token\.user_id/.test(isi); const tulisUserId = /token\.user_id\s*=/.test(isi); if (bacaUserId && !tulisUserId) { catat('sedang', 'token.user_id dibaca tetapi tidak pernah diisi', 'session.user.user_id akan selalu undefined.'); }
// strategy jwt tanpa maxAge eksplisit? if (/"jwt"|'jwt'/.test(isi) && !/maxAge/.test(isi)) { catat('rendah', 'Sesi JWT tanpa maxAge eksplisit', 'Bawaan NextAuth adalah 30 hari.'); } console.log('4. route.js : diperiksa');}
// ------------------------------------------------------------------ 5 & 6const berkasPackage = cariBerkas(target, 'package.json')[0];if (berkasPackage) { const pkg = JSON.parse(readFileSync(berkasPackage, 'utf8')); const semuaDependensi = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
const diimpor = new Set(); const semuaJs = []; (function jelajah(d) { for (const n of readdirSync(d)) { const p = join(d, n); if (statSync(p).isDirectory()) jelajah(p); else if (/\.(js|mjs|jsx)$/.test(n)) semuaJs.push(p); } })(target);
for (const berkasJs of semuaJs) { const isi = readFileSync(berkasJs, 'utf8'); for (const m of isi.matchAll(/from\s*["']([^"'.][^"']*)["']/g)) { const paket = m[1].startsWith('@') ? m[1].split('/').slice(0, 2).join('/') : m[1].split('/')[0]; diimpor.add(paket); } for (const m of isi.matchAll(/from\s*["']node:([^"']+)["']/g)) diimpor.add('node:' + m[1]); }
const hilang = [...diimpor].filter( (p) => !p.startsWith('node:') && !p.startsWith('.') && p !== 'next' && !(p in semuaDependensi) && p !== 'react' && p !== 'react-dom', ); for (const p of hilang) { catat('tinggi', `Paket "${p}" diimpor tetapi tidak ada di package.json`, 'Build akan gagal dengan "Module not found" di mesin yang bersih.'); }
// Paket yang terdaftar tetapi tidak pernah diimpor // Hanya paket yang jelas-jelas bermasalah yang dilaporkan. Folder yang // diperiksa di sini hanya berisi berkas latihan Praktik 10, bukan seluruh // proyek, jadi "tidak ditemukan pemakaiannya" TIDAK berarti dependensinya // mubazir — komponen peta di bagian lain proyek memang tidak ada di sini. const PERLU_DIBUANG = { crypto: 'Paket "crypto" di npm berstatus deprecated dan isinya hanya meneruskan ke modul bawaan Node.\n' + 'Kode latihan memakai randomUUID dari modul bawaan, jadi baris ini tidak diperlukan.\n' + 'Hapus "crypto" dari dependencies; di kode tulis: import { randomUUID } from "node:crypto".', }; for (const [nama, alasan] of Object.entries(PERLU_DIBUANG)) { if (nama in semuaDependensi) { catat('sedang', `Dependensi "${nama}" sebaiknya dibuang`, alasan); } } const tidakTerpakaiDiSini = Object.keys(semuaDependensi).filter( (n) => !['next', 'react', 'react-dom', 'prisma', '@prisma/client'].includes(n) && !diimpor.has(n), ); if (tidakTerpakaiDiSini.length) { console.log(` (${tidakTerpakaiDiSini.length} dependensi tidak muncul di folder latihan ini: ` + `${tidakTerpakaiDiSini.slice(0, 6).join(', ')}${tidakTerpakaiDiSini.length > 6 ? ', …' : ''})`); console.log(' Itu wajar — folder latihan bukan seluruh proyek, jadi tidak dilaporkan sebagai temuan.'); } console.log(`5. package.json: ${Object.keys(semuaDependensi).length} dependensi diperiksa`);}
// ------------------------------------------------------------------ ringkasanconst WARNA = { kritis: '\u001b[31m', tinggi: '\u001b[31m', sedang: '\u001b[33m', rendah: '\u001b[36m', info: '\u001b[2m' };const URUT = { kritis: 0, tinggi: 1, sedang: 2, rendah: 3, info: 4 };const R = '\u001b[0m';const w = (s, t) => (process.stdout.isTTY ? WARNA[s] + t + R : t);
console.log('');if (temuan.length === 0) { console.log('\u001b[32mTidak ada temuan.\u001b[0m'); process.exit(0);}console.log(`HASIL: ${temuan.length} temuan`);for (const t of [...temuan].sort((a, b) => URUT[a.severity] - URUT[b.severity])) { console.log(`${w(t.severity, '[' + t.severity.toUpperCase() + ']')} ${t.judul}`); if (t.detail) for (const b of String(t.detail).split('\n')) console.log(` ${b}`);}process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);