NHN Cloud NHN Cloud Meetup!

JWT(JSON Web Token)の紹介

はじめに

TOASTクラウドメッセージングプラットフォームサービスの1つであるPushに追加されたAPNs(Apple Push Notification service)JWT認証機能について、開発中に行った技術調査の内容を共有します。この記事は「JWTの概要」と「JWTをさらに詳しく」の2部構成になっています。「JWTの概要」では、JWTの構造や作成および検証方法について説明し、「JWTをさらに詳しく」では、JWTの特徴や使用事例について紹介します。JWTを利用した機能を開発する際や、JWTを使用する開発者の方に役立つ内容になれば幸いです。

気になった点があれば、LinkedInやGitHubからご連絡お願いいたします。

JWT(JSON Web Token)の概要

JWTは、一般的にクライアント-サーバー間、サービス-サービス間の通信時の認証(Authorization)に使用されるトークンです。URLに対して安全な文字列で構成されているため、HTTPのどの場所にも(URL、ヘッダー、…)位置することができます。これがJWTの正確な定義ではありませんが、詳細は「JWTをさらに詳しく」でもう一度説明します。

構造と作成

HEADER.PAYLOAD.SIGNATURE

ヘッダー(Header)、ペイロード(Payload)、署名(Signature)の3つの部分を、ドット(.)で区切りで連結させる構造です。

JWTを検証するために必要な情報を持つJSONオブジェクトは、Base64 URL-Safeにエンコードされた文字列です。ヘッダー(Header)は、JWTの検証(Verify)方法の内容を含んでいます。参考までに、algは署名に使用するアルゴリズム、kidは署名に使用するキー(Public / Private Key)を識別する値です。

{
    "alg": "ES256",
    "kid": "Key ID"
}

上記のようなJSONオブジェクトを文字列に直列化し、UTF-8とBase64 URL-Safeにエンコードすると、次のようにヘッダーを作成することができます。

Base64URLSafe(UTF-8('{"alg": "ES256","kid": "Key ID"}')) -> eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9

Payload

ペイロード(Payload)の属性をクレームセット(Claim Set)と呼びます。クレームセットは、JWTに関する内容(トークンコンストラクターの情報、作成日時など)や、クライアントとサーバー間で送受信した値で構成されています。

{
    "iss": "jinho.shin",
    "iat": "1586364327"
}

上記のようなJSONオブジェクトを文字列で直列化し、Base64 URL-Safeにエンコードすると、次のようにペイロードを作成することができます。

Base64URLSafe('{"iss": "jinho.shin","iat": "1586364327"}') -> eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLnNoaW4ifQ

Signature

ドット(.)を区切り記号として、ヘッダーとペイロードを合わせた文字列に署名した値です。署名は、ヘッダーのalgに定義されたアルゴリズムと秘密鍵を使って作成し、Base64 URL-Safeにエンコードします。

Base64URLSafe(Sign('ES256', '${PRIVATE_KEY}',
'eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLnNoaW4ifQ'))) ->
MEQCIBSOVBBsCeZ_8vHulOvspJVFU3GADhyCHyzMiBFVyS3qAiB7Tm_MEXi2kLusOBpanIrcs2NVq24uuVDgH71M_fIQGg

JWT

ドットを区切り記号にして、ヘッダー、ペイロード、署名を合わせると、JWTが完成します。

eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLn
NoaW4ifQ.eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRC9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6Imp
pbmhvLnNoaW4ifQ.MEQCIBSOVBBsCeZ_8vHulOvspJVFU3GADhyCHyzMiBFVyS3qAiB7Tm_ME
Xi2kLusOBpanIrcs2NVq24uuVDgH71M_fIQGg

このように完成したJWTは、ヘッダーのalg、kid属性と公開鍵を使って検証が可能です。署名検証が成功すると、JWTのすべての内容を信頼できるようになり、ペイロードの値でアクセス制御や希望する処理を行うことができます。

実装方法

1. Public / Private Keyの生成

