PowerApps/AutomateのカスタムコネクタでOAuth1.0のAPIに接続する

#概要
PowerApps/PowerAutomateは、豊富な[コネクタ](https://docs.microsoft.com/ja-jp/connectors/connectors)により様々なサービス(WebAPI)に接続し利用することが可能です。
公式に提供されていない場合でも、自分で[カスタムコネクタ](https://docs.microsoft.com/ja-jp/connectors/custom-connectors/)を作成することで接続可能になります。
ただし、APIの利用に認証が必要な場合は以下の認証タイプしか利用できません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/64484e73-8bef-e6af-2513-16b04a603e8e.png)

APIの仕様が古く、認証にOAuth1.0やCookieを使用するようなものに「どうしても接続したい」場合は間にAzure Functionsなどの自前のAPIを用意する必要がありました。
今回は、新機能である[カスタムコード](https://docs.microsoft.com/ja-jp/connectors/custom-connectors/define-blank#step-4-optional-use-custom-code-support)を使って、カスタムコネクタだけでOAuth1.0のAPIに接続してみます。

# 実現方法
アップデートにより、[C#のカスタムコード](https://qiita.com/Rambosan/items/7cda28bac81da794b935) でコネクタ内の処理内容をカスタムできる機能が追加されました。
[DocuSign](https://github.com/microsoft/PowerPlatformConnectors/tree/dev/certified-connectors/DocuSign)のように一部の認定コネクタでもカスタムコードが利用されているようです。

今回はこれを活用してみます。
プレビュー機能につき継続的に動作する保証はありません。

### 仕組み
C#のコードを使って、OAuth1.0の認証に必要な「Authorizationヘッダ」を生成し、APIへのリクエスト時に付加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/340aee4c-7ba0-867e-4025-367115f90fee.png)

カスタムコネクタが呼び出されコードが実行されると、HttpRequestMessage型のContext.Requestにアプリなどから受け取ったリクエスト情報が格納されます。
このHttpRequestMessageに手を加えるといった仕組みです。

その後、Context.SendAsync(Context.Request)の形で認証情報を加えてAPI要求することで接続可能になります。

### OAuth1.0
OAuth1.0に接続する場合、Authorizationヘッダは以下のようになります。
oauth_consumer_keyは管理ページから取得でき固定なのですが、oauth_nonceやoauth_signatureなどは接続の都度計算する必要があります。

```
Authorization : OAuth oauth_consumer_key="",oauth_nonce="",oauth_signature="",oauth_signature_method="HMAC-SHA1",oauth_timestamp="",oauth_token="",oauth_verifier="",oauth_version="1.0"
```

仕組み上、トークン情報を直接コード内に記述する必要があり誤って公開するリスクがありますので自己責任でお願いします。


# 準備
今時OAuth1.0しか対応していないAPIなんてそんなにないと思いますが、
今回は家計簿アプリのZaim APIを例題に実現してみます。


## 開発者登録
先人たちの記事を参考に開発者登録しておきます。
https://qiita.com/shutosg/items/6845057432bca551024b

以下が管理ページから取得できる情報です。
トークンの有効期限は永続的に設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/bbadb649-4e5e-0325-78fd-b10a64743536.png)

## トークンパラメータの取得

カスタムコネクタ画面からユーザー認証画面は出せないので、事前に認証に必要なパラメータを取得しておく必要があります。
以下の5つです。

- consumerKey(開発者ページから取得)
- consumerSecret(開発者ページから取得)
- token
- tokenSecret
- verifier

取得方法は以下のいずれかを使用します。

**①POSTMANを使用する場合**

https://www.youtube.com/watch?v=3gXPjj5iEAA

**②Pythonで作られている方のものを使用する場合**

https://qiita.com/hiro2do/items/8e8998569c5c87cfce69

<details><summary>**③雑に作ったC#コンソールアプリで取得する場合**</summary><div>

```CSharp:
    class Program {

        static RestClient _client;

        static string _requestTokenUrl = "https://api.zaim.net/v2/auth/request";
        static string _userAuthUrl = "https://auth.zaim.net/users/auth";
        static string _accessTokenUrl = "https://api.zaim.net/v2/auth/access";
        static string _callbackUrl = "http://localhost:4336/";
        static string _comsumerKey = "{Your Key}";
        static string _comsumerSecret = "{Your Key}";


        static string _accessToken;
        static string _tokenSecret;
        static string _verifier;

        async static Task Main(string[] args) {

            _client = new RestClient() {
                Authenticator = new OAuth1Authenticator()
            };

            // Authentication workflow

            //token , token secretの取得 
            await GetTokens();

            //ユーザー認証用URLにアクセス
            LaunchUserAuthorization();

            Console.WriteLine("リダイレクトされたURLを入力してください。 ex." + _callbackUrl + "?oauth_token=～");
            var callbackedUrl = Console.ReadLine();

            //verifierの取得
            GetVerifier(callbackedUrl);

            //取得したtokenの検証
            if (await ValidateAccessToken() != true) return;

            var oauthParams = $@"OAuth1.0のトークン情報です。
comsumerKey     :   {_comsumerKey}
comsumerSecret  :   {_comsumerSecret}
accessToken     :   {_accessToken}
tokenSecret     :   {_tokenSecret}
verifier        :   {_verifier}
";
            Console.Write(oauthParams);
            Console.ReadLine();
        }


        static async Task GetTokens() {

            _client.Authenticator = OAuth1Authenticator.ForRequestToken(_comsumerKey, _comsumerSecret, _callbackUrl);
            
            var request = new RestRequest(_requestTokenUrl, Method.GET);
            var reponse = await _client.ExecuteAsync(request);

            var parameters = HttpUtility.ParseQueryString(reponse.Content);

            if (parameters.TryGet("oauth_token", out _accessToken) == false) {
                throw new Exception(nameof(_accessToken) + "の取得に失敗しました");
            }

            if (parameters.TryGet("oauth_token_secret", out _tokenSecret) == false) {
                throw new Exception(nameof(_tokenSecret) + "の取得に失敗しました");
            }
        }

        static void LaunchUserAuthorization() {
            System.Diagnostics.Process.Start(_userAuthUrl + "?oauth_token=" + _accessToken);
        }
        static void GetVerifier(string url) {
            var uri = new Uri(url);
            NameValueCollection query = HttpUtility.ParseQueryString(uri.Query);

            if (query.TryGet("oauth_verifier", out _verifier) == false) {
                throw new Exception("tokenの取得に失敗しました");
            }
        }

        static async Task<bool> ValidateAccessToken(){


            _client.Authenticator = OAuth1Authenticator.ForAccessToken(_comsumerKey, _comsumerSecret, _accessToken, _tokenSecret, _verifier);
           
            var request = new RestRequest(_accessTokenUrl, Method.GET);
            var reponse = await _client.ExecuteAsync(request);

            if (reponse.StatusCode != HttpStatusCode.OK) {
                throw new Exception("Tokenの検証に失敗しました。" + reponse.Content );
            }

            return true;
        }

    }

    public static class Extentions {
        public static bool TryGet(this NameValueCollection nameValueCollection, string keyName, out string value) {

            var nameValue = nameValueCollection[keyName];

            if (nameValue == null) {
                value = null;
                return false;
            }
            value = nameValue;
            return true;
        }
    }

```
</div>
</details>

う～ん、複雑・・・


# カスタムコネクタを使って接続

##カスタムコネクタの作成
こちらの記事を参考にカスタムコネクタを作成します。
https://qiita.com/Rambosan/items/7cda28bac81da794b935

認証は無しで作成。
定義画面で各アクションのエンドポイントや要求スキーマを作成しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/e578ea72-e5f8-676b-2106-bae173698540.png)

## カスタムコードに貼り付けるコード

以下のコードをカスタムコードに貼り付けて反映させます。
一部Zaim専用のロジックが入っていますが、認証部分は汎用性があると思います(多分)

認証部分はリクエスト時にAuthorizationヘッダを生成して付加するだけですので、アクション(エンドポイント)ごとにコードを書かなくてもOKです。

RestSharpを利用できると簡単なコードになるのですが、カスタムコードでは限られたライブラリしか利用できません。
そこで、RestSharpの実装を参考にカスタムコネクタで使えるようにコードを書いてみました。


oauth_signature_methodはHMAC-SHA1のみ対応です。


```CSharp:Script.cs
    public class Script : ScriptBase {

        public override async Task<HttpResponseMessage> ExecuteAsync() {

            // ①事前に取得した_consumerKeyなどをテキストで設定。
            string _consumerKey = Secret.Configuration["ConsumerKey"];
            string _consumerSecret = Secret.Configuration["ConsumerSecret"];
            string _token = Secret.Configuration["Token"];
            string _tokenSecret = Secret.Configuration["TokenSecret"];
            string _verifier = Secret.Configuration["Verifier"];

            var reqBody = await Context.Request.Content.ReadAsStringAsync();

            // Convert json request body to query
            // ②カスタムコネクタのRequestBodyはjsonなのでqueryに変換
            if (Context.Request.Method != HttpMethod.Get) {
                Context.Request.Content = new FormUrlEncodedContent(Helpers.ConvertJsonToQuery(await Context.Request.Content.ReadAsStringAsync()));
            }

            // Replace resource path if http method is put or delete.
            // ③PutやDeleteメソッドの場合はリソースパスを置換。(Zaim専用)
            if (Context.Request.Method == HttpMethod.Put || Context.Request.Method == HttpMethod.Delete) {
                var id = JObject.Parse(reqBody)["id"]?.Value<string>() ?? ":id";
                Context.Request.RequestUri = Helpers.ReplaceUri(Context.Request.RequestUri, ":id", id);
            }

            // Parse query params
            IEnumerable<KeyValuePair<string, string>> queryParams = await Helpers.ParseQueryParamsAsync(Context.Request);

            // Build OAuth1.0 Authorization.
            // ④Authorizationを生成して付加
            var auth = new OAuth1AuthorizationBuilder(Context.Request, queryParams, _consumerKey, _consumerSecret, _token, _tokenSecret, _verifier);
            Context.Request.Headers.Authorization = new AuthenticationHeaderValue("OAuth", auth.BuildAsString());

            // Send request to backend api.
            var result = await Context.SendAsync(this.Context.Request, CancellationToken);

            return result;
        }

        #region Script -> Heplers
        static class Helpers {

            // convert json content to query
            internal static IEnumerable<KeyValuePair<string, string>> ConvertJsonToQuery(string content) {

                var query = new List<KeyValuePair<string, string>>();

                if (string.IsNullOrEmpty(content)) {
                    return query;
                }

                // parse json and convert to query
                foreach (var keyValue in JObject.Parse(content)) {
                    query.Add(new KeyValuePair<string, string>(keyValue.Key, keyValue.Value.ToString()));
                }

                return query;
            }


            internal static Uri ReplaceUri(Uri requestUri, string oldString, string newString) {
                return new Uri(requestUri.ToString().Replace(oldString, newString));
            }

            // parse query  //value task is not allowed
            internal static async Task<IEnumerable<KeyValuePair<string, string>>> ParseQueryParamsAsync(HttpRequestMessage requestMessage) {

                var methods = new string[] { "POST", "PUT", "DELETE" };

                string query = requestMessage.Method.ToString() switch {
                    "GET" => requestMessage.RequestUri.Query,
                    var x when methods.Contains(x) => await requestMessage.Content.ReadAsStringAsync(),
                    _ => ""
                };

                var nameValues = HttpUtility.ParseQueryString(query);
                if (nameValues.Count < 1) {
                    return Enumerable.Empty<KeyValuePair<string, string>>();
                }

                return nameValues.AllKeys.Select(x => new KeyValuePair<string, string>(x, nameValues[x]));
            }


        }
        #endregion

        #region Script -> OAuth1AuthorizationBuilder
        /// <summary>
        /// OAuth1.0のAuthorizationを生成します。HMAC-SHA1方式固定です。
        /// </summary>
        class OAuth1AuthorizationBuilder {

            // request
            private HttpRequestMessage _requestMessage = new HttpRequestMessage();
            private IEnumerable<KeyValuePair<string, string>> _queryParams;

            // access keys
            private string _consumerKey;
            private string _token;
            private string _verifier;
            
            // secrets
            private string _tokenSecret;
            private string _consumerSecret;

            private List<WebPair> _authParameters;

            public OAuth1AuthorizationBuilder(HttpRequestMessage requestMessage,
                IEnumerable<KeyValuePair<string,string>> queryParams,
                string consumerKey, string consumerSecret,
                string token, string tokenSecret,
                string verifier
                ) {
                
                // requests
                _requestMessage = requestMessage;
                _queryParams = queryParams ?? throw new ArgumentNullException(nameof(queryParams));

                // access keys
                _consumerKey = consumerKey;
                _token = token;
                _verifier = verifier;

                // secrets
                _tokenSecret = tokenSecret;
                _consumerSecret = consumerSecret;
            }

            public string BuildAsString() {

                //必要なパラメータをListに定義
                _authParameters = new List<WebPair>() {
                    new WebPair("oauth_consumer_key",_consumerKey,true),
                    new WebPair("oauth_token",_token,true),
                    new WebPair("oauth_verifier",_verifier,true),
                    new WebPair("oauth_version","1.0"),
                    new WebPair("oauth_signature_method","HMAC-SHA1")
                };

                // timestamp
                var timeStamp = GetTimestamp();
                _authParameters.Add(new WebPair("oauth_timestamp", timeStamp));

                // nonce
                var nonce = GetNonce();
                _authParameters.Add(new WebPair("oauth_nonce", nonce));

                // query
                _authParameters.AddRange(_queryParams?.Select(x => new WebPair(x.Key , x.Value, true)));

                // Generate signature
                var signatureBase = ConcatRequestElements(_requestMessage.RequestUri, _requestMessage.Method.ToString(), _authParameters);
                var _signature = GetHmacSignature(new HMACSHA1(), _consumerSecret, _tokenSecret, signatureBase);
                _authParameters.Add(new WebPair("oauth_signature", Encode(_signature)));

                // build headers
                var authHeaders = _authParameters.Select(x => $"{x.Name}=\"{x.WebValue}\"");
                return string.Join(",", authHeaders);
            }

            private string Encode(string value) {
                return Uri.EscapeDataString(value);
            }

            static readonly Random Random = new Random();
            static readonly object RandomLock = new object();

            private string GetNonce() {

                const string chars = "1234567890abcdefghijklmnopqrstuvwxyz";
                var nonce = new char[16];

                //英数字の中からランダムに16文字を生成する。
                lock (RandomLock) {
                    for (var i = 0; i < nonce.Length; i++) nonce[i] = chars[Random.Next(0, chars.Length)];
                }

                return new string(nonce);

            }

            private string GetTimestamp() {
                var timeSpan = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
                return timeSpan.TotalSeconds.ToString();
            }

            private string GetHmacSignature(KeyedHashAlgorithm crypto, string consumerSecret, string tokenSecret, string signatureBase) {
                //暗号化キーを設定
                var key = $"{consumerSecret}&{tokenSecret}";
                crypto.Key = Encoding.UTF8.GetBytes(key);

                //signatureBaseをバイト変換してハッシュを計算
                var hash = crypto.ComputeHash(Encoding.UTF8.GetBytes(signatureBase));

                return Convert.ToBase64String(hash);

            }


            // generate signature base
            private string ConcatRequestElements(Uri requestUri, string methodName, IEnumerable<WebPair> webPairs) {

                // method name as upper case
                var method = methodName.ToUpper();

                // encoded uri 
                var baseUri = Encode(requestUri.GetLeftPart(UriPartial.Path));

                //var signatureParam = webPairs.Where(x => x.IsAuthHeader == true).ToList();
                var signatureParam = webPairs.ToList();

                // sort and join signature params
                string parameters = Uri.EscapeDataString(string.Join("&", ConcatParameters(signatureParam)));

                return $"{method}&{baseUri}&{parameters}";
            }

            // concat parameters
            private IEnumerable<string> ConcatParameters(List<WebPair> parameters) {
                // sort as key -> value and concat as a Webvalue.
                return parameters
                .Select(x => new WebPair(x.Name, x.Value, x.Encode))
                .OrderBy(x => x, WebPair.Comparer)
                .Select(x => $"{x.Name}={x.WebValue}");
            }

        }

        #endregion


        #region Script->Webpair
        /// <summary>
        /// a value object for storing the web key value pair.
        /// </summary>
        class WebPair {
            public WebPair(string name, string value, bool encode = false) {
                Name = name;
                Value = value;
                WebValue = encode ? Uri.EscapeDataString(value) : value;
                Encode = encode;
            }

            public string Name { get; }
            public string Value { get; }
            public string WebValue { get; }
            public bool Encode { get; }

            internal static WebPairComparer Comparer { get; } = new WebPairComparer();

            internal class WebPairComparer : IComparer<WebPair> {
                public int Compare(WebPair x, WebPair y) {
                    var compareName = string.CompareOrdinal(x.Name, y.Name);

                    return compareName != 0 ? compareName : string.CompareOrdinal(x.Value, y.Value);
                }
            }
        }
        #endregion

        //
    }

```

### ①事前に取得した_consumerKeyなどをテキストで設定。
_token = "abcde~";のようにテキストで設定。
上記コードは開発環境なのでUserSecretsから取得しています。

### ②カスタムコネクタのRequestBodyはjsonなのでqueryに変換

POSTやPUTの場合はRequestBodyにJSONとして要求されますが、
OAuth/APIがQuery形式しか受け付けないため、変換します。

### ③PutやDeleteメソッドの場合はリソースパスを置換。
APIのリクエストURIについて、/api/:id→ /api/123に置換。
PUTメソッドなどはURLにIDをつけて更新対象のリソースを指定するため、
RequestBodyにあるIDを取得して付加します。
→カスタムコネクタのポリシーで設定できそうな気がしますが面倒なのでコードで処理します。

### ④Authorizationを生成して付加
そのまんまです。


<font color="red">'21/9/18 追記</font>
GETメソッドしか対応していなかったので、POSTやPUTに対応。

## テストと応答スキーマ
ここからは通常のカスタムコネクタと同様です。
カスタムコードを保存して有効にしたら、各アクションをテストしていき、成功したら応答スキーマを定義に登録します。
その後、再度カスタムコードを保存。
※何か適当に文字列の編集を行わないとコードが反映されません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/47b11c61-40da-f9a4-f857-5123f298fdd8.png)

# PowerAppsでの利用例
カスタムコネクタが完成すれば、後は普通にPowerAppsから呼んで利用するだけです。
以下はZaimに登録した出費一覧を表示するアプリの画面です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/535d8f59-22f7-f831-baaf-b3c2aa5320d3.png)

PowerAutomateでも同様に利用できます。
カスタムコネクタでローコードの領域にもってくれば、後はこちらのものですね。
LINEと連携して家計簿に登録とかもできそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/45a73c7a-4f98-d94e-6ba0-d51e31454267.png)



# その他
業界未経験者の糞コードです。ご注意ください。