OAuth 2.0とOpenID ConnectのSpring Boot 3での実装例
1. 概要
OAuth 2.0とOpenID Connect (OIDC)はしばしば一緒に言及されるため、多くの人が両者を同じものだと考えています。しかし実は、両者は異なる問題を解決しています。OAuth 2.0は認可(アプリケーションがユーザーの代わりにAPIを呼び出すことを許可する)を扱います。OIDCは認証(ユーザーが誰であるかを知る)を扱います。
この記事では、Authorization Code + PKCEフローについて理解します。その後、2つのSpring Boot 3アプリケーションを構築します。1つはOIDCでログインするクライアント、もう1つはJWT形式のアクセストークンを検証するリソースサーバーです。記事の最後には、リソースサーバーのテストと一般的なエラーのリストが含まれています。
2. OAuth 2.0とOpenID Connectの違い
2.1. OAuth 2.0の役割
OAuth 2.0は4つの役割を定義しています:
- Resource Owner: ユーザー、データの所有者(例:注文を持つ顧客)。
- Client: データにアクセスしたいアプリケーション(例:webアプリケーション
shop-web)。 - Authorization Server: ユーザーがログインして権限を付与し、トークンを発行する場所。例:Keycloak、Okta、Auth0。
- Resource Server: データを含むAPI(例:
order-api)。
OAuth 2.0の結果はアクセストークンです。このトークンはリソースサーバー用で、クライアント用ではありません。クライアントは単にそれを保持して送信するだけです。クライアントはトークンを読んでユーザーが誰であるかを推測してはいけません。
2.2. OIDCが追加する機能
OIDCはOAuth 2.0上に構築されたレイヤーです。クライアントがopenidスコープを要求する場合、認可サーバーはIDトークンを追加で返します。これは常にJWTで、署名されており、ログイン情報が含まれています:
{
"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"
}
重要なクレームには以下が含まれます:
iss: トークンを発行した者。sub: ユーザーの一意で安定な識別子。aud: このトークンがどのクライアント用か。nonce: リプレイ攻撃を防止します。
次のようにまとめることができます:
Access token -> API (resource server)に送信 -> 「何ができるか」
ID token -> クライアントが読んでログイン -> 「ユーザーは誰か」
3. Authorization Code + PKCEフロー
webアプリケーション、SPA、およびモバイルアプリケーションでは、Authorization Codeフローを使用する必要があります。これはPKCE(Proof Key for Code Exchange)と組み合わせて使用されます。Implicit flowとPassword grantは推奨されなくなっています。
1. ユーザーがshop-web上の「ログイン」をクリック
2. shop-webがランダムなcode_verifierを生成し、code_challenge = SHA256(code_verifier)を計算
3. ブラウザを次へリダイレクト:
/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. ユーザーがKeycloakでログインし、Keycloakが以下にリダイレクト:
/login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web(バックチャネル)がcode + code_verifier + client_secretを含むPOST /tokenを呼び出す
6. Keycloakが返す: access_token、id_token、refresh_token
7. shop-webがid_tokenを検証(署名、iss、aud、exp、nonce)-> セッションを作成
8. shop-webがorder-apiをheader Authorization: Bearer <access_token>で呼び出す
各パラメータが特定の種類の攻撃を防ぎます:
stateはコールバックステップでのCSRF攻撃を防ぎます。nonceはIDトークンを正しいログインセッションに関連付けます。- PKCEにより、たとえ誰かが
codeを盗んでも、code_verifierがないため、それをトークンに交換することはできません。
良いニュースは、Spring Securityがほぼすべてのステップを自動で実行することです。
4. OIDCでログインするクライアントの構築
4.1. 依存性と設定
<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>
Keycloakがポート8180で動作し、realm shopと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
issuer-uriを宣言するだけで十分です。Springは/.well-known/openid-configurationを読んで、authorize、token、jwks、およびend_sessionエンドポイントを自動的に取得します。スコープopenidは、OAuth 2.0フローをOIDCログインに変えるものです。
4.2. セキュリティ設定
@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();
}
}
この設定は3つのことを行います:
oauth2Loginはセクション3のフロー全体を有効にします。リダイレクト、コールバック、コード交換、IDトークン検証が含まれます。withPkce()は、confidential client(secretを持つクライアント)にもcode_challengeを追加します。Public clientはSpringにより既にPKCEが有効です。OidcClientInitiatedLogoutSuccessHandlerはKeycloak側でもログアウトします(RP-initiated logout)。これがないと、ユーザーが「ログイン」をクリックしても、パスワードを入力せずに直接ログインしてしまいます。
4.3. ユーザー情報の取得と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(), "(なし)"),
"name", Objects.requireNonNullElse(user.getFullName(), "(なし)"));
}
@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);
}
}
2つのエンドポイントは異なる種類のトークンを使用しています:
OidcUserはIDトークン(およびUserInfoがある場合)から構築されます。これはユーザーが誰であるかを知る正しい方法です。@RegisteredOAuth2AuthorizedClientはregistrationshopのアクセストークンを取得します。トークンが期限切れで、リフレッシュトークンがある場合、Springは返す前に自動的に更新します。
ログイン後に/meを呼び出すと、以下が返されます:
{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}
注記:
Map.ofは値がnullの場合、NullPointerExceptionをスローします。クレーム
5. JWTを検証するリソースサーバーの構築
5.1. 依存性と設定
<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
この設定により、Springは各リクエストを次のようにチェックします:
- issuerの
jwks_uriから公開鍵を取得します。 - 署名と
exp/nbfを検証します。 issがissuer-uriと一致することを確認します。audにorder-apiが含まれていることを確認します。
audienceチェックを忘れるのは非常に一般的なエラーです。その場合、APIは同じrealm内の別のサービス用に発行されたトークンも受け入れてしまいます。
5.2. スコープに基づく認可
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());
}
}
デフォルトでは、Springはクレームscope(またはscp)をSCOPE_プレフィックス付きの権限に変換します。したがって、スコープorders.readはSCOPE_orders.readになります。
なぜCSRFを無効にするのか?このAPIはステートレスで、ヘッダーAuthorizationを通じてのみトークンを受け入れ、cookieを使用しません。したがって、CSRFを無効にするのは適切です。
コントローラーはクエリ文字列からcustomerIdを受け取るのではなく、トークンからsubを取得します。これにより、ユーザーは他の人の注文を表示できません。
リクエスト種別ごとの結果:
トークンなし -> 401 Unauthorized
トークンは有効だがスコープ不足 -> 403 Forbidden
トークンが有効でorders.readあり -> 200 OK + ユーザー自身の注文リスト
注記: Keycloakはroleを
realm_access.rolesに配置し、scopeには含まれません。ロールに基づいて認可を行いたい場合は、そのクレームをGrantedAuthorityにマッピングするカスタムJwtAuthenticationConverterを宣言してください。
6. リソースサーバーのテスト
テストのために実際のKeycloakは必要ありません。ライブラリspring-security-testは、認証済みトークンをシミュレートするpost-processor jwt()を提供しています。
<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; // コンテキスト起動時に実際のissuerを呼ばないようにする
@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);
}
}
これら3つのテストはセクション5.2の3つのブランチをカバーしています:
jwt()はデコードステップをスキップします。SecurityContextに直接JwtAuthenticationTokenを配置するため、テストは高速かつ独立しています。authorities(...)を明示的に渡す必要があります。jwt()のデフォルトはSCOPE_readのみであるため、2番目のテストは403を受け取ります。MockMvcTester(Spring Boot 3.4+)により、AssertJを使用してレスポンスに対して直接アサーションを書くことができます。
7. 一般的なエラーと使用してはいけない場合
IDトークンを使用してAPIを呼び出す。 IDトークンのaudはクライアントであり、APIではありません。正しく設定されたリソースサーバーはそれを拒否します。常にアクセストークンを送信してください。
アクセストークンを使用して認証する。 純粋なOAuth 2.0では、アクセストークンは不透明な文字列である可能性があり、ユーザーについて何も含まれません。しかし、クライアントが自身でアクセストークンJWTを解析して識別情報を取得する場合、それは保証されていない形式に依存しています。IDトークンまたはUserInfoエンドポイントを使用してください。
SPAでlocalStorageにトークンを保存する。 単一のXSS脆弱性でトークン全体が露出する可能性があります。より安全なパターンはBFF(Backend for Frontend)です。セクション4のSpringアプリケーションはトークンをサーバー側に保持し、ブラウザはHttpOnlyセッションcookieのみを持ちます。
audを検証しない、またはアクセストークンを長すぎる期間有効にする。 JWTは失効するまで取り消すことはできません。アクセストークンの有効期限を短く保つ(5~15分)し、リフレッシュトークンを使用して延長してください。
issuer-uriが一致しない。 Keycloakがissとしてhttp://keycloak:8080/...(Docker内のホスト名)を返すが、あなたがlocalhost:8180を設定している場合、すべてのトークンがinvalid_tokenエラーで拒否されます。issの値は1文字ごとに正確に一致する必要があります。
注記: 常にOAuth 2.0が必要なわけではありません。monolithアプリケーションが1つあり、フォームログインとサードパーティがAPIを呼び出す必要がない場合は、通常のSpring Securityセッションがより単純で安全です。マシン間通信でユーザーがいない場合は、Authorization Codeではなく
client_credentialsgrantを使用してください。
8. 結論
OAuth 2.0は「このアプリケーションは何ができるか」という質問にアクセストークンで答えます。OIDCは「ユーザーは誰か」という質問にIDトークンで答えます。使用すべきフローはAuthorization Code + PKCEで、stateとnonceが伴います。
Spring Boot 3では、次のことが見られました:
spring-boot-starter-oauth2-clientとoauth2Loginはクライアント側のOIDCログイン全体を処理します。spring-boot-starter-oauth2-resource-serverはissuer-uriとaudiencesに従ってJWTを検証します。spring-security-testにより、実際の認可サーバーなしでスコープベースの認可をテストできます。
次のステップとして、JwtAuthenticationConverterを使用してKeycloakのロールをマップするか、クライアントをSPA用のBFFに分離してみることができます。