JWTの作成と検証に必要な公開鍵と秘密鍵を生成します。ここでは鍵生成アルゴリズムとして、ECDSA(Elliptic Curve Digital Signature Algorithm, 楕円曲線デジタル署名アルゴリズム)の1つであるES256(P-256 +SHA256)を使用します。ブロックチェーンで使用されるアルゴリズムですが、JWTでもよく使われています。

/**
 * Java APIを利用してES256キーを作成
 *
 * @throws NoSuchAlgorithmException
 * @throws InvalidAlgorithmParameterException
 */
@Test
public void test_pure_java_generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException {
    // Given
    final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
    keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); // == P256

    // When
    final KeyPair keyPair = keyPairGenerator.generateKeyPair();

    // Then
    // Nothing Happen
    log.info("ecKey.publicKey: {}", Base64.encodeBase64String(keyPair.getPublic().getEncoded()));
    log.info("ecKey.privateKey: {}", Base64.encodeBase64String(keyPair.getPrivate().getEncoded()));
}
公開鍵: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKY/2QKid9XCTRWCusDHUddgjWUTskYpY2wj
WcgZ6vVfBlYRL0UhyLGbgBpucjGGjRAYoWRvn83f+GhAfiqmydw==
秘密鍵: MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBfWNacqAsGHMnGbWiZXR81
mRvB4w/Icva0jGFPduwBxQ==

2. JWT作成

上記で作成されたキーをJavaの公開鍵(ECPublicKey)と秘密鍵(ECPrivateKey)にロードします。そして、ヘッダーとペイロードをエンコードして両者を合わせた文字列を秘密鍵で署名します。

private static ECPublicKey EC_PUBLIC_KEY;
private static ECPrivateKey EC_PRIVATE_KEY;

/**
 * PEM形式のキーをJavaのECPublicKey, ECPrivateKeyに変換
 *
 * @throws NoSuchAlgorithmException
 * @throws InvalidKeySpecException
 */
@BeforeAll
public static void beforeAll() throws NoSuchAlgorithmException, InvalidKeySpecException {
    final KeyFactory keyPairGenerator = KeyFactory.getInstance("EC"); // EC is ECDSA in Java

    EC_PUBLIC_KEY = (ECPublicKey) keyPairGenerator.generatePublic(new X509EncodedKeySpec(Base64.decodeBase64("上から作成した公開鍵")));
    EC_PRIVATE_KEY = (ECPrivateKey) keyPairGenerator.generatePrivate(new PKCS8EncodedKeySpec(Base64.decodeBase64("上から作成した秘密鍵")));
}

/**
 * Java APIを利用してJWTを作成
 *
 * @throws NoSuchAlgorithmException
 * @throws IOException
 * @throws InvalidKeyException
 * @throws SignatureException
 */
@Test
public void test_java_JWT() throws NoSuchAlgorithmException, IOException, InvalidKeyException, SignatureException {
    // Given
    final ObjectMapper objectMapper = new ObjectMapper();
    final Map<String, Object> header = Maps.newLinkedHashMap();
    header.put("kid", "キーID");
    header.put("typ", "タイプ、一般的に'JWT'に設定");
    header.put("alg", "アルゴリズム、一般的にES256使用");
    final String headerStr =  Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(header));

    final Map<String, Object> payload = Maps.newLinkedHashMap();
    payload.put("iss", "JWTを作成した場所");
    payload.put("iat", 0); // JWT作成時間
    final String payloadStr = Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(payload));

    // When
    // Java 9から可能(Java 8でエラーが発生 'java.security.NoSuchAlgorithmException: SHA256withECDSAinP1363Format Signature not available')
    // SHA256withECDSAと署名形式が異なり、一部のライブラリで検証に失敗する場合がある。
    final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format");
    signature.initSign(EC_PRIVATE_KEY);
    signature.update((headerStr + "." + payloadStr).getBytes());

    byte[] signatureBytes = signature.sign();

    final String signatureStr = Base64.encodeBase64URLSafeString(signatureBytes);

    final String jwt = headerStr + "." + payloadStr + "." + signatureStr;

    logJWT("java", jwt);

    // Then
    verifyJWTByJava(jwt, EC_PUBLIC_KEY);
}
eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODczNDk1MjcsImlzcyI6ImppbmhvLnNoaW4
ifQ.MEUCIGncUpdRpxO9glZi7aKrzXa06DFrWIfxPtEL7kLxcHtWAiEAqenTrf-nD8EucxhJBrBpZw5IuTDFxK1rtv20nF5SYZk

3. JWT検証(Verify)

公開鍵でJWTの署名を検証します。

public void verifyJWTByJava(String jwt, ECPublicKey publicKey) throws NoSuchAlgorithmException,
InvalidKeyException, SignatureException {
    final String[] splitJwt = jwt.split("\\.");
    final String headerStr = splitJwt[0];
    final String payloadStr = splitJwt[1];
    final String signatureStr = splitJwt[2];

    final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format");
    signature.initVerify(publicKey);
    signature.update((headerStr + "." + payloadStr).getBytes());

    assert signature.verify(Base64.decodeBase64(signatureStr));
}

ここでは、JWTの作成および検証過程が理解しやすいようにJava APIを利用しましたが、さまざまなJWTライブラリがあるため、開発時にはライブラリを使用した方が便利です。下記に、Nimubs、Auth0、jjwtなどを利用したJWTの作成や検証に関連するコードがありますので、開発時にご参考ください。

JWTをさらに詳しく

ここからは、JWTの特徴や使用事例について簡単に紹介します。

JWT、JWS、JWE、JWK、JWA …?

JWTは、URL、クッキー、ヘッダーのように使用できる文字を制限し、環境からの情報を送受信できるようにするデータ表現形式(Format)です。ところが、実際に私たちがJWTで利用する署名(Sign)や暗号化(Encryption)のスペックは、JWT下位のJWS(JSON Web Signature)とJWE(JSON Web Encryption)に存在します。分かりやすく説明すると、JWTは抽象クラス(Abstract Class)であり、JWSとJWEは抽象クラスを実装したコンクリートクラス(Concrete Class、具象クラス)と言えます。ほかにも、JWK(JSON Web Key)は、JSON形式で暗号化キーを表現したものであり、JWA(JSON Web Algorithm)は、JWS、JWE、JWKに使用されるアルゴリズムです。

以下は、JWT RFC-7519の一部を引用したものです。

JWS&Compact Serialization

私たちが一般的に使用するほとんどのJWTはJWSです。では、JWEはいつ使用するのかと疑問に思われるかもしれませんが、実際にはほとんど使用していないようです。なぜなら、JWEはその名前からもわかるようにデータを暗号化するのですが、私たちは一般的に通信時に拠点間の暗号化が必要な場合は、TLS(Transport Layer Security)を使用しているからです。したがって、JWEを使用してデータを暗号化する必要がありません。もう一度JWSに戻ってみましょう。先にJWTの構造で説明したHeader.Payload.Signature構造は、JWSの直列化の方法の1つであるコンパクトシリアル化形式に直列化したものです。整理すると、私たちが一般的に使用するJWTはJWSを使用し、JWS コンパクトシリアル化し直列化した文字列です。

以下は、JWS RFC-7515の一部を引用したものです。

Base64 URL-Safe!= Base64

Base64 URL-Safeエンコードは、基本的にBase64エンコードで「+」(plus)を「 – 」(minus)に、 ‘/’(slash)を「_」(underscore)に置き換えるエンコード方法です。これにより、JWTは設計が意図したとおり、URL、クッキー、ヘッダーなどをどこでも使用できる広い汎用性を持つようになりました。

Header&Payload

JWTのヘッダーはBase64でエンコードする前に、常にUTF-8にエンコードされた文字列でなければなりません。その理由は、ヘッダーが必ずJSONでなければならず、JSONのデフォルトのエンコードがUTF-8であるためです。正式名称は、JOSE(JSON Object Signing and Encryption)Headerです。では、ペイロードはJSONでなくても問題ないでしょうか?ペイロードは一般的にJSONを使用するだけで、必ずJSONでなければならないというわけではありません。したがって、ペイロードはヘッダーとは異なり、Base64 URL-Safeエンコードのみを行います。

以下は、JWS RFC-7515の一部を引用したものです。

自己完結(Self-Contained)とステータスレス(Stateless)

JWTはJWT自体に必要なすべての情報を含めることができます。ヘッダーはトークンの解釈方法を、ペイロードはトークンの内容や配信内容(ユーザー情報、権限、サービスに必要なデータ)を自由に入れます。また、署名でヘッダーとペイロードが改ざんされていないことを検証こともできます。サーバーはJWTを作成する際に、JWTに検証や認証時に必要な値を入れるため、JWTの状態を別途管理する必要はありません。たとえば、TOAST Meetup!のJWTペイロードを次のように定義すれば、TOAST Meetup!サーバーはJWT署名を検証したあと、権限を確認する際に追加の通信を行うことなくroles属性で進行することができます。

{
    "iss": "meetup.toast.com", <- 発行者
    "iat": 1586364327, <- 発行時間
    "exp": 1586874996, <- 満了時間
    "email": "email@email.com", <- ユーザーのメールアドレス
    "roles": ["read"] <- 読み取り権限
}

公開鍵暗号方式における署名(Signature)と暗号化(Encryption)

JWTでは基本的に公開鍵暗号方式(PKC、Public Key Cryptography)を使用します。非対称暗号方式を利用して公開鍵と秘密鍵を作成し、このキーを状況に応じて通信時に使用します。署名はデータのハッシュ値を秘密鍵で署名し、再び公開鍵で署名を検証(Verify)します。そして、署名は秘密鍵を持つ場所でのみ行うことができ、公開鍵を持つ場所ならどこでもデータの署名を検証することができます。一方、暗号化は、公開鍵でデータを暗号化(Encrypt)して秘密鍵でデータを復号化(Decrypt)します。公開鍵を持つ誰もがデータを暗号化してデータを送信することができますが、秘密鍵の場所でのみデータを復号化して内容を確認することができます。ここで注目すべきは、公開鍵暗号方式は、秘密鍵で暗号化したデータを公開鍵で復号化することができ、逆に公開鍵で暗号化したデータは、秘密キーで復号化できるという点です。当然ながら、秘密鍵で暗号化したものを秘密鍵で解除したり、公開鍵で暗号化したものを公開鍵で解くことはできません。

署名:秘密鍵を持つごく少数(主に1人)のみデータに署名できる。公開鍵を持つ誰もがデータの署名を検証できる。
暗号化:公開鍵を持つ誰もがデータを暗号化できる。秘密鍵を持つごく少数のみデータを復号化して確認できる。

使用事例

JWT as API Key

AppleのPushメッセージ送信APIであるAPNs Provider APIは、2016年から認証のためJWTをサポートしています。従来は、1年間に使用可能な証明書をApple開発者コンソールから発行してもらい、mTLS(Mutual TLS)を使用してAPI認証を行っていました。JWTをAPI Keyとして使用しつつ、前述した性質によりAPNsはmTLS方式と比べ迅速にAPIを認証できるようになりました。JWTを作成してAPNs Provider APIを呼び出すプロセスは、以下のとおりです。

  1. Apple開発者コンソールからJWTの作成に必要なキーID(Key ID, kid)、発行者(Issuer, iss)、秘密鍵を発行する。
  2. API呼び出し前に発行された値を利用してJWTを作成する。
  3. API呼び出し時、AuthorizationヘッダーにJWTを追加する。
  4. APNsはAuthorizationヘッダーのJWTを認証する。

