OAuth 2.0 và OpenID Connect qua ví dụ với Spring Boot 3

1. Tổng quan

OAuth 2.0 và OpenID Connect (OIDC) thường được nhắc cùng nhau, nên nhiều người coi chúng là một. Thực ra chúng giải quyết hai bài toán khác nhau. OAuth 2.0 lo việc ủy quyền (cho phép ứng dụng gọi API thay người dùng). OIDC lo việc xác thực (biết người dùng là ai).

Trong bài này, bạn sẽ hiểu luồng Authorization Code + PKCE. Sau đó bạn dựng hai ứng dụng Spring Boot 3: một client đăng nhập bằng OIDC và một resource server kiểm tra access token dạng JWT. Cuối bài có test cho resource server và danh sách lỗi hay gặp.

2. OAuth 2.0 và OpenID Connect khác nhau ở đâu

2.1. Các vai trò trong OAuth 2.0

OAuth 2.0 định nghĩa bốn vai trò:

  • Resource Owner: người dùng, chủ sở hữu dữ liệu (ví dụ khách hàng có đơn hàng).
  • Client: ứng dụng muốn truy cập dữ liệu (ví dụ web shop-web).
  • Authorization Server: nơi người dùng đăng nhập và cấp quyền, rồi phát token. Ví dụ Keycloak, Okta, Auth0.
  • Resource Server: API chứa dữ liệu (ví dụ order-api).

Kết quả của OAuth 2.0 là một access token. Token này dành cho resource server, không dành cho client. Client chỉ cầm và gửi đi. Nó không nên đọc token để suy ra người dùng là ai.

2.2. OIDC bổ sung gì

OIDC là một lớp nằm trên OAuth 2.0. Khi client xin scope openid, authorization server trả thêm một ID token. Đây luôn là JWT, có chữ ký, chứa thông tin đăng nhập:

{
  "iss": "http://localhost:8180/realms/shop",
  "sub": "customer-42",
  "aud": "shop-web",
  "exp": 1760353200,
  "iat": 1760352900,
  "nonce": "n-0S6_WzA2Mj",
  "email": "lan@example.com",
  "name": "Nguyễn Thị Lan"
}

Các claim quan trọng gồm:

  • iss: ai phát token.
  • sub: định danh duy nhất, ổn định của người dùng.
  • aud: token này dành cho client nào.
  • nonce: chống replay.

Bạn có thể tóm tắt như sau:

Access token  -> gửi cho API (resource server)  -> "được phép làm gì"
ID token      -> client đọc để đăng nhập        -> "người dùng là ai"

3. Luồng Authorization Code + PKCE

Với ứng dụng web, SPA và mobile, bạn nên dùng luồng Authorization Code, kèm thêm PKCE (Proof Key for Code Exchange). Implicit flow và Password grant đã bị khuyến nghị bỏ.

1. Người dùng bấm "Đăng nhập" trên shop-web
2. shop-web tạo code_verifier ngẫu nhiên, tính code_challenge = SHA256(code_verifier)
3. Redirect trình duyệt tới:
   /authorize?response_type=code&client_id=shop-web&scope=openid profile email
             &redirect_uri=...&state=xyz&nonce=abc
             &code_challenge=...&code_challenge_method=S256
4. Người dùng đăng nhập tại Keycloak, Keycloak redirect về:
   /login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web (back-channel) gọi POST /token với code + code_verifier + client_secret
6. Keycloak trả về: access_token, id_token, refresh_token
7. shop-web kiểm tra id_token (chữ ký, iss, aud, exp, nonce) -> tạo session
8. shop-web gọi order-api với header: Authorization: Bearer <access_token>

Mỗi tham số chặn một kiểu tấn công riêng:

  • state chống CSRF ở bước callback.
  • nonce gắn ID token với đúng phiên đăng nhập.
  • PKCE đảm bảo kẻ nào lấy trộm được code cũng không đổi được nó thành token, vì không có code_verifier.

Tin tốt là Spring Security làm hộ bạn gần như toàn bộ các bước trên.

4. Dựng client đăng nhập bằng OIDC

4.1. Dependency và cấu hình

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Giả sử Keycloak chạy ở cổng 8180, có realm shop và client shop-web:

