2015年1月5日月曜日

[JWT/OAuth]Service Accountを使ってGoogle APIを利用する②

前回の続きです。

前回はGoogle Developer ConsoleおよびGoogleAppsの管理ダッシュボードでGoogle側の設定をするところまでを紹介しましたので、今回は実際にサービスアカウントを使ってAPIを利用するためのアクセストークンを取得してみます。

◆リクエストの仕様
Googleの公式ドキュメントを見るとアクセストークンを取得するためには、JWTを生成してトークンエンドポイントへPOSTする必要があるようです。

リクエストの仕様
エンドポイントhttps://www.googleapis.com/oauth2/v3/token
メソッドPOST
パラメータgrant_typeurn:ietf:params:oauth:grant-type:jwt-bearer
assertion作成したJWT


※ちなみにGoogleが提供している.Net用のクライアントライブラリの通信をキャプチャしてみるとエンドポイントアドレスがドキュメントに記載されているものとは異なり、https://accounts.google.com/o/oauth2/tokenとなっています。(ライブラリ側が古いまま?)

また、POSTするJWTは以下の通りの形である必要があるようです。

JWTの仕様
署名アルゴリズムRSA-SHA256
必要なクレームissサービスアカウントのメールアドレス
scope利用するAPIのスコープ
audhttps://www.googleapis.com/oauth2/v3/token
expトークンの有効期限To
iatトークンの有効期限From


尚、エンタープライズシナリオにおいて特定の管理者がAPIを実行したことを記録していくような場合においては、上記のクレームにsubを追加して管理者メールアドレスを設定することでロギングが出来るようになります。

◆JWTを生成する
・.Net標準のJwtSecurityTokenHandlerを利用する
せっかくマイクロソフト謹製のJWTを扱うライブラリがあり、Azure Active Directory(AzureAD)へのアクセス用のサンプルではほぼ100%このライブラリを使っているので、まずはこちらを使ってJWTを生成してみます。

このライブラリを使うことでコーディング量をかなり削減できるので、うまく使えればかなり楽に開発が出来るはずです。

使うのは、NuGetで提供されている以下のパッケージです。
 名称:JSON Web Token Handler For the Microsoft .Net Framework 4.5
 作成者:Microsoft Open Technologies.
 識別:System.IdentityModel.Tokens.Jwt
 バージョン:4.0.1

JWTを生成するときはシンプルにJwtSecurityTokenに必要なクレーム情報を指定してインスタンスを生成するだけです。
var token = new JwtSecurityToken(
    SERVICE_ACCOUNT, // iss
    TOKEN_ENDPOINT, // aud
    claims, // additional claims
    issuedTime, // iat
    expiresTime, // exp
    credentials); // 署名用のクレデンシャル

固定でiss/aud/iat/exp/credentialを引数で指定するのに加えて、追加のクレームについてはIEmurerable claimsで列挙型の引数を渡す形になるので、必要なクレームは事前にClaim型で定義しておきます。
var claims = new[]
{
    // 「クレーム名,値」のセット
    new Claim("scope", SCOPE)
};

また、署名についてはGoogleからダウンロードした鍵(.p12ファイル)から作成します。
var certificate = new X509Certificate2(
    CERTIFICATE_FILE, // .p12ファイル
    CERTIFICATE_PWD, // 秘密鍵のパスワード(notasecret)
    X509KeyStorageFlags.Exportable);
var credentials = new X509SigningCredentials(certificate);


JWT自体はこれで簡単に生成できるので、ハンドラのインスタンスを生成し、JWTを渡します。
こちらも非常に簡単で、
var handler = new JwtSecurityTokenHandler();
でハンドラは生成でき、
var strJWT = handler.WriteToken(token);
とすればBase64Urlエンコードされた、[ヘッダ].[ペイロード].[署名]が文字列として返ってきます。
この文字列をGoogleのトークンエンドポイントへassertionとしてPOSTしてやれば良いはずです。


出来上がったコードはこんな感じになります。
とてもシンプルです。
        static string CreateJWT()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);
            var credentials = new X509SigningCredentials(certificate);

            // token lifetime
            var issuedTime = DateTime.UtcNow;
            var expiresTime = issuedTime.AddMinutes(55);

            // additional claims
            var claims = new[]
            {
                // add scope claim
                new Claim("scope", SCOPE)
            };

            // create jwt token
            var token = new JwtSecurityToken(
                SERVICE_ACCOUNT, // iss
                TOKEN_ENDPOINT, // aud
                claims, // additional claims
                issuedTime,
                expiresTime,
                credentials);

            var handler = new JwtSecurityTokenHandler();

            // exception in WriteToken method
            return handler.WriteToken(token);

        }



では、早速実行してみましょう。


はい、怒られました。
Googleの秘密鍵のキー長は1024bitなんですが、このライブラリは最低でも2048bitを要求しているようです。
残念です。
今後のGoogleもしくはMicrosoftに期待しましょう。


・スクラッチでJWTを生成するコードを書く
仕方がないのでスクラッチでコードを書きます。と言っても基本はJSONの取り扱いと署名だけなので、それほど複雑にはなりません。
JSONの扱いは、マイクロソフトのサンプルコードでもよく使われているNewtonsoftのJson.NETパッケージを使います。こちらもNuGetで提供されています。
 名称:Json.NET
 作成者:James Newton-King
 識別:Newtonsoft.Json
 バージョン:6.0.7

◇ヘッダ部
求めるのは、{ alg = "RS256", typ = "JWT" }というJSONなのでまずはJson.NETを使ってバイト配列を作成します。
var header = new { alg = "RS256", typ = "JWT" };
var headerSerialized = JsonConvert.SerializeObject(header,Formatting.None);
var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);

その後、作成したバイト配列をBase64Urlエンコードするのですが、JWTの作者でもあるMicrosoft ResearchのMike Jonesがスペック(ドラフト)のAppendixにC#のサンプルコードを載せてくれているので、そちらを使います。
 参考URL)
 http://self-issued.info/docs/draft-goland-json-web-token-00.html

private static string Base64UrlEncode(byte[] input)
{
    var output = Convert.ToBase64String(input);
    output = output.Split('=')[0]; // Remove any trailing '='s
    output = output.Replace('+', '-'); // 62nd char of encoding
    output = output.Replace('/', '_'); // 63rd char of encoding
    return output;
}

こちらを使って先ほどのバイト配列をエンコードします。
var headerEncoded = Base64UrlEncode(headerBytes);


◇ペイロード部
基本的な考え方はヘッダ部と同じです。必要なJSONは先に挙げた仕様通りなので、以下のようなコードになります。
var payload = new
{
    iss = SERVICE_ACCOUNT, // iss
    scope = SCOPE, // scope
    aud = TOKEN_ENDPOINT, // aud
    exp = exp, // exp
    iat = iat // iat
};
var payloadSerialized = JsonConvert.SerializeObject(payload,Formatting.None);
var payloadBytes = Encoding.UTF8.GetBytes(payloadSerialized);
var payloadEncoded = Base64UrlEncode(payloadBytes);


◇署名部
ここまで作成したヘッダとペイロードを"."(ピリオド)でつないだ文字列を先の秘密鍵を使って署名します。
ポイントはRSACryptoServiceProviderを生成する際のパラメータにキー長をちゃんと設定してあげることです。(certificate.PrivateKey.KeySizeで取得できます。先に説明した通りGoogleの場合、1024bitです)

こちらも同じく作成した署名部分のバイト配列をBase64Urlエンコードして文字列を生成します。
こんなコードになります。

RSAParameters rsaPara = ((RSACryptoServiceProvider)(certificate.PrivateKey)).ExportParameters(true);
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(certificate.PrivateKey.KeySize);
rsa.ImportParameters(rsaPara);
Byte[] target = Encoding.UTF8.GetBytes(headerEncoded + "." + payloadEncoded);
byte[] signBytes = rsa.SignData(target, new SHA256Managed());
var signEncoded = Base64UrlEncode(signBytes);


◇JWTの生成
Googleに渡すJWTはヘッダ、ペイロード、署名を"."(ピリオド)で連携した文字列である必要があるので、単純に文字列を連結します。
var strJWT = headerEncoded + "." + payloadEncoded + "." + signEncoded;


