feat: add GitHub OAuth2 login and protect product mutations with JWT

- add /auth/login and /auth/callback (rewritten to pages/api/auth/*) for the
  GitHub Authorization Code flow, with a state cookie against login CSRF
- issue a 1h JWT after the code exchange and verify it (HS256) in the Apollo
  context; createProduct, updateProduct and deleteProduct require it
- set products.created_by from the logged-in GitHub user (upsert into users)
- preset the Authorization header in the embedded Sandbox
- add jsonwebtoken, drop unused cors, rename package to week-7
This commit is contained in:
MelvynFaith
2026-09-30 17:29:52 +07:00
parent d9456638d7
commit 4fe9ff1d7e
7 changed files with 250 additions and 13 deletions
+57
View File
@@ -0,0 +1,57 @@
const jwt = require('jsonwebtoken');
// Langkah 3-5 OAuth2: tukar code dengan access token, ambil profil, terbitkan JWT sendiri.
module.exports = async function handler(req, res) {
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, JWT_SECRET } = process.env;
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET || !JWT_SECRET) {
res.status(500).json({ error: 'GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / JWT_SECRET belum diatur di environment' });
return;
}
const { code, state } = req.query;
// state sekali pakai: hapus cookie apa pun hasilnya.
const expectedState = req.cookies.oauth_state;
res.setHeader('Set-Cookie', 'oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0');
if (!code) {
res.status(400).json({ error: 'Parameter code tidak ada' });
return;
}
if (!state || !expectedState || state !== expectedState) {
res.status(400).json({ error: 'State tidak valid, ulangi login lewat /auth/login' });
return;
}
try {
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token, error_description } = await tokenRes.json();
if (!access_token) {
res.status(401).json({ error: error_description || 'Gagal menukar code dengan access token' });
return;
}
const userRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}`, Accept: 'application/json' },
});
if (!userRes.ok) {
res.status(502).json({ error: 'Gagal mengambil profil dari GitHub' });
return;
}
const githubUser = await userRes.json();
const token = jwt.sign({ username: githubUser.login }, JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
} catch (err) {
console.error('[OAuth error] /auth/callback:', err);
res.status(500).json({ error: 'Terjadi kesalahan pada server, silakan coba lagi' });
}
};
+32
View File
@@ -0,0 +1,32 @@
const crypto = require('crypto');
// Langkah 1 OAuth2: arahkan pengguna ke halaman login GitHub.
module.exports = function handler(req, res) {
const { GITHUB_CLIENT_ID, CALLBACK_URL } = process.env;
if (!GITHUB_CLIENT_ID || !CALLBACK_URL) {
res.status(500).json({ error: 'GITHUB_CLIENT_ID / CALLBACK_URL belum diatur di environment' });
return;
}
// Cookie state hanya terkirim ke host yang sama dengan CALLBACK_URL, jadi login harus dimulai di host itu.
const callbackUrl = new URL(CALLBACK_URL);
if (req.headers.host !== callbackUrl.host) {
res.redirect(`${callbackUrl.origin}/auth/login`);
return;
}
// state acak disimpan di cookie lalu dicocokkan di /auth/callback (anti login-CSRF).
const state = crypto.randomBytes(16).toString('hex');
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
res.setHeader(
'Set-Cookie',
`oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}`
);
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: CALLBACK_URL,
state,
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
};
+44 -6
View File
@@ -1,6 +1,7 @@
const { ApolloServer } = require('@apollo/server');
const { startServerAndCreateNextHandler } = require('@as-integrations/next');
const { GraphQLError } = require('graphql');
const jwt = require('jsonwebtoken');
const { Pool } = require('pg');
// 1. Inisialisasi Pool PostgreSQL untuk Serverless
@@ -114,6 +115,37 @@ function validatePrice(price) {
return price;
}
// Ambil user dari header "Authorization: Bearer <jwt>". Token hilang/salah/kedaluwarsa -> null.
function getUserFromRequest(req) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
try {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch {
return null;
}
}
// Dipakai di resolver mutation: tolak request tanpa JWT valid.
function requireAuth(contextValue) {
if (!contextValue.user) {
throw new GraphQLError('Unauthorized: silakan login terlebih dahulu', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
}
// Cari (atau buat) baris users untuk pengguna GitHub, supaya products.created_by bisa terisi.
// users.email wajib unik & NOT NULL, sedangkan email GitHub bisa privat -> pakai alamat noreply.
async function getOrCreateUserId(username) {
const result = await pool.query(
`INSERT INTO users (username, email) VALUES ($1, $2)
ON CONFLICT (username) DO UPDATE SET username = EXCLUDED.username
RETURNING id`,
[username, `${username}@users.noreply.github.com`]
);
return result.rows[0].id;
}
// Bungkus error tak terduga dari DB supaya tidak bocor ke client,
// tapi biarkan GraphQLError yang sudah kita buat sendiri lewat apa adanya.
function handleUnexpectedError(err, context) {
@@ -180,7 +212,8 @@ const resolvers = {
},
Mutation: {
createProduct: async (_, { input }) => {
createProduct: async (_, { input }, contextValue) => {
requireAuth(contextValue);
try {
const title = validateTitle(input.title);
const price = validatePrice(input.price);
@@ -194,9 +227,11 @@ const resolvers = {
});
}
const userId = await getOrCreateUserId(contextValue.user.username);
const result = await pool.query(
'INSERT INTO products (title, price, category_id) VALUES ($1, $2, $3) RETURNING id, title, price::float, category_id, created_by',
[title, price, categoryId]
'INSERT INTO products (title, price, category_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id, title, price::float, category_id, created_by',
[title, price, categoryId, userId]
);
return result.rows[0];
} catch (err) {
@@ -210,7 +245,8 @@ const resolvers = {
}
},
updateProduct: async (_, { id, input }) => {
updateProduct: async (_, { id, input }, contextValue) => {
requireAuth(contextValue);
try {
const productId = parsePositiveIntId(id, 'id');
@@ -246,7 +282,8 @@ const resolvers = {
}
},
deleteProduct: async (_, { id }) => {
deleteProduct: async (_, { id }, contextValue) => {
requireAuth(contextValue);
try {
const productId = parsePositiveIntId(id, 'id');
const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING id', [productId]);
@@ -304,7 +341,8 @@ const server = new ApolloServer({
// 6. Inisialisasi Handler dengan Context Per-Request
const apolloHandler = startServerAndCreateNextHandler(server, {
context: async () => ({
context: async (req) => ({
user: getUserFromRequest(req),
metrics: {
categoryProductsCalls: 0,
callLogs: [],
+3 -1
View File
@@ -15,6 +15,8 @@ function Home() {
target: '#embedded-sandbox',
initialEndpoint: `${window.location.origin}/api/graphql`,
initialState: {
// Ganti <paste-token-here> dengan JWT dari /auth/login supaya mutation diizinkan.
headers: { Authorization: 'Bearer <paste-token-here>' },
document: `query TestCategoriesWithProducts {
categories {
id
@@ -44,7 +46,7 @@ function Home() {
React.createElement(
Head,
null,
React.createElement('title', null, 'GraphQL Sandbox - Lab 04')
React.createElement('title', null, 'GraphQL Sandbox')
),
React.createElement('div', {
id: 'embedded-sandbox',