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.

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 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.