Authentication & Authorization — GraphQL

Auth di GraphQL Authentication (siapa kamu?) biasanya di transport layer (HTTP header). Authorization (boleh akses apa?) di resolver atau directive level…

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 schema stock: 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 };
  },
});

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;
    },
  },
};

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

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari