SDK Keycloak cho Next.js
SDK TypeScript gói flow đăng nhập OIDC của Keycloak cho Next.js: PKCE viết tay bằng Web Crypto, tự refresh token, kèm một web demo dùng chính nó.
- TypeScript
- Keycloak
- Next.js
- OIDC
- tsup
Ba repo cho cùng một bài toán: một server Keycloak tự dựng, một SDK TypeScript gói flow đăng nhập OIDC, và một web Next.js dùng chính SDK đó làm vật thử.
Vì sao tự viết thay vì dùng keycloak-js#
Không có commit nào ghi lại một lý do kỹ thuật, nên tôi nói thẳng: đây là bài tập để hiểu
OIDC từ bên trong. Dấu vết còn lại trong repo khá rõ — keycloak-js vẫn nằm trong
dependencies của web demo nhưng không file nào import nó; mọi lời gọi đều đi qua
package nội bộ được link bằng file:../sdk. Cái được là sau khi tự viết, tôi biết chính
xác từng tham số trong flow Authorization Code + PKCE dùng để làm gì, trong khi NextAuth
gói gần hết phần đó lại sau một provider có sẵn.
Server Keycloak tự dựng#
Repo server là một bản phân phối Keycloak 26.4.0 chạy trên Quarkus, khởi động bằng
bin/kc.sh start --optimized. Cấu hình dùng hostname v2 với hostname-strict=false để
local vẫn chạy được, và proxy-headers=xforwarded vì nó đứng sau reverse proxy; health và
metrics đều bật. Thư mục providers/ có ba jar SPI bên ngoài: 2FA qua email, xác minh
email bằng mã, và ghi nhớ thiết bị tin cậy. Database vẫn là H2 mặc định — đủ cho môi
trường học, không phải thứ mang lên production.
PKCE viết tay và một bề mặt API cố tình nhỏ#
Đây là public client: trình duyệt không giữ client secret nào, nên PKCE không phải tuỳ
chọn. SDK sinh code_verifier 32 byte ngẫu nhiên, băm SHA-256 bằng Web Crypto rồi gửi
kèm bản base64url của digest.
/* toBase64Url: btoa(...) rồi đổi "+/" thành "-_" và bỏ "=" — file gốc viết thẳng ra. */
async function generateCodeChallenge() {
const codeVerifier = toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
return { codeVerifier, codeChallenge: toBase64Url(new Uint8Array(digest)) };
}
export async function login(config: KeycloakConfig) {
const { codeVerifier, codeChallenge } = await generateCodeChallenge();
/* verifier phải sống qua lần rời trang sang Keycloak rồi quay lại callback. */
LocalStorageService.setCodeVerifier(codeVerifier);
window.location.href =
`${getKeycloakUrl(config)}/auth?client_id=${encodeURIComponent(config.clientId)}` +
`&redirect_uri=${encodeURIComponent(config.redirectUri)}` +
`&response_type=code&scope=openid profile email` +
`&code_challenge=${codeChallenge}&code_challenge_method=S256`;
}Toàn bộ thứ SDK phơi ra chỉ có login, logout, exchangeCodeForToken, refreshTokens,
getUserInfo, isTokenValid, startAutoRefresh, cộng cặp AuthProvider / useAuth và
kiểu KeycloakConfig vỏn vẹn bốn trường. tsup build từ một entry duy nhất ra cả ESM lẫn
CJS kèm .d.ts, nên app Next.js import kiểu nào cũng chạy.
Token nằm trong localStorage#
Đổi code lấy token xong, SDK cất cả gói vào localStorage kèm expires_at tự tính từ
expires_in. startAutoRefresh đặt một setInterval 60 giây: mỗi nhịp kiểm tra hạn, hết
hạn thì gọi refresh, refresh hỏng thì logout luôn bằng id_token_hint.
Middleware chỉ chặn được ở mức điều hướng#
Middleware chạy trước khi React kịp render và không nhìn thấy localStorage, nên trang callback phải chép trạng thái đăng nhập sang cookie cho nó đọc.
const publicPaths = ['/', '/login', '/register', '/api/auth', '/auth/callback', '/dashboard'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (publicPaths.some((path) => pathname.startsWith(path))) return NextResponse.next();
/* Chạy ở edge, không đọc được localStorage: chỉ còn cookie để mà nhìn. */
const kcSession =
request.cookies.get('kc_session')?.value || request.cookies.get('kc_tokens')?.value;
if (!kcSession) return NextResponse.redirect(new URL('/login', request.url));
return NextResponse.next();
}Đây là chỗ cái giá của quyết định phía trên hiện ra rõ nhất: middleware chỉ hỏi cookie có tồn tại hay không, không xác minh chữ ký, không hỏi lại Keycloak. Ai mở devtools cũng tự đặt được cookie đó và đi qua. Nó là trải nghiệm điều hướng — đừng để người dùng thấy một trang trống rồi mới bị đá về login — chứ không phải security boundary; ranh giới thật phải nằm ở phía API, nơi access token được kiểm thật sự. Web demo dùng đúng cơ chế cookie đó cho hai route cần ví: thiếu flag là bị đưa về dashboard.
Kết quả#
- Flow chạy đủ vòng: login → callback → đổi code lấy token → userinfo → tự refresh →
logout kèm
id_token_hint - SDK build ra ESM + CJS + type, cài vào web demo như một package thật qua
file:../sdk keycloak-jsvẫn nằm trong dependencies dù không còn được import — dấu vết của lần đổi hướng, và là thứ nên dọn- Điểm yếu tôi không giấu: token nằm ở localStorage, middleware thì tin vào một cookie không ký. Muốn dùng thật, phải kéo token về phía server trước đã
- Đường đi của token còn rải
console.log— tiện lúc học, nhưng phải bỏ sạch trước khi gọi nó là thư viện dùng chung