Khi có vài side project cùng cần đăng nhập, gom chúng về một Keycloak tự host đỡ hơn nhiều so với để mỗi app tự làm đăng ký, quên mật khẩu, xác minh email. Bài này đi qua cách chia realm, ba kiểu app nối vào Keycloak, và những cái bẫy chỉ lộ ra khi chạy thật: kiểm aud, user đã xoá tự "sống lại", refresh token chạy đua trong React StrictMode, một bean Spring vô tình khiến API phụ thuộc Keycloak lúc khởi động, CSS theme cũ, và health check báo khoẻ quá sớm.
Một server, mỗi app một realm
Theo tài liệu Keycloak, "các realm tách biệt với nhau và chỉ quản lý, xác thực user của chính nó". Cách mình chọn là mỗi app một realm.
Đánh đổi: với cách chia này, Keycloak là hạ tầng đăng nhập dùng chung, không phải "đăng nhập một lần cho mọi app". Phiên đăng nhập thuộc về realm, nên đăng nhập app này không làm bạn đăng nhập sẵn vào app kia; muốn có single sign-on thật thì các app phải chung realm.
Với những side project không liên quan nhau, tách realm là hợp lý:
- Mỗi app có trang đăng nhập mang giao diện riêng, chính sách mật khẩu và thời hạn token riêng.
- Một user không vô tình có tài khoản ở app mà họ chưa từng đăng ký.
- Khi một app cần quyền quản trị user (xem phần xoá tài khoản dưới), quyền đó chỉ chạm được user trong realm của nó.
App nối vào Keycloak theo ba kiểu hay gặp, mỗi kiểu có chỗ dễ sai riêng.
Kiểu 1: SPA dùng PKCE, API chỉ kiểm JWT
Frontend trong trình duyệt là public client: nó không giữ được bí mật, nên dùng Authorization Code kèm PKCE, điều mà RFC 9700 (thực hành bảo mật hiện hành cho OAuth 2.0) yêu cầu bắt buộc với public client. Thư viện như oidc-client-ts lo chuyển hướng và gia hạn access token trước khi hết hạn.
API không gọi Keycloak cho mỗi request: nó kiểm chữ ký của access token bằng bộ khoá công khai (JWKS) của realm, cộng với iss, aud và exp. Với Node, thư viện jose gói gọn việc này, kèm cache khoá:
const jwks = createRemoteJWKSet(new URL(config.OIDC_JWKS_URL));
const { payload } = await jwtVerify(token, jwks, {
issuer: config.OIDC_ISSUER, // phải khớp đúng URL public của realm
audience: config.OIDC_AUDIENCE, // chỉ nhận token được cấp cho API này
algorithms: ["RS256", "ES256", "PS256"],
requiredClaims: ["sub", "exp"],
});
Ba chi tiết dễ sót:
- Issuer và nơi lấy khoá là hai cấu hình khác nhau.
issluôn là URL public của realm, nhưng backend có thể lấy JWKS qua địa chỉ khác (ví dụ mạng nội bộ). Hai biến cấu hình cho phép cả hai mà không phải nới kiểm traiss. - EventSource không gửi được header
Authorization. Với kênh SSE, token phải đi đường khác; cách dùng cookie HttpOnly có trong bài Server-Sent Events trong Spring Boot. - Đăng xuất không thu hồi được access token. API chỉ kiểm chữ ký, nên token còn dùng được tới
exp. Tuỳ chọnrevokeTokensOnSignoutcủa oidc-client-ts làm refresh token chết ngay khi đăng xuất; còn access token thì cách duy nhất là để thời hạn ngắn.
Audience: token của realm chưa chắc là token cho API của bạn
Nếu API không kiểm aud, mọi token còn hạn của realm đều được nhận, kể cả token cấp cho một client khác. Theo tài liệu Keycloak, mặc định client frontend "không tự động được thêm vào audience"; tài liệu khuyên thêm audience của service bằng một mapper "hardcoded audience" gắn vào client frontend. Tài liệu cũng lưu ý không nên dùng cùng một client cho cả frontend lẫn REST service.
Cách gọn nhất: một client riêng cho API (ví dụ my-api), một audience mapper trên client frontend để token có aud: my-api, và API chỉ nhận đúng giá trị đó. Trong Spring Boot, kiểm aud là một validator:
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(expectedAudience))
Kiểu 2: backend đổi code, rồi phát phiên của riêng mình
Với một app cũ đã có đăng nhập bằng mật khẩu, phân quyền theo vai trò và phiên riêng, dựng lại quanh token Keycloak thì tốn công và rủi ro. Cách nhẹ hơn: thêm Keycloak như một cách đăng nhập: backend là confidential client (có client secret), nhận code ở callback, đổi lấy token, gọi userinfo, ánh xạ sang user nội bộ, rồi phát phiên của chính app.
Kiểu này thêm SSO mà không đụng phân quyền, nhưng:
- Kiểm tham số
stateở callback để chống CSRF trên luồng đăng nhập. - RFC 9700 khuyên dùng PKCE cả với confidential client, không chỉ public client.
- Có hai phiên, của Keycloak và của app, và đăng xuất bên này không kết thúc bên kia; muốn thoát hẳn thì phải kết thúc cả hai.
Kiểu 3: gọi Admin API bằng service account, và user đã xoá tự "sống lại"
Khi người dùng tự xoá được tài khoản, xoá dữ liệu của app là chưa đủ: user bên Keycloak vẫn đăng nhập được. API có thể dùng một service account (luồng client_credentials) có quyền quản lý user, chỉ trong realm của app, để xoá luôn user bên Keycloak.
Cái bẫy nằm ở chỗ khác. Nhiều API tạo user nội bộ "đúng lúc cần" (just-in-time): request đầu tiên mang token hợp lệ của một sub chưa có thì tạo bản ghi. Sau khi xoá, access token vẫn còn hạn vài phút, và request kế tiếp mang token đó sẽ tạo lại user vừa bị xoá.
Cách xử lý là một bảng đánh dấu (tombstone), ghi trong cùng transaction với việc xoá:
await db.transaction(async (tx) => {
// Token của user còn hạn vài phút, không được tạo lại user.
await tx.insert(deletedUsers).values({ id: userId }).onConflictDoNothing();
await tx.delete(users).where(eq(users.id, userId));
});
Và bước tạo user just-in-time kiểm bảng này trước:
const [deleted] = await db.select().from(deletedUsers).where(eq(deletedUsers.id, claims.sub));
if (deleted) throw new DeletedUserError();
Với JWT, "đã xoá" và "token không còn hợp lệ" là hai thời điểm khác nhau, và mọi chỗ tạo dữ liệu từ token phải tính tới khoảng hở đó.
Refresh token rotation và React StrictMode
Một lỗi khó truy: app thỉnh thoảng tự đăng xuất trong môi trường dev, không rõ lý do. Ba thứ kết hợp lại:
AuthProvidercủa react-oidc-context tạoUserManagerbên trong một hàm khởi tạo củauseStatenếu bạn không truyền sẵn một cái.- Trong StrictMode, React gọi hàm khởi tạo đó hai lần và bỏ kết quả của một lần, nhưng
UserManagerbị bỏ đi đã kịp bật bộ hẹn giờ gia hạn, và bộ hẹn giờ đó sống mãi. - Realm bật Revoke Refresh Token: mỗi lần gia hạn, Keycloak thu hồi refresh token cũ và cấp cái mới.
Hai bộ hẹn giờ cùng dùng một refresh token, cách nhau vài mili giây. Bên thua nhận invalid_grant, và app hiểu đó là phiên đã kết thúc nên đăng xuất người dùng. Cách sửa: một UserManager duy nhất ở cấp module, truyền vào provider:
export const userManager = new UserManager(userManagerSettings);
export const oidcConfig: AuthProviderProps = { userManager, /* ... */ };
Lỗi này chỉ xảy ra ở dev, nơi StrictMode gọi hai lần, nhưng nó che mất lỗi thật. Bất kỳ đối tượng nào có side effect (timer, kết nối) mà được tạo trong hàm khởi tạo của useState đều dễ gặp đúng chuyện này.
Lệch đồng hồ: một phút là đủ, nhưng đừng gia hạn quá sớm
Spring Security mặc định cho phép lệch 60 giây khi kiểm exp. Nên giữ con số này: nới thêm giây nào là token hết hạn còn dùng được thêm giây đó. Phía trình duyệt nên gia hạn trước hạn khoảng chừng ấy, để mạng chậm không khiến API từ chối token mà client chưa kịp đổi.
Đừng đẩy mốc gia hạn sớm hơn nữa: nếu nó dài hơn thời hạn của access token, mọi token đều "sắp hết hạn" ngay lúc vừa được cấp, và client rơi vào vòng gia hạn liên tục. Với máy có đồng hồ lệch quá xa, frontend có thể đo độ lệch thật và báo người dùng rằng đồng hồ sai, thay vì lỗi im lặng.
JwtDecoder tự khai báo có thể khiến API phụ thuộc Keycloak lúc khởi động
Muốn chỉnh độ lệch đồng hồ hay thêm kiểm aud, người ta thường khai báo một JwtDecoder riêng. Dễ nghĩ rằng NimbusJwtDecoder.withIssuerLocation(...) giữ nguyên hành vi của bản Spring Boot tự cấu hình: chỉ hỏi Keycloak ở token đầu tiên, nên API vẫn khởi động được khi Keycloak đang tắt.
Không phải vậy. Tài liệu Spring Security nói NimbusJwtDecoder tra cứu jwkSetUri từ issuer "khi build() được gọi". Trong mã nguồn bản 6.5 (dòng mà Spring Boot 3.5 dùng), build() gọi processor(), và hàm này gọi endpoint metadata của issuer ngay trong lúc Spring tạo bean, nên Keycloak đang khởi động lại thì API cũng không lên được.
Việc trì hoãn mà Spring Boot làm sẵn nằm ở một lớp khác, SupplierJwtDecoder, và tài liệu khuyên bọc mọi JwtDecoder tự khai báo bằng nó:
@Bean
JwtDecoder jwtDecoder() {
return new SupplierJwtDecoder(() -> {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(clockSkewSeconds)),
new JwtIssuerValidator(issuerUri),
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(expectedAudience))));
return decoder;
});
}
SupplierJwtDecoder chỉ dựng decoder thật ở lần decode đầu tiên và giữ lại kết quả khi dựng thành công; lần dựng lỗi thì lần sau thử lại. Khi thay một bean tự cấu hình, hãy đọc xem bean gốc làm những gì ngoài phần bạn định đổi.
Theme theo từng app và CSS cũ không chịu đi
Mỗi realm có thể có theme đăng nhập riêng theo giao diện của app, kể cả trang quản lý tài khoản. Đóng theme vào một image Keycloak dựng sẵn (bản "optimized" mà hướng dẫn chạy Keycloak trong container khuyên dùng) biến việc đổi theme thành một lần deploy có thể rollback.
Có hai lớp cache khiến sửa CSS xong mà người dùng vẫn thấy bản cũ:
- Cache ở trình duyệt và CDN. Đường dẫn tài nguyên theme không đổi khi bạn sửa file, còn tài nguyên được cache lâu. Lúc build image, thêm
?v=<hash nội dung>vào từng file CSS trongtheme.properties, để CSS đổi thì URL đổi. - Cache gzip của chính Keycloak. Tài liệu theme nhắc Keycloak giữ bản nén của theme trong
data/tmp/kc-gzip-cache, và việc xoá thư mục này có ích khi deploy lại theme mà không tắt cache. Cache này theo đường dẫn file, không theo query string, nên nếudata/nằm trên một volume giữ qua các lần deploy, CSS cũ vẫn được trả ra. Đặtdata/tmptrên tmpfs chẳng hạn thì mỗi container bắt đầu với cache trống.
Health check nên kiểm thứ người dùng cần
Endpoint readiness của Keycloak cho biết tiến trình đã sẵn sàng, nhưng trong lúc khởi động (nhất là khi đang migration schema sau nâng cấp) các realm vẫn có thể trả 503. Nếu deploy chỉ dựa vào readiness, nó có thể báo thành công trong lúc chưa ai đăng nhập được.
Health check chắc hơn gọi đúng thứ app cần: tài liệu discovery của một realm (/realms/<realm>/.well-known/openid-configuration) và đòi mã 200. Nói chung, health check nên chạm vào đúng đường mà người dùng đi qua, không chỉ hỏi tiến trình "còn sống không".
Tóm tắt
| Chỗ dễ sai | Cách làm |
|---|---|
| Nghĩ một Keycloak là có SSO cho mọi app | SSO thuộc về realm; tách realm là tách cả phiên đăng nhập |
| API nhận mọi token của realm | Client riêng cho API, audience mapper, kiểm aud |
iss và nơi lấy JWKS |
Hai cấu hình riêng; iss luôn là URL public |
| Đăng xuất | Thu hồi refresh token; access token thì để thời hạn ngắn |
| User đã xoá được tạo lại từ token cũ | Bảng tombstone, ghi cùng transaction với việc xoá |
| Tự đăng xuất trong dev | Một UserManager ở cấp module, không tạo trong useState |
| API không khởi động khi Keycloak tắt | Bọc JwtDecoder tự khai báo bằng SupplierJwtDecoder |
| CSS theme cũ | ?v=hash lúc build, cache gzip không nằm trên volume giữ lâu |
| Deploy báo khoẻ khi chưa đăng nhập được | Health check gọi discovery của realm |
Nếu bạn cũng chạy Keycloak cho nhiều app và đã cân giữa chung một realm với mỗi app một realm, mình rất muốn được trao đổi. Email của mình ở trang chủ.