出来上がったコードはこんな感じになります。
先ほどよりはコード量は多いですが、それでもシンプルです。
        static string CreateJWT()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);

            // token lifetime
            var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
            var issueTime = DateTime.Now;
            var iat = (long)issueTime.ToUniversalTime().Subtract(utc0).TotalSeconds;
            var exp = (long)issueTime.ToUniversalTime().AddMinutes(55).Subtract(utc0).TotalSeconds;


            // header
            var header = new { alg = "RS256", typ = "JWT" };
            var headerSerialized = JsonConvert.SerializeObject(header,Formatting.None);
            var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
            var headerEncoded = Base64UrlEncode(headerBytes);
            
            // payload
            var payload = new
            {
                iss = SERVICE_ACCOUNT,
                scope = SCOPE,
                aud = TOKEN_ENDPOINT, 
                exp = exp,
                iat = iat
            };
            var payloadSerialized = JsonConvert.SerializeObject(payload,Formatting.None);
            var payloadBytes = Encoding.UTF8.GetBytes(payloadSerialized);
            var payloadEncoded = Base64UrlEncode(payloadBytes);

            // signature
            RSAParameters rsaPara = ((RSACryptoServiceProvider)(certificate.PrivateKey)).ExportParameters(true);
            RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(certificate.PrivateKey.KeySize);
            rsa.ImportParameters(rsaPara);
            Byte[] target = Encoding.UTF8.GetBytes(headerEncoded + "." + payloadEncoded);
            byte[] signBytes = rsa.SignData(target, new SHA256Managed());
            var signEncoded = Base64UrlEncode(signBytes);

            // concatinate header + payload + signature
            return headerEncoded + "." + payloadEncoded + "." + signEncoded;

        }


実行すると単純に文字列を生成しているだけなので問題なく
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3Mi..
という文字列が取得できます。

後はGoogleにこれをPOSTするだけです。

◆アクセストークンのリクエストを投げる
こちらは単純にHTTP POSTするだけなのでhttpClientを使います。

POSTするためのメソッドを用意しておき、パラメータをセットして実行するだけです。

こんな非同期メソッドを用意しておいて、、、
private static async Task<string> Post(string url, Dictionary<string, string> param)
{
    string result = "";
    try
    {
        HttpClient httpClient = new HttpClient();
        httpClient.MaxResponseContentBufferSize = int.MaxValue;
        HttpContent content = new FormUrlEncodedContent(param);
        var response = await httpClient.PostAsync(url, content);
        String text = await response.Content.ReadAsStringAsync();
        result = text;
    }
    catch (Exception Err)
    {
        result = "ERROR: " + Err.Message;
    }
    return result;
}

こんな感じでリクエストを投げ込みます。
Task task = Task.Factory.StartNew(async () =>
{
    string result = await Post(
        TOKEN_ENDPOINT,
        new Dictionary<string, string>() { 
            { "assertion" , request_jwt }, // 先ほど生成したJWT
            { "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer" }});
                Console.WriteLine("Access Token request result :\n{0}", result);
}).Unwrap();
task.Wait();

結果、こんな感じでアクセストークンが取得できます。


後は各APIを利用する際にアクセストークンを提示してあげればOK、という話です。


◆まとめ
サービスアカウントを使ってアクセストークンを取得するところまで解説しました。
どこにもユーザが介在せずにAPIを実行するために必要なトークンが取得できることがわかりましたので、サーバアプリケーションを使う場合は、この方法を使うことになります。
尚、本当は折角なのでマイクロソフト標準のJwtSecurityTokenHandlerを使えればAzureAD用とGoogleApps用でモジュールが簡単に共通化出来るかと思ったのですが、まだまだ発展途上?ということがわかりましたので、今後の対応に期待です。


最後にNGパターンを含めコードを載せておきます。

using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;

namespace GetAccessToken
{
    class Program
    {

        //
        // Constants
        //
        static string TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v3/token";
        static string SERVICE_ACCOUNT = "10.......kvghppo@developer.gserviceaccount.com";
        static string SCOPE = "https://www.googleapis.com/auth/admin.directory.user";
        static string CERTIFICATE_FILE = @"C:\Users\testuser\Downloads\FIMMA-8ce3eefa8ca2.p12";
        static string CERTIFICATE_PWD = "notasecret";

        //
        // Common Library
        //
        private static string Base64UrlEncode(byte[] input)
        {
            var output = Convert.ToBase64String(input);
            output = output.Split('=')[0]; // Remove any trailing '='s
            output = output.Replace('+', '-'); // 62nd char of encoding
            output = output.Replace('/', '_'); // 63rd char of encoding
            return output;
        }

