Lewati ke konten

Periksa konfigurasi NextAuth

proxy.js, basePath, .env, dan dependensi.

Berkas: alat/hari-3/Praktik 10/tools/periksa-konfigurasi.mjs

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.');
}
// ------------------------------------------------------------------ 2
const 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 NextAuth
let 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`);
}
// ------------------------------------------------------------------ 4
if (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 & 6
const 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`);
}
// ------------------------------------------------------------------ ringkasan
const 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);