spring:
  security:
    oauth2:
      client:
        registration:
          shop:
            provider: keycloak
            client-id: shop-web
            client-secret: ${SHOP_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid, profile, email, orders.read
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: http://localhost:8180/realms/shop

Bạn chỉ cần khai báo issuer-uri. Spring sẽ đọc /.well-known/openid-configuration để tự lấy các endpoint authorize, token, jwks và end_session. Scope openid là thứ biến luồng OAuth 2.0 thành đăng nhập OIDC.

4.2. Cấu hình bảo mật

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ClientRegistrationRepository registrations) throws Exception {
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(
                registrations, "/oauth2/authorization");
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());

        var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
        logoutHandler.setPostLogoutRedirectUri("{baseUrl}");

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(login -> login
                .authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));
        return http.build();
    }
}

Đoạn cấu hình này làm ba việc:

  • oauth2Login bật toàn bộ luồng ở mục 3, gồm redirect, callback, đổi code và kiểm tra ID token.
  • withPkce() thêm code_challenge cho cả confidential client (client có secret). Public client đã được Spring bật PKCE sẵn.
  • OidcClientInitiatedLogoutSuccessHandler đăng xuất luôn ở phía Keycloak (RP-initiated logout). Thiếu nó, người dùng bấm “Đăng nhập” lại sẽ vào thẳng mà không cần nhập mật khẩu.

4.3. Đọc thông tin người dùng và gọi API

@RestController
public class AccountController {

    private final RestClient orderApi = RestClient.create("http://localhost:8081");

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
                "subject", user.getSubject(),
                "email", Objects.requireNonNullElse(user.getEmail(), "(không có)"),
                "name", Objects.requireNonNullElse(user.getFullName(), "(không có)"));
    }

    @GetMapping("/my-orders")
    public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
        return orderApi.get()
                .uri("/api/orders")
                .headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
                .retrieve()
                .body(String.class);
    }
}

Hai endpoint dùng hai loại token khác nhau:

  • OidcUser được dựng từ ID token (và UserInfo nếu có). Đây là cách đúng để biết người dùng là ai.
  • @RegisteredOAuth2AuthorizedClient lấy access token của registration shop. Nếu token đã hết hạn và có refresh token, Spring tự làm mới trước khi trả về.

Gọi /me sau khi đăng nhập sẽ trả về:

{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}

Lưu ý: Map.of ném NullPointerException nếu giá trị là null. Claim email chỉ có khi bạn xin scope email và người dùng có email, nên luôn xử lý trường hợp thiếu claim.

5. Dựng resource server kiểm tra JWT

5.1. Dependency và cấu hình

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
  port: 8081
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/shop
          audiences: order-api

Với cấu hình này, Spring kiểm tra từng request theo các bước sau:

  • Lấy public key từ jwks_uri của issuer.
  • Kiểm tra chữ ký và exp/nbf.
  • Kiểm tra iss khớp với issuer-uri.
  • Kiểm tra aud có chứa order-api.

Thiếu bước kiểm tra audience là lỗi rất phổ biến. Khi đó API sẽ chấp nhận cả token phát cho một dịch vụ khác cùng realm.

5.2. Phân quyền theo scope

public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
                .anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orderService.findByCustomer(jwt.getSubject());
    }
}

Mặc định, Spring chuyển claim scope (hoặc scp) thành các authority có tiền tố SCOPE_. Vì vậy scope orders.read trở thành SCOPE_orders.read.

Tại sao lại tắt CSRF? API này stateless và chỉ nhận token qua header Authorization, không dùng cookie. Do đó tắt CSRF là hợp lý.

Controller lấy sub từ token chứ không nhận customerId từ query string. Nhờ vậy người dùng không thể xem đơn của người khác.

Kết quả với các loại request:

Không có token                     -> 401 Unauthorized
Token hợp lệ nhưng thiếu scope     -> 403 Forbidden
Token hợp lệ, có orders.read       -> 200 OK + danh sách đơn của chính người dùng

Lưu ý: Keycloak để role trong realm_access.roles, không nằm trong scope. Nếu bạn muốn phân quyền theo role, hãy khai báo một JwtAuthenticationConverter riêng để map claim đó sang GrantedAuthority.

