OAuth2 Resource Server Temelleri
Giriş — Neden Bu Konu Önemli?
Önceki derslerde kendi JWT auth sistemimizi sıfırdan kurduğumuz — JwtService ile token oluşturduk, JwtAuthenticationFilter ile doğruladık, refresh token mekanizması ekledik. Bu yaklaşım tek bir uygulama için yeterlidir. Peki ya birden fazla uygulamanız varsa? Mobil app, web app, admin paneli, microservice'ler... Her birinde ayrı JWT sistemi mi kuracaksınız?
İşte OAuth2 tam bu noktada devreye girer. OAuth2, yetkilendirme (authorization) için endüstri standardı protokoldür (RFC 6749). Bir uygulamanın, kullanıcının şifresini bilmeden, başka bir servisteki kaynaklarına erişmesini sağlar. "Google ile Giriş Yap", "GitHub ile Bağlan" gibi özellikler OAuth2 üzerine kuruludur.
Gerçek Hayat Analojisi: Bir otele giriş düşünün. Kendi JWT sisteminiz, otelin kapısında kendi kilit sisteminizi kurmaktır — sadece sizin anahtarınız çalışır. OAuth2 ise uluslararası bir anahtar kart sistemidir — aynı kartla dünyanın her yerindeki otellere girebilirsiniz. Keycloak/Auth0 "anahtar kart üreten merkez", sizin Spring Boot API'niz ise "otel kapısındaki kart okuyucu" (Resource Server) rolündedir.
OAuth2 Rolleri
OAuth2 ekosisteminde dört temel rol vardır:
┌──────────────────┐ ┌───────────────────────┐
│ Resource Owner │ │ Authorization Server │
│ (Kullanıcı) │ │ (Keycloak, Auth0, │
│ │ │ Google, Spring Auth) │
└────────┬─────────┘ └───────────┬───────────┘
│ 1. Login │ 2. Token ver
▼ ▼
┌──────────────────┐ ┌───────────────────────┐
│ Client │─────▶│ Resource Server │
│ (React, Mobile, │ 3. │ (Sizin Spring Boot │
│ başka servis) │Token │ API'niz) │
│ │ile │ │
│ │istek │ │
└──────────────────┘ └───────────────────────┘Resource Owner: Kullanıcı — verinin sahibi
Client: Frontend uygulaması, mobil app veya başka bir servis
Authorization Server: Token üreten merkezi kimlik servisi (Keycloak, Auth0, Okta, Spring Authorization Server, Google)
Resource Server: Token'ı doğrulayarak korunan kaynakları sunan API — bizim Spring Boot uygulamamız
Bu derste odak noktamız Resource Server tarafıdır — Authorization Server'dan gelen token'ı doğrulayıp API'mizi korumak.
Kendi JWT Auth vs OAuth2
Neden kendi JWT auth sistemi yerine OAuth2 kullanmalısınız?
| Özellik | Kendi JWT Auth | OAuth2 Resource Server |
|---|---|---|
| Token üretimi | Sizin uygulamanız | Authorization Server (Keycloak, Auth0) |
| Key yönetimi | Manuel (secret key) | Otomatik (JWKS endpoint) |
| Token doğrulama | JwtService sınıfınız | Spring Security otomatik |
| SSO desteği | ❌ Yok | ✅ Tek login, çoklu uygulama |
| Social login | Manuel implementasyon | ✅ Hazır (Google, GitHub vb.) |
| Token revocation | Blacklist gerekir | Authorization Server yönetir |
| Key rotation | Manuel güncelleme + downtime | JWKS ile otomatik |
| Karmaşıklık | Düşük (tek uygulama) | Orta-yüksek (ek altyapı) |
| Uygun olduğu yer | Tek uygulama, basit auth | Microservices, enterprise |
⚠️ Ne zaman hangisi? Tek bir Spring Boot uygulaması ve basit auth gerekiyorsa kendi JWT sisteminiz yeterlidir. Birden fazla uygulama, SSO, social login veya enterprise gereksinimler varsa OAuth2 + Keycloak/Auth0 tercih edin.
Spring Boot Resource Server Kurulumu
Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>Bu tek dependency ile Spring Security'nin OAuth2 resource server desteği aktif olur.
application.yml Konfigürasyonu
spring:
security:
oauth2:
resourceserver:
jwt:
# Authorization Server'ın issuer URI'si
# Spring Boot buradan otomatik olarak JWKS endpoint'ini keşfeder
issuer-uri: https://auth.example.com/realms/myapp
# Alternatif: JWKS URI'sini doğrudan belirt
# jwk-set-uri: https://auth.example.com/realms/myapp/protocol/openid-connect/certsNasıl Çalışır? (Otomatik Keşif)
Spring Boot başlangıçta
issuer-uriadresindeki.well-known/openid-configurationendpoint'ini çağırırBu endpoint'ten JWKS (JSON Web Key Set) URI'sini öğrenir
JWKS'den Authorization Server'ın public key'lerini indirir
Gelen JWT token'ları bu public key'ler ile doğrular (signature verification)
Spring Boot Başlangıcında:
1. GET https://auth.example.com/realms/myapp/.well-known/openid-configuration
→ {"jwks_uri": "https://auth.example.com/.../certs", ...}
2. GET https://auth.example.com/.../certs
→ {"keys": [{"kty":"RSA","kid":"abc123","n":"...","e":"AQAB"}]}
Her İstek Geldiğinde:
3. Token'ın header'ındaki "kid" ile doğru public key'i bul
4. Token'ın signature'ını public key ile doğrula
5. Token'ın exp, iss, aud claim'lerini kontrol et
6. Doğruysa → Authentication başarılı, yanlışsa → 401💡 Kendi JWT vs OAuth2 farkı: Kendi sistemimizde secret key (symmetric) kullanıyorduk — aynı key ile hem imzalama hem doğrulama. OAuth2'de asymmetric key kullanılır — Authorization Server private key ile imzalar, Resource Server public key ile doğrular. Bu sayede Resource Server hiçbir zaman token üretemez, sadece doğrulayabilir.
SecurityFilterChain Konfigürasyonu
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Stateless API — CSRF gerekmez
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("""
{"error": "unauthorized", "message": "Token geçersiz veya eksik"}
""");
})
)
.build();
}
}Dikkat edilmesi gerekenler:
.oauth2ResourceServer(...)kendi JwtAuthenticationFilter'ımız yerine geçer — Spring Security otomatik olarak token'ı parse eder, doğrular ve Authentication nesnesini oluştururSessionCreationPolicy.STATELESS— her istek bağımsız, session yokException handling ile 401 yanıtı özelleştirilebilir
JWT Claim'lerinden Roller Çıkarma
Authorization Server'lar rolleri farklı claim'lerde gönderir. Keycloak realm_access.roles, Auth0 permissions, custom server roles kullanır. Spring Boot'a bu mapping'i öğretmemiz gerekir:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// Default: "scope" veya "scp" claim'ini okur → SCOPE_xxx authority'leri oluşturur
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>();
// 1. Scope'ları ekle (varsa)
authorities.addAll(grantedAuthoritiesConverter.convert(jwt));
// 2. Keycloak realm roller
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null) {
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
}
}
// 3. Custom roller (kendi auth server'ınız için)
List<String> customRoles = jwt.getClaimAsStringList("roles");
if (customRoles != null) {
customRoles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
}
return authorities;
});
return converter;
}Farklı Auth Server'lar İçin Rol Claim Formatları
| Auth Server | Rol Claim Yolu | Örnek |
|---|---|---|
| Keycloak | realm_access.roles | ["admin", "user"] |
| Auth0 | permissions veya custom claim | ["read:users", "write:users"] |
| Okta | groups | ["Admin", "User"] |
| Custom | roles | ["ROLE_ADMIN", "ROLE_USER"] |
JWT Decoder Özelleştirme
Token doğrulama kurallarını özelleştirmek için custom JwtDecoder:
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/realms/myapp/protocol/openid-connect/certs")
.build();
// Issuer validator
OAuth2TokenValidator<Jwt> issuerValidator =
JwtValidators.createDefaultWithIssuer("https://auth.example.com/realms/myapp");
// Custom audience validator — token bu API için mi?
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
List<String> audiences = token.getAudience();
if (audiences != null && audiences.contains("myapp-api")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_audience", "Token bu API için değil", null));
};
// Birden fazla validator birleştir
OAuth2TokenValidator<Jwt> combinedValidator =
new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator);
decoder.setJwtValidator(combinedValidator);
return decoder;
}Scope-Based ve Role-Based Authorization
OAuth2'de scope'lar client'ın hangi kaynaklara erişebileceğini, roller ise kullanıcının ne yapabileceğini belirler:
@RestController
@RequestMapping("/api")
public class ResourceController {
// "read" scope'u gerektirir
@GetMapping("/documents")
@PreAuthorize("hasAuthority('SCOPE_read')")
public List<Document> getDocuments() { ... }
// "write" scope'u gerektirir
@PostMapping("/documents")
@PreAuthorize("hasAuthority('SCOPE_write')")
public Document createDocument(@RequestBody DocumentDto dto) { ... }
// Hem role hem scope kontrolü
@DeleteMapping("/documents/{id}")
@PreAuthorize("hasRole('ADMIN') and hasAuthority('SCOPE_admin')")
public void deleteDocument(@PathVariable Long id) { ... }
// JWT'den kullanıcı bilgilerini al
@GetMapping("/me")
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"subject", jwt.getSubject(),
"email", Objects.toString(jwt.getClaimAsString("email"), "N/A"),
"issuedAt", jwt.getIssuedAt(),
"expiresAt", jwt.getExpiresAt(),
"claims", jwt.getClaims()
);
}
}@AuthenticationPrincipal ile JWT Erişimi
// JWT claim'lerine doğrudan erişim
@GetMapping("/profile")
public UserProfile getProfile(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
String email = jwt.getClaimAsString("email");
String name = jwt.getClaimAsString("preferred_username");
return userService.getProfile(userId);
}
// Custom principal dönüşümü
@GetMapping("/dashboard")
public Dashboard getDashboard(
@AuthenticationPrincipal(expression = "subject") String userId) {
return dashboardService.getForUser(userId);
}Birden Fazla Issuer Desteği
Bazı uygulamalar birden fazla Authorization Server'dan token kabul etmek isteyebilir (örneğin hem Keycloak hem Google):
@Bean
public JwtDecoder jwtDecoder() {
// İki farklı issuer'dan token kabul et
JwtDecoder keycloakDecoder = NimbusJwtDecoder
.withJwkSetUri("https://keycloak.example.com/.../certs")
.build();
JwtDecoder googleDecoder = NimbusJwtDecoder
.withJwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
.build();
// Issuer'a göre doğru decoder'ı seç
return token -> {
try {
// İlk olarak Keycloak ile dene
return keycloakDecoder.decode(token);
} catch (JwtException e) {
// Keycloak başarısız → Google ile dene
return googleDecoder.decode(token);
}
};
}💡 Spring Security'nin
JwtIssuerAuthenticationManagerResolversınıfı bu senaryoyu daha temiz şekilde çözer:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
JwtIssuerAuthenticationManagerResolver resolver =
JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
"https://keycloak.example.com/realms/myapp",
"https://accounts.google.com"
);
return http
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(resolver)
)
.build();
}Test Yazma
@WebMvcTest(ResourceController.class)
class ResourceControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER")
void shouldAllowAuthenticatedUser() throws Exception {
mockMvc.perform(get("/api/documents"))
.andExpect(status().isOk());
}
@Test
void shouldRejectUnauthenticatedRequest() throws Exception {
mockMvc.perform(get("/api/documents"))
.andExpect(status().isUnauthorized());
}
// JWT mock ile test
@Test
void shouldAcceptValidJwt() throws Exception {
mockMvc.perform(get("/api/me")
.with(jwt()
.jwt(builder -> builder
.subject("user123")
.claim("email", "user@example.com")
.claim("realm_access", Map.of("roles", List.of("user")))
)
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subject").value("user123"));
}
}Keycloak ile Pratik Entegrasyon
Keycloak, en popüler open-source Authorization Server'dır. Docker ile hızlıca kurulabilir:
# Keycloak'u Docker ile başlat
docker run -d \
--name keycloak \
-p 8180:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:24.0 start-devKeycloak Konfigürasyonu
http://localhost:8180adresine gidin (admin/admin)Yeni bir Realm oluşturun:
myappClient oluşturun:
myapp-api(Client type: OpenID Connect)User oluşturun ve şifre belirleyin
Realm roles oluşturun:
admin,userKullanıcıya rolleri atayın
Spring Boot Konfigürasyonu
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/myappToken Alma ve Test
# Keycloak'tan token al (Resource Owner Password Grant — sadece test için)
curl -X POST http://localhost:8180/realms/myapp/protocol/openid-connect/token \
-d "client_id=myapp-api" \
-d "grant_type=password" \
-d "username=testuser" \
-d "password=test123" \
-d "scope=openid"
# Yanıt:
# {
# "access_token": "eyJhbGciOiJSUzI1NiIs...",
# "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
# "token_type": "Bearer",
# "expires_in": 300
# }
# Token ile API'ye istek at
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
http://localhost:8080/api/meKeycloak Token'ının İçeriği
{
"sub": "f5e8b4a2-1234-5678-abcd-1234567890ab",
"realm_access": {
"roles": ["admin", "user", "default-roles-myapp"]
},
"scope": "openid email profile",
"email": "testuser@example.com",
"preferred_username": "testuser",
"given_name": "Test",
"family_name": "User",
"iss": "http://localhost:8180/realms/myapp",
"exp": 1709321123,
"iat": 1709320823
}Bu token'ı doğru şekilde parse etmek için JwtAuthenticationConverter'da realm_access.roles claim'ini okuyan kodu kullanın (yukarıda gösterildi).
Hata Yönetimi
OAuth2 Resource Server'da özel hata yanıtları:
@Configuration
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
.authenticationEntryPoint((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("""
{
"error": "unauthorized",
"message": "Geçersiz veya eksik Bearer token",
"details": "%s"
}
""".formatted(exception.getMessage()));
})
.accessDeniedHandler((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("""
{
"error": "forbidden",
"message": "Bu işlem için yetkiniz yok"
}
""");
})
)
.build();
}
}Özet
OAuth2 Resource Server dışarıdan alınan JWT token'ları doğrulayarak API'nizi korur
Authorization Server (Keycloak, Auth0) token üretir, Resource Server (Spring Boot) public key ile doğrular
issuer-uri ayarı ile Spring Boot otomatik olarak JWKS endpoint'ini keşfeder ve public key'leri indirir
JwtAuthenticationConverter ile claim-to-authority mapping yapılır — her Auth Server farklı format kullanır
Scope-based (SCOPE_read) ve role-based (ROLE_ADMIN) authorization birlikte kullanılabilir
@AuthenticationPrincipal Jwt ile controller'da token claim'lerine doğrudan erişilebilir
Tek uygulama + basit auth → kendi JWT sisteminiz yeterli; microservices + SSO → OAuth2 tercih edin
AI Asistan
Sorularını yanıtlamaya hazır