OAuth 2.0 und OpenID Connect mit Spring Boot 3
1. Übersicht
OAuth 2.0 und OpenID Connect (OIDC) werden oft zusammen erwähnt, daher sehen viele Menschen sie als dasselbe an. Tatsächlich lösen sie zwei verschiedene Probleme. OAuth 2.0 kümmert sich um Autorisierung (erlaubt einer Anwendung, APIs im Namen eines Benutzers zu nutzen). OIDC kümmert sich um Authentifizierung (wer der Benutzer ist).
In diesem Artikel werden Sie den Authorization-Code-Flow + PKCE verstehen. Dann bauen Sie zwei Spring-Boot-3-Anwendungen: einen Client, der sich über OIDC anmeldet, und einen Ressourcen-Server, der Access-Tokens im JWT-Format validiert. Am Ende des Artikels finden Sie Tests für den Ressourcen-Server und eine Liste häufiger Fehler.
2. Unterschiede zwischen OAuth 2.0 und OpenID Connect
2.1. Rollen in OAuth 2.0
OAuth 2.0 definiert vier Rollen:
- Resource Owner: der Benutzer, Eigentümer der Daten (z. B. Kunde mit Bestellungen).
- Client: die Anwendung, die auf Daten zugreifen möchte (z. B. Web
shop-web). - Authorization Server: wo sich der Benutzer anmeldet und Zugriff gewährt, dann ein Token ausstellt. Beispiele: Keycloak, Okta, Auth0.
- Resource Server: die API mit den Daten (z. B.
order-api).
Das Ergebnis von OAuth 2.0 ist ein Access Token. Dieses Token ist für den Ressourcen-Server gedacht, nicht für den Client. Der Client hält es nur und sendet es weiter. Er sollte es nicht lesen, um abzuleiten, wer der Benutzer ist.
2.2. Was OIDC hinzufügt
OIDC ist eine Schicht über OAuth 2.0. Wenn der Client den Scope openid anfordert, gibt der Authorization Server zusätzlich ein ID Token zurück. Dies ist immer ein JWT mit Signatur, enthält Authentifizierungsinformationen:
{
"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"
}
Die wichtigsten Claims sind:
iss: wer das Token ausgestellt hat.sub: eindeutige, stabile Kennung des Benutzers.aud: für welchen Client dieses Token gedacht ist.nonce: Schutz vor Replay-Angriffen.
Sie können es so zusammenfassen:
Access Token -> an API senden (Ressourcen-Server) -> "darf was machen"
ID Token -> Client liest zur Anmeldung -> "wer ist der Benutzer"
3. Authorization-Code-Flow + PKCE
Für Web-Anwendungen, SPAs und Mobile sollten Sie den Authorization-Code-Flow mit zusätzlichem PKCE (Proof Key for Code Exchange) verwenden. Implicit Flow und Password Grant werden nicht mehr empfohlen.
1. Benutzer klickt "Anmelden" auf shop-web
2. shop-web generiert zufällige code_verifier, berechnet code_challenge = SHA256(code_verifier)
3. Browser umleiten zu:
/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. Benutzer meldet sich bei Keycloak an, Keycloak leitet um zu:
/login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web (back-channel) ruft POST /token mit code + code_verifier + client_secret auf
6. Keycloak gibt zurück: access_token, id_token, refresh_token
7. shop-web validiert id_token (Signatur, iss, aud, exp, nonce) -> erstellt Session
8. shop-web ruft order-api mit Header auf: Authorization: Bearer <access_token>
Jeder Parameter blockiert einen anderen Angriffstyp:
stateschützt vor CSRF beim Callback.noncebindet ID Token an die richtige Anmeldungssitzung.- PKCE stellt sicher, dass auch wenn jemand den
codestiehlt, dieser nicht in ein Token umgewandelt werden kann, weil dercode_verifierfehlt.
Die gute Nachricht ist, dass Spring Security fast alle Schritte oben für Sie übernimmt.
4. Client mit OIDC-Anmeldung aufbauen
4.1. Abhängigkeiten und Konfiguration
<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>
Angenommen, Keycloak läuft auf Port 8180, hat Realm shop und 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
Sie müssen nur issuer-uri deklarieren. Spring liest /.well-known/openid-configuration und ruft automatisch die Endpoints authorize, token, jwks und end_session ab. Der Scope openid ist das, was den OAuth-2.0-Flow in eine OIDC-Anmeldung verwandelt.
4.2. Sicherheitskonfiguration
@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();
}
}
Diese Konfiguration macht drei Dinge:
oauth2Loginaktiviert den gesamten Flow aus Abschnitt 3, einschließlich Umleitung, Callback, Code-Austausch und ID-Token-Validierung.withPkce()fügtcode_challengeauch für vertrauliche Clients hinzu (Client mit Secret). Öffentliche Clients haben PKCE bereits standardmäßig aktiviert.OidcClientInitiatedLogoutSuccessHandlermeldet sich auch bei Keycloak ab (RP-initiated logout). Ohne ihn wird der Benutzer beim erneuten Anmelden direkt eingeloggt, ohne Passwort einzugeben.
4.3. Benutzerinformationen lesen und API aufrufen
@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(), "(nicht vorhanden)"),
"name", Objects.requireNonNullElse(user.getFullName(), "(nicht vorhanden)"));
}
@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);
}
}
Die zwei Endpoints verwenden zwei verschiedene Token-Typen:
OidcUserwird aus dem ID Token konstruiert (und UserInfo, falls vorhanden). Dies ist die richtige Methode, um zu wissen, wer der Benutzer ist.@RegisteredOAuth2AuthorizedClientruft das Access Token der Registrierungshopab. Wenn das Token abgelaufen ist und es einen Refresh Token gibt, erneuert Spring es automatisch vor Rückgabe.
Der Aufruf von /me nach der Anmeldung gibt zurück:
{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}
Hinweis:
Map.ofwirftNullPointerException, wenn ein Wertnullist. Der Claim
5. Ressourcen-Server mit JWT-Validierung aufbauen
5.1. Abhängigkeiten und Konfiguration
<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
Mit dieser Konfiguration validiert Spring jeden Request nach folgenden Schritten:
- Liest den öffentlichen Schlüssel vom
jwks_urides Ausstellers. - Validiert Signatur und
exp/nbf. - Validiert, dass
issmitissuer-uriübereinstimmt. - Validiert, dass
audorder-apienthält.
Das Überspringen der Audience-Validierung ist ein sehr häufiger Fehler. Dann akzeptiert die API sogar Tokens, die für einen anderen Service im selben Realm ausgegeben wurden.
5.2. Autorisierung nach 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());
}
}
Standardmäßig konvertiert Spring den Claim scope (oder scp) in Authorities mit Präfix SCOPE_. Daher wird der Scope orders.read zu SCOPE_orders.read.
Warum CSRF deaktivieren? Diese API ist zustandslos und akzeptiert nur Tokens über den Header Authorization, verwendet keine Cookies. Daher ist das Deaktivieren von CSRF angemessen.
Der Controller ruft sub aus dem Token ab, anstatt customerId aus der Query-Zeichenkette zu akzeptieren. Dadurch kann ein Benutzer keine Bestellungen anderer sehen.
Ergebnisse mit verschiedenen Request-Typen:
Kein Token -> 401 Unauthorized
Token gültig, aber Scope fehlt -> 403 Forbidden
Token gültig, mit orders.read -> 200 OK + Liste der Benutzerbestellungen
Hinweis: Keycloak speichert Rollen in
realm_access.roles, nicht imscope. Wenn Sie nach Rollen autorisieren möchten, deklarieren Sie einen benutzerdefiniertenJwtAuthenticationConverter, um diesen Claim aufGrantedAuthorityabzubilden.
6. Ressourcen-Server testen
Sie brauchen keinen echten Keycloak zum Testen. Die Bibliothek spring-security-test hat einen Post-Processor jwt(), um ein bereits authentifiziertes Token zu simulieren.
<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; // verhindert Aufruf des echten Ausstellers beim Starten
@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);
}
}
Diese drei Tests decken die drei Branches in Abschnitt 5.2 ab:
jwt()überspringt die Dekodierung. Es setzt direkt einenJwtAuthenticationTokeninSecurityContext, daher läuft der Test schnell und unabhängig.- Sie müssen
authorities(...)explizit übergeben.jwt()hat standardmäßig nurSCOPE_read, daher gibt der zweite Test 403 zurück. MockMvcTester(Spring Boot 3.4+) ermöglicht es, Assertions mit AssertJ direkt auf der Response zu schreiben.
7. Häufige Fehler und wann Sie OAuth 2.0 nicht verwenden sollten
ID Token zum Aufrufen einer API verwenden. ID Token hat aud als Client, nicht als API. Der korrekt konfigurierte Ressourcen-Server wird es ablehnen. Senden Sie immer das Access Token.
Access Token zur Anmeldung verwenden. Mit reinem OAuth 2.0 kann das Access Token eine undurchsichtige Zeichenkette sein und nichts über den Benutzer enthalten. Wenn der Client das Access Token selbst parst, um die Identität zu ermitteln, stützt er sich auf ein Format, für das es keine Garantie gibt. Verwenden Sie das ID Token oder den UserInfo-Endpoint.
Token in localStorage in einer SPA speichern. Eine einzige XSS-Sicherheitslücke reicht aus, um alle Tokens preiszugeben. Das sicherere Pattern ist BFF (Backend for Frontend). Die Spring-Anwendung in Abschnitt 4 behält das Token serverseitig, der Browser hat nur ein HttpOnly Session-Cookie.
Nicht validieren Sie aud, oder lassen Sie das Access Token zu lange gültig sein. JWTs können nicht widerrufen werden, bevor sie ablaufen. Halten Sie die Gültigkeitsdauer des Access Tokens kurz (5–15 Minuten) und verwenden Sie das Refresh Token zum Erneuern.
issuer-uri stimmt nicht überein. Wenn Keycloak iss als http://keycloak:8080/... zurückgibt (Hostname im Docker), aber Sie konfigurieren localhost:8180, werden alle Tokens mit invalid_token abgelehnt. Der iss-Wert muss Zeichen für Zeichen übereinstimmen.
Hinweis: Sie brauchen nicht immer OAuth 2.0. Wenn Sie nur ein einziges Monolith mit Formular-Login haben und kein Dritter die API aufrufen muss, ist die Standard-Session von Spring Security simpler und sicherer. Für Maschine-zu-Maschine-Kommunikation ohne Benutzer verwenden Sie den Grant
client_credentials, nicht Authorization Code.
8. Fazit
OAuth 2.0 beantwortet die Frage „was darf diese Anwendung tun” mit dem Access Token. OIDC beantwortet die Frage „wer ist der Benutzer” mit dem ID Token. Der Flow, den Sie verwenden sollten, ist Authorization Code + PKCE, mit state und nonce dabei.
Mit Spring Boot 3 haben Sie gesehen:
spring-boot-starter-oauth2-clientmitoauth2Loginübernimmt den gesamten OIDC-Login-Teil auf Client-Seite.spring-boot-starter-oauth2-resource-servervalidiert JWT nachissuer-uriundaudiences.spring-security-testermöglicht Scope-basierte Autorisierungstests ohne echten Authorization Server.
Der nächste Schritt könnte sein, Keycloak-Rollen mit einem benutzerdefinierten JwtAuthenticationConverter abzubilden oder den Client für eine SPA in einen BFF zu teilen.