        //
        static string OK_RSA256()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);

            // token lifetime
            var utc0 = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
            var issueTime = DateTime.Now;
            var iat = (long)issueTime.ToUniversalTime().Subtract(utc0).TotalSeconds;
            var exp = (long)issueTime.ToUniversalTime().AddMinutes(55).Subtract(utc0).TotalSeconds;


            // header
            var header = new { alg = "RS256", typ = "JWT" };
            var headerSerialized = JsonConvert.SerializeObject(header,Formatting.None);
            var headerBytes = Encoding.UTF8.GetBytes(headerSerialized);
            var headerEncoded = Base64UrlEncode(headerBytes);
            
            // payload
            var payload = new
            {
                iss = SERVICE_ACCOUNT,
                scope = SCOPE,
                aud = TOKEN_ENDPOINT, 
                exp = exp,
                iat = iat
            };
            var payloadSerialized = JsonConvert.SerializeObject(payload,Formatting.None);
            var payloadBytes = Encoding.UTF8.GetBytes(payloadSerialized);
            var payloadEncoded = Base64UrlEncode(payloadBytes);

            // signature
            RSAParameters rsaPara = ((RSACryptoServiceProvider)(certificate.PrivateKey)).ExportParameters(true);
            RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(certificate.PrivateKey.KeySize);
            rsa.ImportParameters(rsaPara);
            Byte[] target = Encoding.UTF8.GetBytes(headerEncoded + "." + payloadEncoded);
            byte[] signBytes = rsa.SignData(target, new SHA256Managed());
            var signEncoded = Base64UrlEncode(signBytes);

            // concatinate header + payload + signature
            return headerEncoded + "." + payloadEncoded + "." + signEncoded;

        }

        // occur following error.
        //    IDX10630: The 'System.IdentityModel.Tokens.X509AsymmetricSecurityKey' for signing cannot be smaller than '2048' bits.
        // cause: Google's certificate key length is 1024bits, but JwtSecurityTokenHandler/WriteToken requires 2048bits key.
        static string NG_RS256()
        {
            // signing certificate
            // pfx(.p12) file which is downloaded from Google Developer Console
            var certificate = new X509Certificate2(
                CERTIFICATE_FILE,
                CERTIFICATE_PWD,
                X509KeyStorageFlags.Exportable);
            var credentials = new X509SigningCredentials(certificate);

            // token lifetime
            var issuedTime = DateTime.UtcNow;
            var expiresTime = issuedTime.AddMinutes(55);

            // additional claims
            var claims = new[]
            {
                // add scope claim
                new Claim("scope", SCOPE)
            };

            // create jwt token
            var token = new JwtSecurityToken(
                SERVICE_ACCOUNT, // iss
                TOKEN_ENDPOINT, // aud
                claims, // additional claims
                issuedTime,
                expiresTime,
                credentials);

            var handler = new JwtSecurityTokenHandler();

            // exception in WriteToken method
            return handler.WriteToken(token);

        }

        static void Main(string[] args)
        {
            string request_jwt = null;

            // NG : use System.IdentityModel.Tokens.jwt
            // Cause : JwtSecurityTokenHandler only supports 2048bit certificate for signature.
            //request_jwt = NG_RS256();

            // OK : use custom logic
            request_jwt = OK_RSA256();

            Console.WriteLine("Access token request jwt :\n{0}", request_jwt);

            Task task = Task.Factory.StartNew(async () =>
            {
                string result = await Post(
                    TOKEN_ENDPOINT,
                    new Dictionary<string, string>() { 
                        { "assertion" , request_jwt },
                        { "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer" }});

                Console.WriteLine("Access Token request result :\n{0}", result);
            }).Unwrap();
            task.Wait();
            Console.WriteLine("End");
            Console.ReadKey();

        }

        private static async Task<string> Post(string url, Dictionary<string, string> param)
        {
            string result = "";
            try
            {
                HttpClient httpClient = new HttpClient();
                httpClient.MaxResponseContentBufferSize = int.MaxValue;
                HttpContent content = new FormUrlEncodedContent(param);
                var response = await httpClient.PostAsync(url, content);
                String text = await response.Content.ReadAsStringAsync();
                result = text;
            }
            catch (Exception Err)
            {
                result = "ERROR: " + Err.Message;
            }
            return result;
        }
    }

}

0 件のコメント: