C# 任意のペイロードを含んだJWT（JSON Web Token）を生成する

# このドキュメントの内容

`System.IdentityModel.Tokens.Jwt` を使って JWT (JSON Web Token) を生成する方法を説明します。
`System.IdentityModel.Tokens.Jwt` のバージョンは 5.4.0 です。

## トークンの生成

### トークンプロバイダー

今回定義しているのはトークン生成メソッドのみです。

```c#:ITokenProvider
/// <summary>
/// トークンプロバイダー。
/// </summary>
/// <typeparam name="TPayload">ペイロードの型</typeparam>
public interface ITokenProvider<TPayload>
{
    /// <summary>
    /// トークンを生成します。
    /// </summary>
    /// <param name="claim">クレーム</param>
    /// <param name="payload">ペイロード</param>
    /// <param name="expiration">有効期限</param>
    /// <returns>トークン</returns>
    string CreateToken(IClaim claim, TPayload payload, DateTimeOffset expiration);
}
```

### クレーム情報

トークンに与えるクレーム情報のうち、呼び出し元から指定する項目を定義しています。

```c#:IClaim
/// <summary>
/// クレーム情報。
/// </summary>
public interface IClaim
{
    /// <summary>
    /// IDを取得します。
    /// </summary>
    string ID { get; }

    /// <summary>
    /// 利用者を取得します。
    /// </summary>
    string Audience { get; }
}

/// <summary>
/// クレーム情報。
/// </summary>
public class MsJwtClaim : IClaim
{
    /// <summary>
    /// IDを取得または設定します。
    /// </summary>
    public string ID { get; set; }

    /// <summary>
    /// 利用者を取得または設定します。
    /// </summary>
    public string Audience { get; set; }
}
```

### トークンプロバイダーの実装

前述の `ITokenProvider<TPayload>` インターフェースを実装する、`System.IdentityModel.Tokens.Jwt` を用いたトークンプロバイダーです。
ペイロードは JSON 文字列にして指定します。JSON シリアライザには `JSON.net` を用いています。

```c#:MsJwtProvider
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using Newtonsoft.Json;

/// <summary>
/// トークンプロバイダー。
/// </summary>
/// <typeparam name="TPayload">ペイロードの型</typeparam>
public class MsJwtProvider<TPayload> : ITokenProvider<TPayload>
{
    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="key">RSA秘密鍵</param>
    public MsJwtProvider(RsaSecurityKey key)
    {
        m_Key = key;
    }

    private readonly RsaSecurityKey m_Key;
    private readonly JwtSecurityTokenHandler m_TokenHandler = new JwtSecurityTokenHandler();

    /// <summary>
    /// 発行者を取得または設定します。
    /// </summary>
    public string Issuer { get; set; }

    /// <summary>
    /// トークンを生成します。
    /// </summary>
    /// <param name="claim">クレーム</param>
    /// <param name="payload">ペイロード</param>
    /// <param name="expiration">有効期限</param>
    /// <returns>トークン</returns>
    public string CreateToken(IClaim claim, TPayload payload, DateTimeOffset expiration)
    {
        var credentials = new SigningCredentials(m_Key, "RS256");

        var descriptor = new SecurityTokenDescriptor { };

        descriptor.SigningCredentials = credentials;
        descriptor.Issuer = Issuer;
        descriptor.Audience = claim.Audience;
        descriptor.Expires = expiration.UtcDateTime;
        descriptor.NotBefore = DateTime.UtcNow.AddSeconds(-5);
        descriptor.IssuedAt = DateTime.UtcNow;

        // 定義済でない項目をクレームに含めるには、Claims ではなく Subject に格納します。

        //descriptor.Claims = new Dictionary<string, object> {
        //    { "userpayload", JsonConvert.SerializeObject(payload) }
        //    { "jti", claim.ID }
        //};
        descriptor.Subject = new ClaimsIdentity(new Claim[] {
                new Claim("userpayload", JsonConvert.SerializeObject(payload)),
                new Claim("jti", claim.ID)
            });

        var token = m_TokenHandler.CreateJwtSecurityToken(descriptor);
        var tokenString = m_TokenHandler.WriteToken(token);

        return tokenString;
    }
}
```

## トークンの検証

### トークンバリデーター

今回定義しているのはトークン文字列検証メソッドのみです。
検証すると同時に、戻り値としてトークンから取り出したクレーム情報とペイロードを返します。

