やりたいこと
AWS Amplify の Authentication で認証したクライアントが id_tokenをサーバに送信し、Javaで実装したサーバで id_token を検証します。
TL;DR
- 発行者(ペイロードのiss) が対象にしている Cognitoのユーザプールであることを確認する。
- 署名の検証は https://github.com/auth0/java-jwt に丸投げ。
- com.auth0.jwt.JWTVerifier はスレッドセーフなのを前提にして、Cognitoが署名に使用した RSA鍵ペアを取得する回数を減らすために自前でキャッシュする。
実装
IdTokenValidator.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";
}
}
InvalidTokenException.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);
}
}
使う側
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