---
title: Spring BootでJWT認証・認可の設定メモ
summary: この記事では、Spring SecurityのJWT認証機能を使い、トークン検証・認可なしでREST APIを実装し、OpenSSLで鍵生成・トークン発行まで解説します。
tags: ["Java", "Spring Boot", "Spring Security", "JWT", "OIDC", "OpenSSL"]
categories: ["Programming", "Java", "org", "springframework", "security", "oauth2", "jwt"]
date: 2024-09-03T06:48:08Z
updated: 2024-09-04T07:53:16Z
---

"Spring Boot JWT"でGoogle検索するとヒットする多くの記事・チュートリアルは`JwtAuthenticationFilter`を自作して、JWTの検証が不十分だったりします。
Spring SecurityにはJWT認証・認可が用意されています。
https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
これを使えば`JwtAuthenticationFilter`を作ることなく、フレームワークにトークンの検証や認証・認可を任せることができます。

この記事ではSpring SecurityのJWT認証を使って、簡単なREST APIの認証・認可を実装します。

以下の作業はLinux(Ubuntu)上で検証しています。Macだと一部のコマンドの引数が異なり、エラーが出ると思います。

**目次**
<!-- toc -->

### 雛形プロジェクトの作成

まずはSpring Initializrで雛形プロジェクトを作成します。

```bash
curl -s https://start.spring.io/starter.tgz \
       -d artifactId=hello-jwt \
       -d baseDir=hello-jwt \
       -d packageName=com.example \
       -d dependencies=web,actuator,security,configuration-processor \
       -d type=maven-project \
       -d name=hello-jwt \
       -d applicationName=HelloJwtApplication | tar -xzvf -
cd hello-jwt
```

Spring SecurityにはJWT認証・認可機能を使う場合は、以下のdependencyを`pom.xml`に追加する必要があります。

```xml
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
		</dependency>
```

### JWTの署名・検証用のRSA鍵をOpenSSLで生成

次のコマンドでJWTの署名と検証用の鍵を生成します。

```bash
cd src/main/resources

openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
openssl pkcs8 -topk8 -inform PEM -in private.pem -out private_key.pem -nocrypt
rm -f private.pem
```

`private_key.pem`が署名用の秘密鍵で、`public.pem`が検証用の公開鍵です。

### JWTで認証・認可されたREST APIの作成

メッセージを読み書きする簡単なREST APIを作成します。Spring Securityの機能を使えば、Controllerの引数に`@AuthenticationPrincipal`をつけて認証済みの`Jwt`オブジェクトを取得できます。

```java
cat <<'EOF' > src/main/java/com/example/MessageController.java
package com.example;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MessageController {
	private final List<Message> messages = new CopyOnWriteArrayList<>();

	@GetMapping(path = "/messages")
	List<Message> getMessages() {
		return this.messages;
	}

	@PostMapping(path = "/messages")
	Message postMessages(@RequestBody String text, @AuthenticationPrincipal Jwt jwt) {
		Message message = new Message(text, jwt.getSubject());
		this.messages.add(message);
		return message;
	}

	record Message(String text, String username) {
	}
}
EOF
```

JWT認証・認可のための設定は以下の通りです。
ここでメッセージの書き込みには`message:write`、読み込みには`message:read`スコープがJWTに含まれていないといけない、とします。

```java
cat <<'EOF' > src/main/java/com/example/SecurityConfig.java
package com.example;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		return http
				.authorizeHttpRequests(authz -> authz
						.requestMatchers(HttpMethod.GET, "/messages").access(hasScope("message:read"))
						.requestMatchers(HttpMethod.POST, "/messages").access(hasScope("message:write"))
						.anyRequest().permitAll())
				.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> {
				}))
				.csrf(csrf -> csrf.disable())
				.build();
	}
}
EOF
```

JWTを検証するための設定を`application.properties`に記述します。今回は公開鍵を直接指定します。

```properties
cat <<'EOF' > src/main/resources/application.properties
spring.application.name=hello-jwt
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:public.pem
EOF
```

> [!NOTE] OpenID Connect ProviderからJWTを取得する場合は、`spring.security.oauth2.resourceserver.jwt.public-key-location`ではなく、
> ```properties
> spring.security.oauth2.resourceserver.jwt.issuer-uri=<OIDC Issuer URI>
> ```
> を設定すると良いです。起動時にSpring Securityが`<OIDC Issuer URI>/.well-known/openid-configuration`にアクセスして、`jwks_uri`キーに設定されたURIから公開鍵をダウンロードします。
> 本記事では後に同一アプリケーション内でトークンの生成まで行います。その場合は、`spring.security.oauth2.resourceserver.jwt.issuer-uri`は使えません。

アプリを起動します。

