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:
statechống CSRF ở bước callback.noncegắn ID token với đúng phiên đăng nhập.- PKCE đảm bảo kẻ nào lấy trộm được
codecũ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:
oauth2Loginbật toàn bộ luồng ở mục 3, gồm redirect, callback, đổi code và kiểm tra ID token.withPkce()thêmcode_challengecho 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.@RegisteredOAuth2AuthorizedClientlấy access token của registrationshop. 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.ofnémNullPointerExceptionnếu giá trị lànull. 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_uricủa issuer. - Kiểm tra chữ ký và
exp/nbf. - Kiểm tra
isskhớp vớiissuer-uri. - Kiểm tra
audcó chứaorder-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 trongscope. Nếu bạn muốn phân quyền theo role, hãy khai báo mộtJwtAuthenticationConverterriêng để map claim đó sangGrantedAuthority.
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ộtJwtAuthenticationTokenvàoSecurityContext, 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-clientcùngoauth2Loginlo toàn bộ phần đăng nhập OIDC phía client.spring-boot-starter-oauth2-resource-serverkiểm tra JWT theoissuer-urivàaudiences.spring-security-testcho 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.