Hình dung access token của OAuth2 như một chìa khoá valet. Bạn đưa cho người giữ xe một chiếc chìa chỉ lái và đỗ được, không mở được cốp hay hộc đựng đồ — nó cho phép làm một việc cụ thể mà không phải là bạn, không trao toàn quyền. Nó nói được phép gì, không nói bạn là ai. 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 — chiếc chìa valet.

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 đó — kẹp thêm một tấm thẻ căn cước vào lúc trao tay. 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 — dùng chìa valet để xác nhận danh tính — và 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.

Muốn đọc nguồn chính xác nhất trong ba mươi giây, hỏi thẳng tài liệu khám phá của nhà cung cấp:

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ó luôn cập nhật, bài hướng dẫn thì không.

Mẫu số chung

Cái nhầm lẫn ở đầu bài là một lằn ranh đáng khắc sâu: "người cầm token này được làm gì" (uỷ quyền — access token của OAuth2, API key, chìa valet) và "người này là ai" (xác thực — id_token của OIDC, tấm thẻ căn cước) là hai câu hỏi khác nhau. Coi một token quyền như bằng chứng danh tính là lỗ hổng đã cắn nhiều công ty thật — ai cầm được token cấp cho ứng dụng khác cũng giả mạo được. OIDC sinh ra đúng vì OAuth2 trả lời câu đầu còn người ta cần câu sau; và cùng lằn ranh quyền-so-với-danh-tính đó chạy xuyên API key, session, và JWT (bài 44).

Hai điều đáng mang theo. Một: danh tính là thứ nên uỷ thác, không nên tự dựng — đăng nhập liên kết (OIDC/SAML/Kerberos) để một nhà cung cấp chuyên biệt đứng ra bảo chứng người dùng, nhờ vậy bạn không bao giờ giữ mật khẩu của họ — cùng bản năng "đừng tự chế" với băm mật khẩu và mật mã. Hai: lựa chọn giao thức an toàn có hạn dùng — Implicit và Password grant từng là chuẩn và giờ bị cấm; Authorization Code + PKCE là câu trả lời hôm nay — nên một bài hướng dẫn cũ giao cho bạn một luồng đã biết là thủng. Sợi chỉ chung: trước khi nối "đăng nhập bằng X", quyết xem bạn cần một quyền hay một danh tính rồi chọn đúng token/giao thức — đừng bao giờ đọc token-quyền như danh tính; hãy uỷ thác danh tính cho một nhà cung cấp thay vì tự làm một; và kiểm lại luồng bạn đang chép có còn là luồng được khuyến nghị không, vì góc bảo mật này đổi dưới chân bạn.

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.