--- title: Spring BootでJWT認証・認可の設定メモ 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だと一部のコマンドの引数が異なり、エラーが出ると思います。 **目次** ### 雛形プロジェクトの作成 まずは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 org.springframework.boot spring-boot-starter-oauth2-resource-server ``` ### 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 messages = new CopyOnWriteArrayList<>(); @GetMapping(path = "/messages") List 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= > ``` > を設定すると良いです。起動時にSpring Securityが`/.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"を表示されるでしょう。 image これにより今回の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 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 messageTester; @Autowired JacksonTester> 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 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 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> 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)を参照してください。