Một Keycloak cho nhiều app: realm, audience và những lỗi khó thấy

Keycloak tự host cho nhiều side project: mỗi app một realm, kiểm aud, user đã xoá bị tạo lại từ token còn hạn, refresh chạy đua và bẫy lúc khởi động.

· Đăng ngày · Cập nhật · 9 phút đọc · Phiên bản: Keycloak 26, Spring Boot 3.5

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.

Sơ đồ tuần tự cho SPA: SPA chuyển hướng sang Keycloak kèm code_challenge, nhận code, đổi code kèm code_verifier lấy access token JWT, rồi gọi API kèm Bearer token; API kiểm chữ ký bằng JWKS đã cache cùng iss, aud và exp, rồi phản hồi mà không gọi Keycloak.SPAKeycloakAPIchuyển hướngkèm code_challengecodecode + code_verifieraccess token (JWT)gọi API kèmBearer tokenkiểm chữ ký bằngJWKS đã cache,iss, aud, expphản hồi
Sau khi đăng nhập, Keycloak không nằm trên đường đi của request: API tự kiểm token.

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. iss luô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 tra iss.
  • 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ọn revokeTokensOnSignout củ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.

Sơ đồ tuần tự cho backend: trình duyệt đăng nhập ở trang Keycloak rồi được chuyển về callback của backend kèm code và state; backend đổi code kèm client secret lấy token, gọi userinfo, kiểm state, gắn kết quả với user nội bộ và trả cho trình duyệt phiên riêng của app.Trình duyệtBackendKeycloakđăng nhập ởtrang Keycloakchuyển hướngkèm code + statecallback: code, stateđổi codekèm client secrettokenuserinfokiểm state, gắnvới user nội bộphiên riêng của app
Token nằm lại ở backend; trình duyệt chỉ giữ phiên riêng của 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:

  1. AuthProvider của react-oidc-context tạo UserManager bên trong một hàm khởi tạo của useState nếu bạn không truyền sẵn một cái.
  2. 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 UserManager bị bỏ đi đã kịp bật bộ hẹn giờ gia hạn, và bộ hẹn giờ đó sống mãi.
  3. 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 trong theme.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ếu data/ nằm trên một volume giữ qua các lần deploy, CSS cũ vẫn được trả ra. Đặt data/tmp trê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ủ.