Bài này về chỗ mà tài liệu nhiều nhưng hiểu nhầm cũng nhiều nhất.
OAuth2 không phải để đăng nhập
Đây là điều cần sửa trước tiên.
OAuth2 là giao thức uỷ quyền. Nó trả lời: "ứng dụng A có được phép đọc dữ liệu của tôi ở dịch vụ B không". Kết quả là một access token cho phép gọi API.
Nó không nói cho bạn biết người dùng là ai. Access token không chứa danh tính; nó chứa quyền.
OpenID Connect (OIDC) là lớp mỏng đặt trên OAuth2 để làm việc đó. Nó thêm:
id_token— một JWT chứa danh tính người dùng- scope
openid - endpoint
/userinfo - tài liệu khám phá tại
/.well-known/openid-configuration
Nên: "đăng nhập bằng Google" là OIDC, không phải OAuth2 thuần. Cài đặt bằng OAuth2 thuần rồi lấy hồ sơ qua API là mẫu chống — nó từng dẫn tới lỗ hổng thật ở nhiều dịch vụ lớn.
Trong Spring, khác biệt nằm ở hai starter:
spring-boot-starter-oauth2-client <- đăng nhập, gọi API thay người dùng
spring-boot-starter-oauth2-resource-server <- xác minh token nhận được (bài 43)
Luồng nào còn đúng
| Luồng | Trạng thái 2026 |
|---|---|
| Authorization Code + PKCE | dùng cái này |
| Client Credentials | đúng, cho dịch vụ gọi dịch vụ |
| Implicit | bỏ — token đi trong URL |
| Resource Owner Password | bỏ — ứng dụng cầm mật khẩu người dùng |
| Device Code | đúng, cho TV và thiết bị không có bàn phím |
Hai dòng "bỏ" từng có mặt trong mọi bài hướng dẫn. Implicit trả token qua fragment của URL, nên nó nằm trong lịch sử trình duyệt và log; Password grant bắt ứng dụng của bạn cầm mật khẩu người dùng — phá hỏng toàn bộ lý do OAuth2 tồn tại.
PKCE giờ bắt buộc cho mọi client, kể cả client có secret. Nó chặn tấn công chặn mã uỷ quyền: client sinh một chuỗi ngẫu nhiên, gửi bản băm của nó lúc bắt đầu, và gửi bản gốc lúc đổi mã lấy token. Kẻ chặn được mã không có bản gốc.
Đăng nhập Google trong Spring
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
http.oauth2Login(o -> o.defaultSuccessUrl("/", true));
Đó là toàn bộ. Spring Boot biết sẵn endpoint của Google, Facebook, GitHub, Okta — với nhà cung cấp khác thì thêm một khối provider trỏ tới issuer-uri, và Spring tự đọc tài liệu khám phá.
Ánh xạ vào người dùng của bạn
Đây là phần thật sự cần viết mã, và cũng là phần các bài hướng dẫn hay bỏ qua.
@Bean OAuth2UserService<OidcUserRequest, OidcUser> dichVuNguoiDung() {
var goc = new OidcUserService();
return yc -> {
OidcUser tu = goc.loadUser(yc);
var nguoi = repo.findByEmail(tu.getEmail())
.orElseGet(() -> repo.save(NguoiDung.tuOidc(tu))); // tự tạo lần đầu
return new DefaultOidcUser(nguoi.quyen(), tu.getIdToken(), tu.getUserInfo());
};
}
Ba quyết định phải trả lời trước khi viết dòng này:
Người mới thì tự tạo tài khoản, hay phải được mời? Với ứng dụng nội bộ, tự tạo là mở cửa cho bất kỳ ai có tài khoản Google.
Ghép theo email hay theo id nhà cung cấp? Email dễ hiểu nhưng đổi được, và nếu nhà cung cấp không xác minh email thì có người đăng ký email của người khác. Kiểm email_verified, và lưu cả (nhaCungCap, sub) làm khoá thật.
Một người dùng đăng nhập bằng hai nhà cung cấp thì sao? Google hôm nay, GitHub hôm sau, cùng email — một tài khoản hay hai? Quyết định trước, vì sửa sau là phải gộp dữ liệu.
Gọi API thay người dùng
@Bean OAuth2AuthorizedClientManager quanLy(...) { ... }
var token = client.getAccessToken().getTokenValue();
Với RestClient của bài 20, Spring có interceptor tự gắn token và tự làm mới khi hết hạn:
RestClient.builder()
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(quanLy))
.build();
Đây là chỗ đáng dùng thư viện thay vì tự xoay xở với refresh token — logic làm mới có nhiều trường hợp biên hơn nó trông.
Có nên tự dựng máy chủ uỷ quyền
Spring Authorization Server cho phép bạn tự phát token. Câu trả lời của tôi: thường là không.
Bạn sẽ phải tự lo xoay vòng khoá, thu hồi token, quản lý client, giao diện đồng ý, chống nhồi thông tin đăng nhập, xác thực nhiều yếu tố, khôi phục tài khoản. Keycloak, Auth0, Okta hay Cognito đã làm cả rồi.
Tự dựng đúng khi: bạn là nhà cung cấp danh tính cho bên thứ ba, hoặc yêu cầu tuân thủ buộc mọi thứ nằm trong hạ tầng của bạn.
Bốn cái bẫy
Trả về không khớp redirect-uri. Phải khớp chính xác với thứ khai ở nhà cung cấp, kể cả http với https và dấu gạch chéo cuối. Đây là lỗi số một khi triển khai lên môi trường thật, vì URL trên máy dev khác URL sản xuất.
Thiếu header X-Forwarded-*. Sau nginx, ứng dụng dựng URL trả về bằng http thay vì https và nhà cung cấp từ chối. CLAUDE.md của blog này ghi rõ ba header đó là bắt buộc, và đây là một trong những lý do.
Tham số state là bắt buộc. Nó chống CSRF cho chính luồng đăng nhập. Spring lo sẵn — đừng tắt.
Chỉ xin scope cần thiết. Xin drive.readonly để hiện tên người dùng là cách nhanh nhất khiến người ta bấm huỷ.
Đăng xuất
Khó hơn người ta tưởng: đăng xuất khỏi ứng dụng của bạn không đăng xuất khỏi Google. Người dùng bấm đăng xuất, bấm đăng nhập lại, và vào thẳng không cần nhập gì — trông như đăng xuất không hoạt động.
OIDC có end_session_endpoint cho đăng xuất toàn cục:
http.logout(l -> l.logoutSuccessHandler(
new OidcClientInitiatedLogoutSuccessHandler(repo)));
Quyết định xem bạn muốn hành vi nào, và nói rõ với người dùng — đây là chỗ họ hay báo lỗi mà thật ra không có lỗi.
Thử ba mươi giây
curl -s https://accounts.google.com/.well-known/openid-configuration | jq 'keys'
Mọi nhà cung cấp OIDC đều có tài liệu này, và nó liệt kê đầy đủ endpoint, thuật toán, scope được hỗ trợ. Đọc nó trước khi tìm bài hướng dẫn — nó là nguồn chính xác nhất, và nó luôn cập nhật.
Ngày mai: header bảo mật và CSP — và vì sao script nội tuyến không chạy mà test vẫn xanh.