```
./mvnw spring-boot:run
```

### JWTをOpenSSLで生成

JWTをコマンドラインで作成してみましょう。`application.properties`にに設定した`public.pem`とペアとなる`private_key.pem`を使ってJWTを署名します。

まず、JWTのヘッダーとペイロードを準備する必要があります。ヘッダーとペイロードをBase64エンコードし、それらを連結した文字列を生成します。その後、この連結文字列に対してRSA署名を行います。
ヘッダーとペイロードを作成し、それをBase64エンコードします。例えば、以下のようなヘッダーとペイロードを使用します。

ヘッダー:
```json
{
  "alg": "RS256",
  "typ": "JWT"
}
```

ペイロード:
```json
{
  "sub": "foo@example.com",
  "issuer": "http://localhost:8080",
  "scope": ["message:read", "message:write"],
  "iat": 1725333684,
  "exp": 1725355284
}
```

以下のコマンドで、ヘッダーとペイロードのJSONをBase64エンコードし、さらにそれらを`.`で連結した文字列をRSA署名します。そして、ヘッダー・ペイロード・署名を`.`で結合してJWTができます。

```sh
IAT=$(date +%s)
EXP=$(date -d '+10 min' +%s)
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n '{"sub":"foo@example.com","issuer":"http://localhost:8080","scope":["message:read","message:write"],"iat":'$IAT',"exp":'$EXP'}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign private_key.pem | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}"

echo "$JWT"
```