curl -X POST -H 'Authorization: bearer HEADER.PAYLOAD.SIGNATURE' -d '{"aps":{"alert":"Hello, JWT"}}'
[https://api.push.apple.com/3/device/jinho-token](https://api.push.apple.com/3/device/jinho-token)

JWT in MSA

1. Access Token in MSA

一般的に、認証に応じたアクセス制御が必要なウェブサービスは、まずログインを通じてユーザー認証(Authentication)を行います。認証サービス(Authorizatioin Service)は、認証を通過したクライアントにアクセストークン(Access Token)を発行します。通常のアクセストークンは、認証を指す任意の文字列で構成されていますが、認証を参照するという意味で参照トークン(By Reference Token)と呼ばれています。モノリス(Monolith)アーキテクチャでは、参照トークンをアクセストークンとして使用しても大きな問題はありません。しかし、複数のサービス間のAPI呼び出しが発生するMSA(Micro Service Architecture)やクラウド環境では、アクセストークンが指す認証を確認するために、すべてのサービスが認証サービスと通信を行う必要があります。サービスが増えるほど、認証サーバーにかかる負荷が指数関数的に増える可能性があり、これはMSAの拡張性(Scalability)に負担を与えかねません。

2. JWT as Access Token in MSA

参照トークンの代わりに、JWTをアクセストークンとして使用することができます。JWTは独自に必要な情報をすべて含められるため、バリュートークン(By Value Token)と呼ばれます。JWTアクセストークンは、MSA(Micro Service Architecture)環境の認証とアクセス制御に適しています。サービスは、JWTに含まれる値に基づいて認証を確認することができます。サービスと認証サービスの通信は、JWT署名を認証するため公開鍵を照会します。しかし、JWTをアクセストークンとして使用した場合、メリットだけでなくデメリットも存在します。その例として、ユーザーの権限や情報が変更された場合は、JWTを新規に発行する必要があり、場合によってはJWTのサイズが大きくなることがあります。JWTのヘッダーやペイロードは、デコード(Decoding)するとすぐに内容を確認できるため、JWTのすべての値はクライアントに公開されてしまいます。外部に露出してはならない、またはセンシティブな値が公開されることがあり、セキュリティ上の問題につながる恐れがあります。

3. API Gateway between Access Token and JWT in MSA

クライアント、認証サービス、サービス間にAPI Gatewayを位置させると、JWTをクライアントに隠しつつサービス間の通信時に使用することができます。API Gatewayは、クライアントから受け取ったアクセストークンを認証サービスを通じてJWTで受け取り、アクセストークンの代わりにサービスに渡してくれます。

結論

JWTの広い汎用性、完全性の保証、必要な値を自己完結できる性質により、多くの場所でJWTが使用されており、今後ますます広範囲に使用されることでしょう。特に、MSAのサービス間通信時に認証サービスとの依存性を低減できるため、サーバーとサーバー間の通信に非常に有用です。MSA環境での認証の1つの方法としてJWTを使用すると、よりMSAに相応しいクラウドネイティブ(Cloud Native)なサービスを作ることができるでしょう。

参考

JWT RFC:https://tools.ietf.org/html/rfc7519
JWS RFC:https://tools.ietf.org/html/rfc7515
JWE RFC:https://tools.ietf.org/html/rfc7516
https://docs.oracle.com/javase/tutorial/security/apisign/gensig.html
https://docs.oracle.com/javase/tutorial/security/apisign/versig.html
https://ldapwiki.com/wiki/ ES256
https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns
https://www.ibm.com/blogs/security-identity-access/oauth-jwt-access-token/
https://curity.io/resources/tutorials/howtos/advanced/jwt-assertion/
https://www.oauth.com/oauth2-servers/access-tokens/self-encoded-access-tokens/
https://auth0.com/blog/using-json-web-tokens-as-api-keys/
https://yos.io/2017/09/03/serverless-authentication-with-jwt/
https://techdocs.broadcom.com/content/broadcom/techdocs/us/en/ca-enterprise-software/layer7-api-management/api-management-oauth-toolkit/4-3/installation-workflow/configure-authentication/token-configuration/configure-jwt-access-tokens.html
https://medium.com/@rahulgolwalkar/pros-and-cons-in-using-jwt-json-web-tokens-196ac6d41fb4
https://www.scottbrady91.com/OAuth/OAuth-is-Not-Authentication
https://www.oauth.com/oauth2-servers/openid-connect/authorization-vs-authentication/

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop