Cơn ác mộng session lúc 2 giờ sáng
2:15 sáng, chuông PagerDuty reo liên hồi. Hơn 300 nhân sự ca đêm đồng loạt đăng nhập vào hệ thống ERP và kẹt cứng. Người dùng vừa bấm chuyển từ portal chính sang dashboard phân tích thì văng ngược về trang login. Vòng lặp redirect xảy ra liên tục. Token 5 phút hết hạn nhưng client không tự làm mới. Cùng lúc đó, database user chạm ngưỡng 100 connection pool vì hơn 10 microservice cùng dồn dập truy vấn xác thực.
Trước đây, mỗi team tự dựng một module auth riêng. Nhóm làm bằng JWT tự sinh, nhóm lại dùng session cookie trên Redis. Khi hệ thống phình to, việc duy trì cấu trúc chắp vá này tiêu tốn hàng giờ debug mỗi tuần. Chuyển sang Single Sign-On (SSO) là giải pháp bắt buộc để dọn sạch mớ hỗn độn này.
Thời điểm đó, team mình nhận nhiệm vụ bóc tách toàn bộ phần auth cũ khỏi 50.000 dòng code. Một bài học nhớ đời: với tầng authentication, bạn phải viết test coverage thật kỹ trước khi đụng vào code. Chỉ cần lệch múi giờ 3 giây giữa hai server hay gõ sai một claim trong payload, toàn bộ user sẽ bị văng khỏi hệ thống ngay lập tức.
Hiểu đúng luồng OIDC giữa Keycloak và Next.js
Muốn debug nhanh, bạn cần nắm chắc cơ chế hoạt động của OpenID Connect (OIDC) — tầng định danh chạy phía trên giao thức OAuth 2.0.
1. Identity Provider (IdP) và Relying Party (RP)
Mô hình này gồm hai nhân vật chính:
- Keycloak đóng vai trò IdP (Identity Provider): Nơi nắm giữ user, cấp phát token, xử lý xác thực hai yếu tố (2FA) và quản lý phân quyền (Realm Roles).
- Next.js đóng vai trò RP (Relying Party): Ứng dụng không lưu password hay tự hash mật khẩu. Next.js ủy quyền hoàn toàn khâu đăng nhập cho Keycloak và chỉ tin vào chữ ký số của token trả về.
2. Luồng Authorization Code Flow kèm PKCE
Quá trình trao đổi diễn ra qua 5 bước:
- Người dùng vào route cần bảo vệ trên Next.js (ví dụ
/dashboard). - Next.js kiểm tra session. Nếu chưa có, app chuyển hướng trình duyệt sang Keycloak kèm
client_id,redirect_urivà mã băm PKCE. - Người dùng điền user/password tại màn hình Keycloak.
- Keycloak xác thực xong liền redirect ngược về Next.js kèm theo một chuỗi
authorization_codedùng một lần. - Server Next.js âm thầm gửi code này lên Keycloak qua kênh backend-to-backend. Keycloak kiểm tra hợp lệ rồi trả về cặp ID Token và Access Token.
Các bước tích hợp chi tiết từ hạ tầng đến mã nguồn
Bước 1: Chạy Keycloak nội bộ bằng Docker Compose
Chúng ta dựng Keycloak cùng database PostgreSQL để mô phỏng sát nhất môi trường staging:
# docker-compose.yml
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak_user
POSTGRES_PASSWORD: super_secure_db_pass
volumes:
- pgdata:/var/lib/postgresql/data
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak_user
KC_DB_PASSWORD: super_secure_db_pass
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin_password_here
ports:
- "8080:8080"
depends_on:
- postgres
volumes:
pgdata:
Chạy lệnh sau để khởi động container:
docker compose up -d
Lưu ý: Flag start-dev chỉ dùng cho môi trường local/dev. Khi deploy production, bạn cần dùng lệnh start --optimized kèm chứng chỉ SSL hợp lệ.
Bước 2: Cấu hình Realm và Client trên Keycloak Console
Mở trình duyệt vào http://localhost:8080, đăng nhập tài khoản admin và cấu hình các mục sau:
- Realm: Tạo realm mới tên
internal-corp. Không nên dùng trực tiếp realmmastercho ứng dụng nội bộ. - Client: Tạo client ID là
nextjs-portal. - Client Authentication: Gạt sang On (confidential client). Next.js chạy Node.js ở server nên có thể giữ secret an toàn.
- Valid Redirect URIs: Điền
http://localhost:3000/api/auth/callback/keycloak. - Web Origins: Điền
http://localhost:3000. - Credentials: Chuyển sang tab Credentials của client để copy chuỗi Client Secret.
- Tạo User test: Vào menu Users, tạo tài khoản
testuser, qua tab Credentials đặt password cố định (tắt tùy chọn Temporary).
Bước 3: Cài đặt và cấu hình NextAuth trong Next.js App Router
Cài đặt thư viện xác thực:
npm install next-auth
Thêm các biến cấu hình vào file .env.local:
KEYCLOAK_ID=nextjs-portal
KEYCLOAK_SECRET=chuoi_secret_lay_tu_buoc_2
KEYCLOAK_ISSUER=http://localhost:8080/realms/internal-corp
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=sinh_chuoi_32_ky_tu_bang_openssl_rand_hex_32
Tạo file handler tại đường dẫn app/api/auth/[...nextauth]/route.ts. Đoạn code dưới đây xử lý cả việc giải mã role và lưu token:
import NextAuth, { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_ID!,
clientSecret: process.env.KEYCLOAK_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
// Lưu access_token, refresh_token và tính thời gian hết hạn (ms)
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = (account.expires_at ?? 0) * 1000;
}
if (profile) {
token.roles = (profile as any).realm_access?.roles || [];
}
return token;
},
async session({ session, token }) {
(session as any).accessToken = token.accessToken;
(session as any).roles = token.roles;
(session as any).error = token.error;
return session;
},
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Bước 4: Kiểm soát phân quyền bằng Middleware
Đừng để lập trình viên phải nhớ kiểm tra quyền trên từng page component. Hãy chặn truy cập trái phép ngay từ lớp ngoài cùng với middleware.ts đặt ở thư mục gốc:
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const pathname = req.nextUrl.pathname;
// Nếu vào route admin mà thiếu role 'admin' -> chuyển sang trang từ chối
if (pathname.startsWith("/admin") && !token?.roles?.includes("admin")) {
return NextResponse.rewrite(new URL("/unauthorized", req.url));
}
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
Bước 5: Tự động gia hạn Access Token ngầm (Silent Refresh)
Mặc định Keycloak đặt vòng đời Access Token chỉ 5 phút (300 giây). Nếu người dùng đang soạn dở một biểu mẫu mà token hết hạn, request kế tiếp lên backend sẽ dính lỗi 401 Unauthorized.
Để khắc phục, chúng ta viết thêm hàm refresh token và gắn vào callback jwt trong file route.ts:
async function refreshAccessToken(token: any) {
try {
const url = `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_ID!,
client_secret: process.env.KEYCLOAK_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
}),
});
const refreshedTokens = await response.json();
if (!response.ok) throw refreshedTokens;
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
};
} catch (error) {
return { ...token, error: "RefreshAccessTokenError" };
}
}
// Trong callbacks của NextAuthOptions:
async jwt({ token, account, profile }) {
if (account && profile) {
return {
accessToken: account.access_token,
accessTokenExpires: (account.expires_at ?? 0) * 1000,
refreshToken: account.refresh_token,
roles: (profile as any).realm_access?.roles || [],
};
}
// Trả về token cũ nếu vẫn còn hạn (trừ hao trước 60 giây)
if (Date.now() < (token.accessTokenExpires as number) - 60000) {
return token;
}
// Token sắp hoặc đã hết hạn, tự gọi hàm cấp mới
return refreshAccessToken(token);
}
Ba kinh nghiệm vận hành thực tế
Hệ thống SSO là điểm nghẽn chịu lực cao nhất. SSO mà sập, toàn bộ web app con đều ‘đứng hình’. Dưới đây là 3 điểm mấu chốt bạn cần lưu ý khi đưa hệ thống lên production:
- Đồng bộ giờ hệ thống bằng NTP: Lệch đồng hồ giữa server Keycloak và server Next.js dù chỉ 2 giây cũng sinh lỗi
token used before issued. Luôn bật chrony hoặc systemd-timesyncd trên mọi máy chủ. - Bảo mật Client Secret tuyệt đối: Không bao giờ commit secret lên git hoặc để lọt vào code client-side React. Mọi thao tác trao đổi code lấy token phải chạy qua Route Handler phía backend.
- Kiểm thử tự động cho luồng auth: Viết sẵn kịch bản E2E bằng Playwright hoặc Cypress kiểm tra đủ 3 case: đăng nhập lần đầu, tự refresh token khi hết hạn, và tự động kick user khi admin thu hồi quyền (session revocation).