[jwt.io](https://jwt.io/)にアクセスし、JWTとRSA鍵を貼り付けると、"Signature Verified"を表示されるでしょう。

<img width="1024" alt="image" src="https://github.com/user-attachments/assets/d77873fc-fa30-4ff7-96eb-4da47bdf2412">


これにより今回のREST APIに対して使用できるJWTを手動で生成できます。


```bash
IAT=$(date +%s)
EXP=$(date -d '+10 min' +%s)
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n '{"sub":"foo@example.com","issuer":"http://localhost:8080","scope":["message:read","message:write"],"iat":'$IAT',"exp":'$EXP'}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign private_key.pem | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}"
```

このJWTを使ってメッセージを書き込みましょう。

```bash
$ curl http://localhost:8080/messages -H "Authorization: Bearer $JWT" -H "Content-Type: text/plain" -d "Hello World"
{"text":"Hello World","username":"foo@example.com"}
```

JWTの`sub` Claimに指定した値がユーザー名として使われていることを確認できます。

同じくメッセージを取得します。

```bash
$ curl http://localhost:8080/messages -H "Authorization: Bearer $JWT"
[{"text":"Hello World","username":"foo@example.com"}]
```

今度は`message:read` scopeしかないJWTを生成します。

```bash
IAT=$(date +%s)
EXP=$(date -d '+10 min' +%s)
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
PAYLOAD=$(echo -n '{"sub":"foo@example.com","issuer":"http://localhost:8080","scope":["message:read"],"iat":'$IAT',"exp":'$EXP'}' | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign private_key.pem | openssl enc -base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}"
```

このJWTを使ってメッセージを書き込もうとすると、401エラーになります。

```
curl http://localhost:8080/messages -H "Authorization: Bearer $JWT" -H "Content-Type: text/plain" -d "Hello World" -v
```

エラーメッセージは`WWW-Authenticate`ヘッダーに設定されます。

```
< WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
```

今回は有効期限が10分のJWTを作成しました。10分経過した後にこのJWTを使うと、401エラーになり、次のメッセージが返ります。

```
< WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2024-09-03T03:38:43Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
```

また、アプリに設定した公開鍵とはペアではない秘密鍵を新規に生成して署名したJWTを使った場合は、次のエラーメッセージが返ります。

```
< WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
```

### JWTを生成するAPIの作成

JWTの生成やユーザーの管理は外部のOIDC Providerに任せる方が安全ですが、Basic認証の代わりに気軽にJWT認証を使いたいというケースでは同一のアプリ内でJWTを生成したいこともあるでしょう。

Spring Securityでは[Spring Authorization Server](https://spring.io/projects/spring-authorization-server)という別プロジェクトを使うことでOIDC Provider/OAuth2認可サーバーを実装することもできますが、
OAuth2やOIDCに準拠するほどでなく、Basic認証の代わりとなるようなシンプルなトークンエンドポイントが欲しいだけであれば、次のように実装できます。

まずは、公開鍵と秘密鍵をプロパティから取得できるようにします。

```java
cat <<'EOF' > src/main/java/com/example/JwtProperties.java
package com.example;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

import org.springframework.boot.context.properties.ConfigurationProperties;


@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {

}
EOF
```

プロパティを読み込めるように`@ConfigurationPropertiesScan`アノテーションをメインクラスにつけます。

```java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan; // <---

@SpringBootApplication
@ConfigurationPropertiesScan // <---
public class HelloJwtApplication {

	public static void main(String[] args) {
		SpringApplication.run(HelloJwtApplication.class, args);
	}

}
```

鍵のパスを`application.properties`に定義します。

```properties
cat <<'EOF' > src/main/resources/application.properties
jwt.private-key=classpath:private_key.pem
jwt.public-key=classpath:public.pem
spring.application.name=hello-jwt
spring.security.oauth2.resourceserver.jwt.public-key-location=${jwt.public-key}
EOF
```

秘密鍵を使ってJWT署名するための`TokenSigner`を作成します。署名にはSpring Security内で使われている[nimbus-jose-jwt](https://connect2id.com/products/nimbus-jose-jwt)を使用します。

```java
cat <<'EOF' > src/main/java/com/example/TokenSigner.java
package com.example;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

@Component
public class TokenSigner implements InitializingBean {
	private final JWSSigner signer;

	private final JWSVerifier verifier;

	public TokenSigner(JwtProperties jwtProps) {
		this.signer = new RSASSASigner(jwtProps.privateKey());
		this.verifier = new RSASSAVerifier(jwtProps.publicKey());
	}

	public SignedJWT sign(JWTClaimsSet claimsSet) {
		JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
				.type(JOSEObjectType.JWT)
				.build();
		SignedJWT signedJWT = new SignedJWT(header, claimsSet);
		try {
			signedJWT.sign(this.signer);
		}
		catch (JOSEException e) {
			throw new IllegalStateException(e);
		}
		return signedJWT;
	}

	@Override
	public void afterPropertiesSet() throws Exception {
		// Validate the key-pair
		JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().subject("test").build();
		SignedJWT signedJWT = sign(claimsSet);
		if (!signedJWT.verify(this.verifier)) {
			throw new IllegalStateException("The pair of public key and private key is wrong.");
		}
	}
}
EOF
```

Tokenを生成するControllerを作成します。

```java
cat <<'EOF' > src/main/java/com/example/TokenController.java
package com.example;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.Set;

import com.nimbusds.jwt.JWTClaimsSet;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;

import static org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER;

@RestController
public class TokenController {
	private final TokenSigner tokenSigner;

	private final AuthenticationManager authenticationManager;

	private final Clock clock;

	public TokenController(TokenSigner tokenSigner, AuthenticationManager authenticationManager, Clock clock) {
		this.tokenSigner = tokenSigner;
		this.authenticationManager = authenticationManager;
		this.clock = clock;
	}

	@PostMapping(path = "/token")
	public Object issueToken(@RequestParam String username, @RequestParam String password, UriComponentsBuilder builder) {
		try {
			Authentication authenticated = authenticationManager.authenticate(UsernamePasswordAuthenticationToken.unauthenticated(username, password));
			UserDetails userDetails = (UserDetails) authenticated.getPrincipal();
			String issuer = builder.path("").build().toString();
			Instant issuedAt = Instant.now(this.clock);
			Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
			Set<String> scope = Set.of("message:read", "message:write");
			JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
					.issuer(issuer)
					.expirationTime(Date.from(expiresAt))
					.subject(userDetails.getUsername())
					.issueTime(Date.from(issuedAt))
					.claim("scope", scope)
					.build();
			String tokenValue = this.tokenSigner.sign(claimsSet).serialize();
			return ResponseEntity.ok(Map.of("access_token", tokenValue,
					"token_type", BEARER.getValue(),
					"expires_in", Duration.between(issuedAt, expiresAt).getSeconds(),
					"scope", scope));
		}
		catch (AuthenticationException e) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
					.body(Map.of("error", "unauthorized",
							"error_description", e.getMessage()));
		}
	}
}
EOF
```


Controllerで`AuthenticationManager`を使うために`SecurityConfig`に以下の設定を追加します。

```java
import java.time.Clock;

// ...
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

// ...

	@Bean
	public Clock clock() {
		return Clock.systemUTC();
	}

	// https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html#publish-authentication-manager-bean
	@Bean
	public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
		DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
		authenticationProvider.setUserDetailsService(userDetailsService);
		authenticationProvider.setPasswordEncoder(passwordEncoder);
		return new ProviderManager(authenticationProvider);
	}

	@Bean
	public UserDetailsService userDetailsService() {
		UserDetails userDetails = User.withUsername("bar@example.com")
				.password("{noop}secret")
				.roles("USER")
				.build();
		return new InMemoryUserDetailsManager(userDetails);
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
```

アプリケーションを再起動して、次のコマンドでトークンを発行します。

```bash
$ curl -s http://localhost:8080/token -d username=bar@example.com -d password=secret | jq .
{
  "scope": [
    "message:write",
    "message:read"
  ],
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOi8vaG9zdC5vcmIuaW50ZXJuYWw6ODA4MCIsInN1YiI6ImJhckBleGFtcGxlLmNvbSIsImV4cCI6MTcyNTM0MzA0MSwiaWF0IjoxNzI1MzM5NDQxLCJzY29wZSI6WyJtZXNzYWdlOndyaXRlIiwibWVzc2FnZTpyZWFkIl19.wxraPTTuQFb4gEPfmUUXBuHAd6nLgiCVO6gTbPw5lYam1XQfe8m1c1HgapI0HwzkUEW4VT243t6E2erl43F7RWeVSsSVfiT5vGogqYd6iwVQ1mK2BPrGPyWyT8jUw0yuVGzHw03tpK6oBhL8j90R2CX1kUsWslQDQu2w1JPcP7MTeD0vN-gHj_dapx-ClBo5CtO7rLLvNc0US6REBbIisI45DTQliR3HypoZN8YaGHyaal2Q6uIi9JnL2Zow9VW4l8Drol-oIGdM-sx_ZrpHu3xmVpZfv2o-q4pAGEMvtYW_l1buzHB8anSuW2xV6AvPH5YF2jHYE6LDtuxach-2AQ",
  "token_type": "Bearer",
  "expires_in": 3600
}
```

JWTを取り出し、メッセージAPIにアクセスします。

```bash
JWT=$(curl -s http://localhost:8080/token -d username=bar@example.com -d password=secret | jq -r .access_token)

curl http://localhost:8080/messages -H "Authorization: Bearer $JWT" -H "Content-Type: text/plain" -d "Hello World"
curl http://localhost:8080/messages -H "Authorization: Bearer $JWT"
```


### Integration Testの作成

最後に簡単なIntegration Testを作成します。

```java
cat <<'EOF' > src/test/java/com/example/HelloJwtApplicationTests.java
package com.example;

import java.util.List;

import com.example.MessageController.Message;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.NoOpResponseErrorHandler;
import org.springframework.web.client.RestClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureJsonTesters
class HelloJwtApplicationTests {
	@LocalServerPort
	int port;

	RestClient restClient;

	@Autowired
	JacksonTester<Message> messageTester;

	@Autowired
	JacksonTester<List<Message>> listTester;

	@BeforeEach
	void setUp(@Autowired RestClient.Builder restClientBuilder) {
		this.restClient = restClientBuilder
				.baseUrl("http://localhost:" + port)
				.defaultStatusHandler(new NoOpResponseErrorHandler())
				.build();
	}

	@Test
	void issueTokenUsingValidCredentialsAndAccessMessageApi() throws Exception {
		String token;
		{
			ResponseEntity<JsonNode> response = this.restClient.post()
					.uri("/token")
					.contentType(MediaType.APPLICATION_FORM_URLENCODED)
					.body("username=bar@example.com&password=secret")
					.retrieve()
					.toEntity(JsonNode.class);
			assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
			assertThat(response.getBody()).isNotEmpty();
			token = response.getBody().get("access_token").asText();
		}
		{
			ResponseEntity<Message> response = this.restClient.post()
					.uri("/messages")
					.contentType(MediaType.TEXT_PLAIN)
					.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
					.body("Hello World")
					.retrieve()
					.toEntity(Message.class);
			assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
			assertThat(response.getBody()).isNotNull();
			assertThat(this.messageTester.write(response.getBody())).isEqualToJson("""
					{
					  "username": "bar@example.com",
					  "text": "Hello World"
					}
					""");
		}
		{
			ResponseEntity<List<Message>> response = this.restClient.get()
					.uri("/messages")
					.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
					.retrieve()
					.toEntity(new ParameterizedTypeReference<>() {
					});
			assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
			assertThat(response.getBody()).isNotNull();
			assertThat(this.listTester.write(response.getBody())).isEqualToJson("""
					[
						{
						  "username": "bar@example.com",
						  "text": "Hello World"
						}
					]
					""");
		}
	}

	@Test
	void issueTokenUsingInvalidCredentials() {
		assertThatThrownBy(() -> this.restClient.post()
				.uri("/token")
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.body("username=bar@example.com&password=bar")
				.retrieve()
				.toEntity(JsonNode.class))
				.isInstanceOf(HttpClientErrorException.Unauthorized.class);
	}

}
EOF
```

テストがパスすればOKです。

```
./mvnw clean test
```

---

Spring Boot + SecurityでJWT認証・認可を試しました。

作成したコードは https://github.com/making/hello-jwt です。

その他JWTの`audience` Claimを検証したり、カスタム検証したりできます。詳しくは[ドキュメント](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)を参照してください。