```c#:ITokenValidator
/// <summary>
/// トークンバリデーター。
/// </summary>
/// <typeparam name="TPayload">ペイロードの型</typeparam>
public interface ITokenValidator<TPayload>
{
    /// <summary>
    /// 指定されたトークンを検証します。
    /// </summary>
    /// <param name="token">トークン文字列</param>
    /// <param name="claim">トークンから取り出したクレーム情報</param>
    /// <param name="payload">トークンから取り出したペイロード</param>
    /// <param name="tokenState">トークンの状態</param>
    /// <param name="errorMessage">エラーメッセージ</param>
    /// <returns>トークンが妥当であるかどうか</returns>
    bool ValidateToken(string token
        , out IClaim claim
        , out TPayload payload
        , out TokenState tokenState
        , out string errorMessage
    );
}
```

### トークンの状態

検証したトークンの状態を表す列挙体です。

```c#
/// <summary>
/// トークンの状態。
/// </summary>
public enum TokenState
{
    /// <summary>
    /// 不明
    /// </summary>
    Unknown = 0,

    /// <summary>
    /// 妥当
    /// </summary>
    Valid,

    /// <summary>
    /// 不正
    /// </summary>
    Invalid,

    /// <summary>
    /// まだ有効でない
    /// </summary>
    NotBefore,

    /// <summary>
    /// 有効期限切れ
    /// </summary>
    Expired,
}
```

### トークンバリデーターの実装

前述の `ITokenValidator<TPayload>` インターフェースを実装する、`System.IdentityModel.Tokens.Jwt` を用いたトークンバリデーターです。
ペイロードは JSON 文字列から復元します。JSON シリアライザには `JSON.net` を用いています。

```c#:MsJwtValidator
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using Newtonsoft.Json;

/// <summary>
/// トークンバリデーター。
/// </summary>
/// <typeparam name="TPayload">ペイロードの型</typeparam>
public class MsJwtValidator<TPayload> : ITokenValidator<TPayload>
{
    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="key">RSA公開鍵</param>
    internal MsJwtValidator(RsaSecurityKey key)
    {
        m_Key = key;

        m_LifetimeValidator = new LifetimeValidator((nbf, exp, token, parameter) =>
        {
            return ValidateLifetime(nbf, exp, out TokenState state, out string message);
        }
        );
    }

    private readonly RsaSecurityKey m_Key;
    private readonly JwtSecurityTokenHandler m_TokenHandler = new JwtSecurityTokenHandler();
    private readonly LifetimeValidator m_LifetimeValidator;

    /// <summary>
    /// 妥当と見なす発行者を取得または設定します。
    /// </summary>
    public string[] ValidIssuers { get; set; }

    /// <summary>
    /// 妥当と見なす利用者を取得または設定します。
    /// </summary>
    public string[] ValidAudiences { get; set; }

    /// <summary>
    /// 指定されたトークンを検証します。
    /// </summary>
    /// <param name="token">トークン文字列</param>
    /// <param name="claim">トークンから取り出したクレーム情報</param>
    /// <param name="payload">トークンから取り出したペイロード</param>
    /// <param name="tokenState">トークンの状態</param>
    /// <param name="errorMessage">エラーメッセージ</param>
    /// <returns>トークンが妥当であるかどうか</returns>
    public bool ValidateToken(string tokenString
        , out IClaim claim
        , out TPayload payload
        , out TokenState tokenState
        , out string errorMessage
    )
    {

        TokenValidationParameters parameters = new TokenValidationParameters();
        parameters.IssuerSigningKey = m_Key;
        parameters.ValidateIssuerSigningKey = true;

        // 発行者を検証するかどうか
        if (ValidIssuers != null && ValidIssuers.Length > 0)
        {
            if (ValidIssuers.Length == 1) { parameters.ValidIssuer = ValidIssuers.First(); }
            else { parameters.ValidIssuers = ValidIssuers; }
            parameters.ValidateIssuer = true;
        }
        else
        {
            parameters.ValidateIssuer = false;
        }

        // 利用者を検証するかどうか
        if (ValidAudiences != null && ValidAudiences.Length > 0)
        {
            if (ValidAudiences.Length == 1) { parameters.ValidAudience = ValidAudiences.First(); }
            else { parameters.ValidAudiences = ValidAudiences; }
            parameters.ValidateAudience = true;
        }
        else
        {
            parameters.ValidateAudience = false;
        }

        // 有効期限を検証するかどうか
        parameters.ValidateLifetime = true;
        parameters.LifetimeValidator = m_LifetimeValidator;

        try
        {

            ClaimsPrincipal claims = m_TokenHandler.ValidateToken(tokenString, parameters, out SecurityToken token);

            // クレーム情報
            claim = new MsJwtClaim()
            {
                ID = GetClaim(claims, "jti),
                Audience = GetClaim(claims, "aud")
            };

            // ペイロード
            string payloadJson = claims.FindFirst("userpayload")?.Value;

            if (string.IsNullOrEmpty(payloadJson))
            {
                payload = default(TPayload);
            }
            else
            {
                payload = JsonConvert.DeserializeObject<TPayload>(payloadJson);
            }

            tokenState = TokenState.Valid;
            errorMessage = null;
            return true;

        }
        catch (SecurityTokenInvalidLifetimeException ex)
        {
            // 有効期限が不正
            claim = null;
            payload = default(TPayload);
            if (ValidateLifetime(ex.NotBefore, ex.Expires, out tokenState, out errorMessage))
            {
                // NotBefore でも Expires でもない
                tokenState = TokenState.Invalid;
                errorMessage = "The token is invalid.";
            }
            return false;
        }
        catch (SecurityTokenInvalidIssuerException)
        {
            // 発行者が不正
            claim = null;
            payload = default(TPayload);
            tokenState = TokenState.Invalid;
            errorMessage = "The issuer is invalid.";
            return false;
        }
        catch (SecurityTokenInvalidAudienceException)
        {
            // 利用者が不正
            claim = null;
            payload = default(TPayload);
            tokenState = TokenState.Invalid;
            errorMessage = "The audience is invalid.";
            return false;
        }
    }

    /// <summary>
    /// 指定されたクレームの値を取得します。
    /// </summary>
    /// <param name="claims">クレーム情報</param>
    /// <param name="key">キー</param>
    /// <returns></returns>
    private string GetClaim(ClaimsPrincipal claims, string key)
    {
        foreach (Claim claim in claims.Claims)
        {
            if (string.Compare(claim.Type, key, true) == 0)
            {
                return claim.Value;
            }
        }
        return null;
    }

    /// <summary>
    /// 有効期限を検証します。
    /// </summary>
    /// <param name="nbf">トークンが有効になる日時</param>
    /// <param name="exp">トークンの有効期限</param>
    /// <param name="state">トークンの状態</param>
    /// <param name="message">エラーメッセージ</param>
    /// <returns>妥当であるかどうか</returns>
    private bool ValidateLifetime(DateTime? nbf, DateTime? exp, out TokenState state, out string message)
    {
        DateTime now = DateTime.Now.ToUniversalTime();

        if (exp.HasValue && exp.Value < now)
        {
            state = TokenState.Expired;
            message = "The token is expired.";
            return false;
        }

        if (nbf.HasValue && nbf.Value > now)
        {
            state = TokenState.NotBefore;
            message = "The token is not yet valid.";
            return false;
        }

        state = TokenState.Valid;
        message = null;
        return true;
    }
}
```

### トークンの検証時に得られる内容

```c#
ClaimsPrincipal claims = m_TokenHandler.ValidateToken(tokenString, parameters, out SecurityToken token);
```

上記のトークン検証メソッドで得られる内容を簡単に説明します。

戻り値 claims の Claims プロパティにはクレームがコレクションとして格納されています。トークン生成時に Subjects として与えたクレームもここに格納されます。今回指定したペイロードの型には Role と Permission というプロパティが定義されています。その値が格納されていることが分かります。

```
[0]	{userpayload: {"user":{"Role":2,"Permissions":1}}}
[1]	{jti: ff28ffc3-6709-4de2-a914-e5aa88594fd8}
[2]	{nbf: 1553426924}
[3]	{exp: 1553427228}
[4]	{iat: 1553426929}
[5]	{iss: testIssuer}
[6]	{aud: testAudience}
```

今回の検証では out 引数 token からもクレーム情報を取得することはできました。
SecurityToken は基底型であり、実際に返された型は System.IdentityModel.Tokens.Jwt.JwtSecurityToken でした。この型には Claims プロパティが定義されており、上記と同じ内容の情報を取得することができました。但し、常にこの型が返されるのか、その他の型が返されることがあるのかは分かりません。