Auth punya dua pertanyaan. Authentication: siapa kamu? Authorization: kamu boleh apa? Bayangin gudang Toko Buku. Satpam di pintu memeriksa kartu karyawan sekali waktu kamu masuk: itu authentication. Di dalam, rak stok cuma boleh dibuka staf, dan aturan itu dicek tiap kali ada yang menyentuh rak: itu authorization. Kartunya bilang siapa kamu, aturan di tiap rak bilang apa yang boleh kamu lakukan.
🧪 Tebakan cepat: Budi login sebagai pelanggan dan mengirim
{ books { title stock } }. Stok cuma boleh dibaca staf, dan di schemastock: Int!. Apakah Budi tetap dapat judul-judul bukunya? Jawabannya ada di bagian "Aturan per field dan null".
Dua pertanyaan, dua tempat
Spesifikasi GraphQL gak mengatur auth sama sekali. Itu tugas server, dan pembagiannya biasanya begini:
| Pertanyaan | Di mana | Kalau gagal | |
|---|---|---|---|
| Authentication | Siapa kamu? | Sekali per request, di HTTP layer, hasilnya ditaruh di context |
token palsu: request ditolak, status 401 |
| Authorization | Kamu boleh apa? | Di resolver, atau di lapisan bisnis di bawahnya | field itu null, dengan kode UNAUTHENTICATED atau FORBIDDEN |
context adalah argumen ketiga tiap resolver. Isinya dibuat server sekali per request, jadi semua resolver dalam satu request membaca user yang sama.
Authentication: token di header, user di context
Client mengirim token di header HTTP, bukan di dalam query: Authorization: Bearer token-budi. Dengan fetch, header itu masuk ke headers, di samping Content-Type. Server membacanya di fungsi context. Di Apollo Server 4:
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { GraphQLError } from "graphql";
const server = new ApolloServer({ typeDefs, resolvers });
await startStandaloneServer(server, {
// Jalan sekali per request, sebelum resolver mana pun.
context: async ({ req }) => {
const token = req.headers.authorization?.replace("Bearer ", "") ?? "";
if (token === "") return { user: null }; // tamu: katalog tetap boleh dibaca
const user = await findUserByToken(token);
if (!user) {
throw new GraphQLError("Invalid token", {
extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
});
}
return { user };
},
});
req.headers.authorizationberisi"Bearer token-budi". KataBearerdibuang, sisanya token.- Tanpa token,
userbernilainull. Field publik seperti judul buku tetap jalan. - Token yang gak dikenal menolak seluruh request dengan status 401, sebelum satu resolver pun jalan.
http: { status: 401 }adalah cara Apollo Server memilih status HTTP dari sebuah error. - Fungsi ini membuat object baru tiap request. Jangan simpan user di variabel global, nanti request orang lain ikut memakainya.
Untuk pelajaran ini, Toko Buku punya dua akun, dan field baru viewer mengembalikan user yang sedang login:
type Query {
# ...field lama tetap ada
viewer: User
}
type User {
id: ID!
name: String!
role: Role!
}
enum Role {
CUSTOMER
STAFF
}
Header Authorization |
context.user |
Response { viewer { name role } } |
|---|---|---|
| (gak ada) | null |
{ "data": { "viewer": null } } |
Bearer token-budi |
Budi, CUSTOMER |
{ "data": { "viewer": { "name": "Budi", "role": "CUSTOMER" } } } |
Bearer token-sari |
Sari, STAFF |
{ "data": { "viewer": { "name": "Sari", "role": "STAFF" } } } |
Bearer token-palsu |
ditolak | status 401, cuma errors dengan kode UNAUTHENTICATED |
Authorization: satu aturan di resolver
Aturannya: stok dan semua mutation cuma untuk STAFF. Tulis aturannya sekali, lalu panggil di tiap resolver yang butuh:
import { GraphQLError } from "graphql";
// Satu aturan, dipakai semua resolver yang membutuhkannya.
const authorize = (user) => {
if (!user) throw new GraphQLError("Login required", { extensions: { code: "UNAUTHENTICATED" } });
if (user.role !== "STAFF") throw new GraphQLError("Staff only", { extensions: { code: "FORBIDDEN" } });
};
const resolvers = {
Query: {
viewer: (_, __, context) => context.user,
},
Mutation: {
updateStock: (_, { id, stock }, context) => {
authorize(context.user); // gagal di sini: stok gak berubah
// ...lalu ubah stok seperti di pelajaran "Mutations"
},
},
Book: {
stock: (book, _, context) => {
authorize(context.user);
return book.stock;
},
},
};
context.userdatang dari fungsicontexttadi. Resolver gak membaca header sendiri.UNAUTHENTICATEDberarti belum login.FORBIDDENberarti sudah login, tapi gak boleh. Kedua kode ini dicontohkan dokumentasi Apollo Server, seperti di pelajaran "Error Handling".Book.stockadalah aturan per field: semua orang boleh membacatitle, cuma staf yang boleh membacastock.
Aturan per field dan null
Ini response untuk Budi di tebakan, dengan stock: Int!:
{
"errors": [
{
"message": "Staff only",
"locations": [{ "line": 4, "column": 5 }],
"path": ["books", 0, "stock"],
"extensions": { "code": "FORBIDDEN" }
}
],
"data": null
}
Budi gak dapat apa-apa, judulnya pun gak. Itu jawaban tebakannya. stock ditolak dan jadi null, padahal Int! gak boleh null. Null-nya naik ke buku pertama, lalu ke list [Book!]!, lalu ke data. Satu aturan menghapus seluruh response.
Field yang bisa ditolak sebaiknya nullable. Untuk pelajaran ini Book.stock di Toko Buku diubah jadi stock: Int. Request Budi yang sama sekarang memberi:
{
"data": {
"books": [
{ "title": "Laskar Pelangi", "stock": null },
{ "title": "Bumi Manusia", "stock": null },
{ "title": "Cantik Itu Luka", "stock": null }
]
}
}
ditambah tiga error FORBIDDEN di errors, dengan path ["books", 0, "stock"], ["books", 1, "stock"], dan ["books", 2, "stock"]. Judulnya sampai, stoknya ditolak satu per satu. Sari, dengan token staf, mendapat stok 12, 0, dan 5 tanpa error.
Taruh aturan di satu tempat
Field status dihitung dari stok: SOLD_OUT kalau stoknya 0. Tamu yang meminta { books { title status } } tetap tahu Bumi Manusia habis, walaupun stock ditolak. Boleh atau gaknya itu keputusan bisnis: mungkin toko memang ingin pembeli tahu. Tapi kalau aturannya "stok itu rahasia", aturan yang cuma ada di resolver Book.stock gampang bocor lewat field lain.
Karena itu dokumentasi graphql.org menyarankan aturan otorisasi ditaruh di lapisan bisnis: satu fungsi, seperti authorize di atas, yang dipanggil semua jalur yang menyentuh data itu. Resolver GraphQL, endpoint REST lama, dan job di belakang memakai aturan yang sama.
Directive @auth: sebuah pola, bukan bawaan
GraphQL cuma punya beberapa directive bawaan, seperti @include, @skip, dan @deprecated. @auth bukan salah satunya. Kamu bisa membuatnya sendiri supaya aturannya terbaca di schema:
directive @auth on FIELD_DEFINITION
type Book {
# ...field lain
stock: Int @auth
}
Directive ini cuma tanda. Yang menjalankannya adalah kode yang membungkus resolver tiap field bertanda, misalnya dengan @graphql-tools/utils:
import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";
import { defaultFieldResolver } from "graphql";
const withAuth = (schema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
if (!getDirective(schema, fieldConfig, "auth")?.[0]) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: (source, args, context, info) => {
authorize(context.user); // aturan yang sama
return resolve(source, args, context, info);
},
};
},
});
getDirective mencari @auth di tiap field, lalu resolver field bertanda dibungkus: authorize dulu, baru resolver aslinya. Contoh lama dengan SchemaDirectiveVisitor sudah dihapus sejak graphql-tools versi 8.
Ide yang sama bisa juga lewat middleware yang membungkus resolver, misalnya library graphql-shield. Ujungnya tetap satu aturan yang jalan sebelum resolver field itu. Lab di halaman ini memanggil authorize langsung di resolver, karena mesin GraphQL mini-nya belum menjalankan directive di schema.
📌 Introspection dan query yang terlalu dalam
- Introspection adalah query
__schemayang mengembalikan seluruh schema. Apollo Server mematikannya secara default kalauNODE_ENVbernilaiproduction. Menyembunyikan schema bukan pengganti authorization: penyerang tetap bisa menebak nama field, jadi tiap field rahasia tetap butuh aturannya. API publik seperti milik GitHub malah sengaja membiarkannya aktif. - Query yang terlalu dalam, seperti
book { author { books { author { books { title } } } } }, bisa memaksa server bekerja berlipat-lipat, bahkan dari user yang sah. Batas kedalaman (depth limit) dan batas biaya (complexity) dibahas di pelajaran "Performance & Caching".
⚠️ Jebakan yang sering ketemu
- User disimpan di variabel global. Request lain ikut memakainya. Taruh di
context, yang dibuat ulang tiap request. - Field rahasia bertype non-null. Satu
FORBIDDENmenghapus seluruhdata. Jadikan nullable. - Mengandalkan client menyembunyikan field. Siapa pun bisa mengirim query apa pun langsung ke server. Aturannya wajib ada di server.
- Aturan cuma di satu resolver. Field turunan seperti
status, atau mutation yang terlupa, membuka jalan lain. Satu fungsi aturan, dipanggil semua jalur.
TL;DR: Authentication (siapa kamu?) jalan sekali per request: server membaca Authorization: Bearer <token> di fungsi context dan menaruh user di sana, null untuk tamu, dan request dengan token palsu ditolak dengan status 401. Authorization (boleh apa?) jalan di resolver lewat satu fungsi seperti authorize, dengan kode UNAUTHENTICATED (belum login) atau FORBIDDEN (gak boleh); di Toko Buku, stock dan mutation cuma untuk STAFF. Field yang bisa ditolak sebaiknya nullable, supaya sisa response tetap sampai. Aturan sebaiknya tinggal di lapisan bisnis supaya gak bocor lewat field lain. @auth adalah directive buatan sendiri yang membungkus resolver (misalnya dengan mapSchema), bukan bawaan GraphQL. Introspection biasanya dimatikan di production, tapi itu bukan pengganti authorization.