6. Kiểm thử resource server

Bạn không cần Keycloak thật để test. Thư viện spring-security-test có post-processor jwt() để giả lập một token đã được xác thực.

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    OrderService orderService;

    @MockitoBean
    JwtDecoder jwtDecoder; // tránh gọi tới issuer thật khi khởi động context

    @Test
    void returnsOrdersOfCurrentCustomer() {
        given(orderService.findByCustomer("customer-42"))
                .willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));

        assertThat(mvc.get().uri("/api/orders")
                .with(jwt().jwt(j -> j.subject("customer-42"))
                           .authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
                .hasStatusOk()
                .bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
    }

    @Test
    void rejectsTokenWithoutScope() {
        assertThat(mvc.get().uri("/api/orders").with(jwt()))
                .hasStatus(HttpStatus.FORBIDDEN);
    }

    @Test
    void rejectsRequestWithoutToken() {
        assertThat(mvc.get().uri("/api/orders"))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }
}

Ba test này phủ ba nhánh ở mục 5.2:

  • jwt() bỏ qua bước giải mã. Nó đặt thẳng một JwtAuthenticationToken vào SecurityContext, nên test chạy nhanh và độc lập.
  • Bạn phải truyền authorities(...) tường minh. jwt() mặc định chỉ có SCOPE_read, nên test thứ hai nhận về 403.
  • MockMvcTester (Spring Boot 3.4+) cho phép viết assertion bằng AssertJ ngay trên response.

7. Lỗi hay gặp và khi nào không nên dùng

Dùng ID token để gọi API. ID token có aud là client, không phải API. Resource server cấu hình đúng sẽ từ chối nó. Luôn gửi access token.

Dùng access token để đăng nhập. Với OAuth 2.0 thuần, access token có thể là chuỗi opaque và không chứa gì về người dùng. Còn nếu client tự parse access token JWT để lấy danh tính, nó đang dựa vào định dạng mà nó không được đảm bảo. Hãy dùng ID token hoặc endpoint UserInfo.

Lưu token trong localStorage ở SPA. Một lỗ hổng XSS là đủ để lộ toàn bộ token. Pattern an toàn hơn là BFF (Backend for Frontend). Ứng dụng Spring trong mục 4 giữ token phía server, còn trình duyệt chỉ có session cookie HttpOnly.

Không kiểm tra aud, hoặc để access token sống quá lâu. JWT không thu hồi được trước khi hết hạn. Hãy giữ thời hạn access token ngắn (5–15 phút) và dùng refresh token để gia hạn.

Lệch issuer-uri. Nếu Keycloak trả iss là http://keycloak:8080/... (tên host trong Docker) nhưng bạn cấu hình localhost:8180, mọi token đều bị từ chối với lỗi invalid_token. Giá trị iss phải khớp từng ký tự.

Lưu ý: Không phải lúc nào bạn cũng cần OAuth 2.0. Nếu chỉ có một ứng dụng monolith với form login và không có bên thứ ba nào cần gọi API, session thông thường của Spring Security đơn giản và an toàn hơn. Với giao tiếp máy-với-máy không có người dùng, hãy dùng grant client_credentials, không dùng Authorization Code.

8. Kết luận

OAuth 2.0 trả lời câu hỏi “ứng dụng này được làm gì” bằng access token. OIDC trả lời câu hỏi “người dùng là ai” bằng ID token. Luồng nên dùng là Authorization Code + PKCE, với state và nonce đi kèm.

Với Spring Boot 3, bạn đã thấy:

  • spring-boot-starter-oauth2-client cùng oauth2Login lo toàn bộ phần đăng nhập OIDC phía client.
  • spring-boot-starter-oauth2-resource-server kiểm tra JWT theo issuer-uri và audiences.
  • spring-security-test cho phép test phân quyền theo scope mà không cần authorization server thật.

Bước tiếp theo bạn có thể thử là map role của Keycloak bằng JwtAuthenticationConverter, hoặc tách client thành BFF cho một SPA.

oauth2openid-connectspring-securityjwtweb-security