Membangun auth-service pertama di tokokita: register, login, JWT access token pendek plus refresh token rotating, logout dan blacklist di Redis, hash password dengan Argon2, serta emit event user.registered ke event bus

Setelah di episode 3 fondasi projek tokokita berdiri — monorepo, shared packages, dan environment dev hidup — kini saatnya membangun layanan nyata pertama: auth-service. Ini adalah keputusan sengaja: auth adalah layanan paling fundamental karena hampir semua layanan lain akan bergantung padanya.
Mengapa auth-service sekaligus pelajaran bagus tentang microservices? Karena ia menggabungkan semua pola yang akan berulang di layanan lain: validasi input dengan zod schema dari shared-types, penyimpanan di PostgreSQL, pemanfaatan Redis, dan — yang paling penting — emit event ke Kafka agar layanan lain bereaksi. Pola yang kalian pelajari di sini akan dipakai di hampir setiap episode berikutnya.
auth-service menangani siklus penuh identitas:
Alur token: JWT access token berumur pendek (15 menit) untuk mengakses API, dan refresh token berumur panjang (30 hari) yang disimpan di httpOnly cookie dan di-rotate setiap refresh.
Login → access token (15m, dikirim header) + refresh token (30d, cookie httpOnly)
↓ setelah access habis
refresh → access token baru + refresh token baru (rotate, yang lama di-invalidasi)
logout → blacklist access token di RedisTypeScript + Bun + Elysia
PostgreSQL (users, refresh_tokens)
Redis (blacklist access token, session)
Argon2 (hash password)
Kafka (emit user.registered)Schema users milik eksklusif auth-service — tidak ada layanan lain yang menyentuh tabel ini:
create table users (
id uuid primary key default gen_random_uuid(),
email text unique not null,
password_hash text not null,
name text not null,
created_at timestamptz not null default now()
);Kita gunakan Elysia sebagai framework service. Contoh handler register:
import { Elysia, t } from 'elysia'
import { registerRequestSchema } from '@tokokita/shared-types'
import { db } from '../db'
import { hashPassword } from '../security'
export const register = new Elysia().post(
'/auth/register',
async ({ body, set }) => {
const parsed = registerRequestSchema.safeParse(body)
if (!parsed.success) {
set.status = 422
return { error: parsed.error.issues }
}
const { email, password, name } = parsed.data
const existing = await db.query('select id from users where email = $1', [email])
if (existing.length > 0) {
set.status = 409
return { error: 'email already registered' }
}
const passwordHash = await hashPassword(password)
const [user] = await db.query(
'insert into users (email, password_hash, name) values ($1, $2, $3) returning id, email, name',
[email, passwordHash, name]
)
await publishUserRegistered(user)
set.status = 201
return { user }
},
{ body: t.Object({ email: t.String(), password: t.String(), name: t.String() }) }
)Jangan pernah menyimpan password plaintext. Pilihan default 2026 adalah Argon2id; jika dependency minim, bcrypt dengan cost yang cukup (10-12). Contoh dengan @node-rs/argon2:
import { hash, verify } from '@node-rs/argon2'
const ARGON2_OPTS = { memoryCost: 19456, timeCost: 2, parallelism: 1 }
export function hashPassword(password: string) {
return hash(password, ARGON2_OPTS)
}
export function verifyPassword(hash: string, password: string) {
return verify(hash, password, ARGON2_OPTS)
}Important
Selalu gunakan library standar yang sudah teruji untuk kriptografi — jangan menulis hash atau JWT manual. Alasan kinerja: hashing Argon2 dengan memoryCost tinggi secara sengaja lambat, jadi limit laju login (rate-limit) wajib dipasang di depan endpoint ini — akan dibahas di episode 19.
JWT dipakai untuk access token agar stateless — service lain bisa mem-verifikasi tanpa menyimpan sesi. Access token berisi klaim minimal + jti agar bisa di-blacklist:
import { SignJWT, jwtVerify } from 'jose'
import { randomUUID } from 'crypto'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export async function signAccessToken(userId: string, email: string) {
return await new SignJWT({ email })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime('15m')
.sign(secret)
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret)
return payload
}Refresh token disimpan sebagai cookie httpOnly; secure; sameSite=lax, berisi jti random, dan di-rotate setiap kali dipakai — token lama di-revoke. Logout mem-blacklist jti access token di Redis sampai expiry-nya tercapai:
await redis.set(`blacklist:${jti}`, '1', { ex: 60 * 15 })Setelah user dibuat, auth-service bukan penerima email — itu tanggung jawab notification-service. auth-service cukup mengumumkan fakta:
import { produce } from '../kafka'
export async function publishUserRegistered(user: { id: string; email: string; name: string }) {
const event = {
id: randomUUID(),
type: 'user.registered',
timestamp: new Date().toISOString(),
data: {
id: user.id,
email: user.email,
name: user.name,
},
}
await produce('user-events', [{ key: user.id, value: JSON.stringify(event) }])
}Producer ini akan dikonsumsi notification-service di episode 9 untuk mengirim email selamat datang. Nantinya episode 12 akan memperbaikinya menjadi transactional outbox agar event tidak hilang saat commit database gagal.
Layanan lain memvalidasi token dengan JWT verification lokal — tidak boleh memanggil auth-service untuk tiap request (overhead network + SPOF). Dua pendekatan:
jti di Redis (kecil, ~1 request/hit).api-gateway akan menerapkan verifikasi ini di episode 10, menguatkan prinsip bahwa internal service tidak menerima token user — gateway yang lebih banyak tahu (topik episode 11 dan 19).
Episode 4 membangun auth-service dan tiga pola besar pertama tokokita:
user.registered ke Kafka → notifikasi di episode 9.Di episode 5 selanjutnya, kita akan membangun product-service — CRUD produk lengkap dengan query katalog (filter, sort, pagination, pencarian), koneksi Redis cache, penyimpanan gambar ke object storage, dan konsep stok reserve untuk konsistensi stok. Sampai jumpa di episode 5!