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:

  • state schützt vor CSRF beim Callback.
  • nonce bindet ID Token an die richtige Anmeldungssitzung.
  • PKCE stellt sicher, dass auch wenn jemand den code stiehlt, dieser nicht in ein Token umgewandelt werden kann, weil der code_verifier fehlt.

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:

  • oauth2Login aktiviert den gesamten Flow aus Abschnitt 3, einschließlich Umleitung, Callback, Code-Austausch und ID-Token-Validierung.
  • withPkce() fügt code_challenge auch für vertrauliche Clients hinzu (Client mit Secret). Öffentliche Clients haben PKCE bereits standardmäßig aktiviert.
  • OidcClientInitiatedLogoutSuccessHandler meldet 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:

  • OidcUser wird aus dem ID Token konstruiert (und UserInfo, falls vorhanden). Dies ist die richtige Methode, um zu wissen, wer der Benutzer ist.
  • @RegisteredOAuth2AuthorizedClient ruft das Access Token der Registrierung shop ab. 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.of wirft NullPointerException, wenn ein Wert null ist. Der Claim email existiert nur, wenn Sie den Scope email anfordern und der Benutzer eine E-Mail hat, daher müssen Sie immer den Fall fehlender Claims bearbeiten.

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_uri des Ausstellers.
  • Validiert Signatur und exp/nbf.
  • Validiert, dass iss mit issuer-uri übereinstimmt.
  • Validiert, dass aud order-api enthä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 im scope. Wenn Sie nach Rollen autorisieren möchten, deklarieren Sie einen benutzerdefinierten JwtAuthenticationConverter, um diesen Claim auf GrantedAuthority abzubilden.

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 einen JwtAuthenticationToken in SecurityContext, daher läuft der Test schnell und unabhängig.
  • Sie müssen authorities(...) explizit übergeben. jwt() hat standardmäßig nur SCOPE_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-client mit oauth2Login übernimmt den gesamten OIDC-Login-Teil auf Client-Seite.
  • spring-boot-starter-oauth2-resource-server validiert JWT nach issuer-uri und audiences.
  • spring-security-test ermö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.

oauth2openid-connectspring-securityjwtweb-security