AWS Cognitoで認証したユーザのID トークンを Java で検証する

## やりたいこと

[AWS Amplify の Authentication](https://aws-amplify.github.io/docs/js/authentication) で認証したクライアントが id_tokenをサーバに送信し、Javaで実装したサーバで id_token を検証します。

## TL;DR

- 発行者(ペイロードのiss) が対象にしている Cognitoのユーザプールであることを確認する。
- 署名の検証は https://github.com/auth0/java-jwt に丸投げ。
- com.auth0.jwt.JWTVerifier はスレッドセーフなのを前提にして、Cognitoが署名に使用した RSA鍵ペアを取得する回数を減らすために自前でキャッシュする。

## 実装

<details><summary>IdTokenValidator.java</summary><div>

```Java:IdTokenValidator.java
package com.exampleawsCognito.jwt;

import java.net.MalformedURLException;
import java.net.URL;
import java.security.interfaces.RSAPublicKey;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;

import com.auth0.jwk.GuavaCachedJwkProvider;
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;


/**
 * Cognitoで認証して得られるid tokenを扱う
 *
 * <p>
 * 実装の参考にしたソース
 * https://github.com/awslabs/cognito-proxy-rest-service/blob/master/src/main/kotlin/com/budilov/cognito/services/CognitoService.kt
 * </p>
 *
 * @ThreadSafe
 *
 */
public class IdTokenValidator {
	private static final String AWS_REGION = "ap-northeast-1";
	private static final String AWS_COGNITO_USER_POOL_ID = "my_userpool_id";

	private static JWTVerifier verifier = null;
	private static long verifierExpire = -1;
	private static final long VERIFIER_LIVE_MILISEC = 10 * 60 * 1000; //10分
	private static final JWT JWT = new JWT();

	/**
	 * コンストラクタ
	 */
	public IdTokenValidator() {
	}

	/**
	 * IDトークン を検証します
	 *
	 * @param idToken 検証対象のIDトークン
	 * @return 検証に成功した場合は ID Tokenのペイロード
	 *
	 * @throws InvalidTokenException ID Tokenの値が不正なので認証に失敗した
	 */
	public DecodedJWT verify(String idToken) throws InvalidTokenException {
		DecodedJWT decodedToken = JWT.decodeJwt(idToken);

		// cognitoのユーザプールで署名された事を確認する
		String iss = decodedToken.getIssuer();
		if (!jwtTokenIssuer().equals(iss)) {
			throw new InvalidTokenException("ID トークンの発行者が対象のシステムではありません。iss=" + iss, idToken);
		}

		//  ID トークンの用途が「ID」であることを確認します。
		String tokenUse = decodedToken.getClaim("token_use").asString();
		if (!"id".equals(tokenUse)) {
			throw new InvalidTokenException("ID トークンの用途が ID ではありません。token_use=" + tokenUse, idToken);
		}

		// 署名のアルゴリズムを確認します。
		String alg = decodedToken.getAlgorithm();
		if (!"RS256".equals(decodedToken.getAlgorithm())) {
			throw new InvalidTokenException("ID トークンの署名アルゴリズムが対応していないものです。alg =" + alg, idToken);
		}

		// payloadと署名を検証します。
		DecodedJWT decodedJWT = null;
		if ((decodedJWT = tokenVerify(decodedToken)) == null) {
			throw new InvalidTokenException("ID Tokenの検証に失敗しました。", idToken);
		}

		return decodedJWT;
	}

	/**
	 * auth0の のライブラリを利用して 検証を行う
	 *
	 * @param kid ID トークンのヘッダーにある キーID
	 * @return nullでなければ デコードされた ID トークン
	 *
	 * @throws InvalidTokenException 検証に失敗した
	 */
	private DecodedJWT tokenVerify(DecodedJWT jwToken) throws InvalidTokenException {

		try {
			DecodedJWT verified = getVerifier(jwToken.getKeyId()).verify(jwToken);
			return verified;
		} catch (Exception e) {
			throw new InvalidTokenException(e);
		}
	}

	/**
	 * JWTVerifier のインスタンスを取得する。
	 *
	 * <p>
	 * JWTVerifier は ver.3 からスレッドセーフになったので再利用する。
	 * ただし、署名に使われた RSAキーペアが更新される可能性を考えて、定期的に更新する
	 * </p>
	 *
	 * @param kid 署名に使われたキーID
	 *
	 * @return
	 *
	 * @throws MalformedURLException
	 * @throws JwkException
	 */
	private JWTVerifier getVerifier(String kid) throws MalformedURLException, JwkException {
		if (verifier != null && System.currentTimeMillis() < verifierExpire) {
			// 有効期限内ならそのまま使う
			return verifier;
		}
		synchronized (JWT) {
			// ロックを獲得したので念のためにもう一度確認してからインスタンスを生成する
			if (verifier != null && System.currentTimeMillis() < verifierExpire) {
				return verifier;
			}

			UrlJwkProvider http = new UrlJwkProvider(new URL(jwksUrl()));
			GuavaCachedJwkProvider provider = new GuavaCachedJwkProvider(http);
			Jwk jwk = provider.get(kid);

			Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);
			verifier = JWT.require(algorithm)
					.withIssuer(jwtTokenIssuer())
					.build();

			// JWTVerifier の寿命を延ばす
			verifierExpire = System.currentTimeMillis() + VERIFIER_LIVE_MILISEC;

			Calendar expire = GregorianCalendar.getInstance();
			expire.setTimeInMillis(verifierExpire);
			Logger.info("JWTVerifierのインスタンスを生成しました。期限は"
					+ new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(expire.getTime()));

		}

		return verifier;

	}

	/**
	 * ID トークンの発行者を取得します
	 *
	 * @return
	 */
	private String jwtTokenIssuer() {
		return String.format("https://cognito-idp.%s.amazonaws.com/%s", AWS_REGION, AWS_COGNITO_USER_POOL_ID);
	}

	/**
	 * JSON Web トークン (JWT) セットのURLを取得します。
	 *
	 * @return
	 */
	private String jwksUrl() {
		return jwtTokenIssuer() + "/.well-known/jwks.json";
	}

}

```
</div></details>

<details><summary>InvalidTokenException.java</summary><div>

```java:InvalidTokenException.class
public class InvalidTokenException extends Exception {

    public InvalidTokenException(String message, String idToken) {
        super(message + " token is " + idToken);
    }

    public InvalidTokenException(Throwable e) {
        super(e);
    }
}

```
</div></details>


```Java:使う側
    try{
        // TODO IdTokenValidator はスレッドセーフなので static にする
        DecodedJWT payload = new IdTokenValidator().verify(idToken);
        String cognitoUserName = payload.getClaim("cognito:username").asString();

        // 必要な処理

    }catch (InvalidTokenException e) {
        Logger.error("ID トークンの検証に失敗しました", e);
        badRequest("IdTokenの値が不正ですよ");
    }
```

## 参考

- https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html
- https://aws.amazon.com/jp/premiumsupport/knowledge-center/decode-verify-cognito-json-token/
- https://qiita.com/ya-mada/items/154ea6e10f9f788bfdd5
- https://openid.net/developers/jwt/
- http://openid-foundation-japan.github.io/rfc7517.ja.html
- https://github.com/awslabs/cognito-proxy-rest-service/blob/master/src/main/kotlin/com/budilov/cognito/services/CognitoService.kt