TwitterのPINコード認証をシェルで実施する

# これは
自身の学習のためにTwitterの認証方法の1つであるPINコード認証を実施するシェルスクリプトを実装しました。本稿はその手順とコードの解説になります。

コードは下記に置いてあります。

https://github.com/taiga371/twitter-pincode-login

##### 環境
WSL2環境(Ubuntu 20.04 LTS)です。また、テキストブラウザとして [lynx](https://lynx.invisible-island.net/) を使用しています。

##### 前提
Twitter Developper Portalへの登録が済んでおり、アプリケーションの登録とコンシューマーキー、コンシューマーシークレットキーが取得できていることを前提とします。

# 実装の前提
### 各種キーの取得方法
Twitter APIでPINコード認証する際は、コンシューマーキー、コンシューマーシークレットキー、および（手順上）OAuthトークンシークレットが必要となります。これらは環境によって異なるため、それぞれ環境変数`CONSUMER_KEY`, `CONSUMER_SECRET_KEY`, `OAUTH_TOKEN_SECRET`で受け取るようにしています。

### nonceの生成方法
Twitter APIでリクエストを発行する際は、`oauth_nonce`パラメータを指定する必要があります。これは[こちら](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/authorizing-a-request)に記載されている通りアプリケーションがリクエストごとに生成する必要がある一意のトークンです。

oauth_nonceパラメータの生成処理は複数のリクエストで共通的に使用するため、下記のような関数に切り出しています。
``` bash
function generate_nonce {
  fold -b -w 32 < /dev/urandom | head -n 1 | base64
}
```
処理内容は、`/dev/urandom`からランダム値を取得し、`base64`エンコードしています。

### timestampの生成方法
`oauth_nonce`パラメータと同じく、Twitter APIでリクエストを発行する際は`oauth_timestamp`パラメータを指定する必要があります。これも[こちら](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/authorizing-a-request)に記載されている通りリクエストが生成された際のUnix時間からの秒数です。

`oauth_timestamp`パラメータの生成処理も複数のリクエストで共通的に使用するため、下記のような関数に切り出しています。

``` bash
function generate_timestamp {
  date +%s
}
```
処理内容は、`date`コマンドでunix時間からの経過秒数を取得しています。

### パーセントエンコードの方法
Twitter APIの仕様を見ると、さまざまな所でパーセントエンコードを行う必要があります。

bashで文字列をパーセントエンコードする方法は、調べると`jq`を用いる方法や`nkf`を用いる方法など、いくつか存在するのですが、今回は下記のように自前で実装する方法を取っています（それぞれの方法には癖があるようで、期待通りにエンコードされなかったため）。

``` bash
function pct_encode {
  local length="${#1}"
  for ((i = 0; i < length; i++)); do
    local c="${1:$i:1}"
    case $c in
    [a-zA-Z0-9.~_-]) printf '%s' "$c" ;;
    *) printf '%%%02X' "'$c" ;;
    esac
  done
}
```

処理内容なシンプルに、1文字ずつ、`a-zA-Z0-9.~_-`のいずれかであればそのまま出力、そうでなければASCIIコードの値を16進で出力しています。


# Twitter API の認証について
Twitter APIを叩くためには、基本的に認証が必要となります。認証は大きく下記2つの手順から成ります。
- 署名の作成
- Authorizationヘッダ文字列の作成

### 署名の作成
リクエストにかかわる各種パラメータを基に署名を作成します。手順は[こちら](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/creating-a-signature)。

##### 実装したコード
署名作成の処理を複数のリクエストで共通的に利用できるよう、`mksignature.sh`というコマンドへ切り出しています。
``` mksignature.sh
#!/bin/bash

# shellcheck disable=SC2214
# shellcheck disable=SC2034

############
# 署名の作成
############

###
# リクエストメソッドとURLの収集, パラメーターの収集
###
declare -A signature_params=(
  ["oauth_consumer_key"]="${CONSUMER_KEY:?CONSUMER_KEY not found}"
  ["oauth_signature_method"]="HMAC-SHA1"
  ["oauth_version"]="1.0"
)

while getopts :-: opt; do
  optarg="${!OPTIND}"
  [[ "$opt" = - ]] && opt="-$OPTARG"

  case "-$opt" in
  --http_method)
    http_method="$optarg"
    shift
    ;;
  --base_url)
    base_url="$optarg"
    shift
    ;;
  --*)
    signature_params["${opt##-}"]="$optarg"
    shift
    ;;
  esac
done

# 署名されるすべてのキーと値をパーセントエンコードします。
declare -A encoded_signature_params
for key in "${!signature_params[@]}"; do
  encoded_signature_params[$(pct_encode "${key}")]=$(pct_encode "${signature_params[${key}]}")
done

# パラメーターのリストをエンコードされたキーでアルファベット順に並べ替えます。
# (bashの連想配列はソートできないため、ソートされたキー順にアクセスすることで代替します)
orig_ifs=${IFS}
IFS=$'\n'
mapfile -t sorted_encoded_keys < <(echo "${!encoded_signature_params[*]}" | sort)
IFS=${orig_ifs}

# それぞれのキーと値のペアに対して:
parameter_string=""
for encoded_key in "${sorted_encoded_keys[@]}"; do
  # 出力文字列にエンコードされたキーを追加します。
  parameter_string="${parameter_string}${encoded_key}"
  # 出力文字列に「=」を追加します。
  parameter_string="${parameter_string}="
  # 出力文字列にエンコードされた値を追加します。
  parameter_string="${parameter_string}${encoded_signature_params[${encoded_key}]}"
  # キーと値のペアがまだ残っている場合は、出力文字列に「&」を追加します。
  parameter_string="${parameter_string}&"
done
parameter_string=${parameter_string%&} #末尾の不要な"&"を取り除く

###
# 署名ベース文字列の作成
###
signature_base_string=""
# HTTPメソッドを大文字に変換し、出力文字列をこの値に設定します。
signature_base_string="${signature_base_string}${http_method^^}"
# 出力文字列に「&」を追加します。
signature_base_string="${signature_base_string}&"
# URLをパーセントエンコードし、出力文字列に追加します。
signature_base_string="${signature_base_string}$(pct_encode "${base_url}")"
# 出力文字列に「&」を追加します。
signature_base_string="${signature_base_string}&"
# パラメーター文字列をパーセントエンコードし、出力文字列に追加します。
signature_base_string="${signature_base_string}$(pct_encode "${parameter_string}")"

###
# 署名キーの取得
###
signature_key="$(pct_encode "${CONSUMER_SECRET_KEY:?CONSUMER_SECRET_KEY not found}")&$(pct_encode "${OAUTH_TOKEN_SECRET}")"

###
# 署名の計算
###
signature="$(echo -n "${signature_base_string}" | openssl sha1 -hmac "${signature_key}" -binary | base64 | tr -d '\n')"

echo "${signature}" #作成した署名を返す
```

署名作成に利用するパラメータを格納する連想配列`signature_params`を定義し、すべてのリクエストで固定値となる`oauth_consumer_key`, `oauth_signature_method`, `oauth_version`はハードコーディング、リクエスト毎に異なるものは引数で受け付けるようにしています。

引数はロングオプションのオプション名として`http_method`, `base_url`, もしくはそれ以外のパラメータ(`oauth_xx`)が指定され、`http_method`, `base_url`の場合はそれぞれの変数、パラメータの場合はオプション名をkey、設定値をvalueとして連想配列`signature_params`へ格納しています。

それ以降の処理は[こちら](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/creating-a-signature)の手順を愚直に実装したものです。手順のうちパラメータのリストのソートについては、bashの連想配列はソートできないため、キー一覧を取得し、それをソートした順にアクセスすることで処理を代替しています。なお、`OAUTH_TOKEN_SECRET`が定義されていない場合でもエラーとしてはいませんが、これはリクエストトークンの取得など、OAuthトークンシークレットを使用しないリクエストが存在するためです（今回のPINコード認証でも使用しません）。

#### Authorizationヘッダ文字列の作成
作成した署名、および他のパラメータを基にリクエストのAuthorizationヘッダへ付与する文字列を作成します。手順は[こちら](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/authorizing-a-request)。

##### 実装したコード
ヘッダ作成の処理を複数のリクエストで共通的に利用できるよう、`mkauthorization.sh`というコマンドへ切り出しています。
``` mkauthorization.sh
#!/bin/bash

# shellcheck disable=SC2034

################
# リクエストの認証
################

###
# パラメーターの収集
###
declare -A auth_params=(
  ["oauth_consumer_key"]="${CONSUMER_KEY:?CONSUMER_KEY not found}"
  ["oauth_signature_method"]="HMAC-SHA1"
  ["oauth_version"]="1.0"
)

while getopts :-: opt; do
  optarg="${!OPTIND}"
  [[ "$opt" = - ]] && opt="-$OPTARG"

  case "-$opt" in
  --*)
    auth_params["${opt##-}"]="$optarg"
    shift
    ;;
  esac
done

###
# ヘッダー文字列の構築
###

dst=""
# 文字列「OAuth 」（最後のスペースを含む）をDSTに追加します。
dst="${dst}OAuth "
# 上述の7つのパラメーターの各キー/値のペアについて:
for key in ${!auth_params[*]}; do
  # キーをパーセントエンコードしてDSTに追加します。
  dst="${dst}$(pct_encode "${key}")"
  # 等号文字「=」をDSTに追加します。
  dst="${dst}="
  # 二重引用符「”」をDSTに追加します。
  dst="${dst}\""
  # 値をパーセントエンコードしてDSTに追加します。
  dst="${dst}$(pct_encode "${auth_params[${key}]}")"
  # 二重引用符「”」をDSTに追加します。
  dst="${dst}\""
  # キーと値のペアが残っている場合は、コンマ「,」とスペース「 」をDSTに追加します。
  dst="${dst}, "
done
dst=${dst%, } #末尾の不要な", "を取り除く

echo "${dst}" #作成したヘッダー文字列を返す
```

中の作りは前述した`mksignature.sh`とほぼ同じで、ヘッダ作成に利用するパラメータを格納する連想配列`params`を定義し、すべてのリクエストで固定値となる`oauth_consumer_key`, `oauth_signature_method`, `oauth_version`はハードコーディング、リクエスト毎に異なるものは引数で受け付け、オプション名をkey、設定値をvalueとして連想配列`params`へ格納します。

実際にヘッダを作成する処理も[こちら](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/authorizing-a-request)の手順を愚直に実装したもので、特に特筆すべきことはありません。

# PINコード認証の手順
[こちら](https://developer.twitter.com/en/docs/authentication/oauth-1-0a/pin-based-oauth)の手順を順に実施していきます。ここに書かれている通り、手順は[3レッグ認証](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/obtaining-user-access-tokens)や[Twitterでログイン](https://developer.twitter.com/ja/docs/authentication/guides/log-in-with-twitter)の手順とほぼ同じです。

## 手順1 : リクエストトークンの取得
まずはリクエストトークンを取得します。リクエストトークンとはPINコード認証（や他の認証方法）を実施するために必要となるコードであり、[oauth/request_token](https://developer.twitter.com/ja/docs/authentication/api-reference/request_token) へリクエストをpostすることで取得します。

### 署名の作成
署名の作成では下記の**HTTPメソッド**、**ベースURL**、およびパラメータ群を用います。

- **HTTPメソッド**
    POST

- **ベースURL**
    https://api.twitter.com/oauth/request_token

- パラメータ群
    | パラメータ名 | 設定値 |
    | - | - |
    | oauth_consumer_key | (アプリ固有の文字列) |
    | oauth_signature_method | "HMAC-SHA1" |
    | oauth_version | "1.0" |
    | oauth_nonce | (任意) |
    | oauth_timestamp | (任意) |
    | oauth_callback | "oob" |

    - #### oauth_consumer_key
      Twitter Developper Portalで取得する、アプリケーションを識別する一意の文字列となります。

    - #### oauth_signature_method
      Twitter APIでは`"HMAC-SHA1"`の固定文字列となります。

    - #### oauth_version
      Twitter APIでは`"1.0"`の固定文字列となります。

    - #### oauth_nonce
      前述した`oauth_nonce`パラメータです。

    - #### oauth_timestamp
      前述した`oauth_timestamp`パラメータです。

    - #### oauth_callback
      [3レッグ認証](https://developer.twitter.com/ja/docs/authentication/oauth-1-0a/obtaining-user-access-tokens)や[Twitterでログイン](https://developer.twitter.com/ja/docs/authentication/guides/log-in-with-twitter)とは異なり、PINコード認証では`oauth_callback`は固定文字列`"oob"`となります。


### Authorizationヘッダ文字列の作成
リクエストトークンの取得において、Authorizationヘッダ文字列の作成では下記のパラメータ群を用います。`oauth_signature`以外は前述の**署名の作成**と同じです。

- パラメータ群
    | パラメータ名 | 設定値 |
    | - | - |
    | oauth_consumer_key | (アプリ固有の文字列) |
    | oauth_signature_method | "HMAC-SHA1" |
    | oauth_version | "1.0" |
    | oauth_nonce | (任意) |
    | oauth_timestamp | (任意) |
    | oauth_signature | **署名の作成**で作成した署名 |

    - #### oauth_signature
      前述した**署名の作成**で作成した署名の文字列になります。

### 実装したコード
以上より、実際にリクエストトークンの取得の処理の実装は下記のようになります。
``` sh
http_method="POST"
base_url="https://api.twitter.com/oauth/request_token"
oauth_nonce="$(generate_nonce)"
oauth_timestamp="$(generate_timestamp)"
oauth_callback="oob"

# 署名の作成
oauth_signature="$(./mksignature.sh      \
  --oauth_nonce     "${oauth_nonce}"     \
  --oauth_timestamp "${oauth_timestamp}" \
  --oauth_callback  "${oauth_callback}"  \
  --http_method     "${http_method}"     \
  --base_url        "${base_url}"        \
)"

# リクエストの認証
authorization_header="$(./mkauthorization.sh \
  --oauth_nonce     "${oauth_nonce}"         \
  --oauth_timestamp "${oauth_timestamp}"     \
  --oauth_signature "${oauth_signature}"     \
)"

request_token="$(curl -s                                                   \
  --request "${http_method}"                                               \
  --url     "${base_url}?oauth_callback=$(pct_encode "${oauth_callback}")" \
  --header  "Authorization: ${authorization_header}" |  sed -r 's/^oauth_token=([^&]*).*$/\1/g')"
```

処理内容は、各種パラメータを定義したのち、前述の`mksignature.sh`と`mkauthorization.sh`で署名とAuthorizationヘッダの文字列を作成し、curlでAPIを叩いています。

レスポンスは`auth_token=XXX&oauth_token_secret=YYY&oauth_callback_confirmed=true`のような文字列で帰ってくるため、そこからリクエストトークンだけを取得しています（リクエストトークンシークレットは使用しません）。

## 手順2 : PINコードの取得の取得
手順1で取得したリクエストトークンを用いてPINコードを取得します。この手順ではブラウザでのログインが必要となるため、前述したとおりテキストブラウザである`lynx`を用いています。

### 実装したコード
``` sh
lynx "https://api.twitter.com/oauth/authenticate?oauth_token=${request_token}"
read -r -p "Input the pin code : " pincode
```

lynxで`https://api.twitter.com/oauth/authenticate`へアクセスし、取得したPINコードを入力として受け付けるようにしています。

## 手順3 : アクセストークンの取得
最後に、認証を行いアクセストークンを取得します。

### 署名の作成
署名の作成では下記の**HTTPメソッド**、**ベースURL**、およびパラメータ群を用います。`oauth_consumer_key`, `oauth_signature_method`, `oauth_version`は固定であるため手順1と同じです。

- **HTTPメソッド**
    POST

- **ベースURL**
    https://api.twitter.com/oauth/access_token

- パラメータ群
    | パラメータ名 | 設定値 |
    | - | - |
    | oauth_consumer_key | (アプリ固有の文字列) |
    | oauth_signature_method | "HMAC-SHA1" |
    | oauth_version | "1.0" |
    | oauth_nonce | (任意) |
    | oauth_timestamp | (任意) |
    | oauth_token | 手順1で取得したリクエストトークン |
    | oauth_verifier | 手順2で取得したPINコード |

    - #### oauth_nonce
      前述した`oauth_nonce`パラメータです。リクエスト毎に一意の値である必要があるため、手順1と同じものは用いずに新たに生成します。

    - #### oauth_timestamp
      前述した`oauth_timestamp`パラメータです。リクエストを生成した時刻であるため、手順1と同じものは用いずに新たに生成します。

    - #### oauth_token
      手順1で取得したリクエストトークンを指定します。

    - #### oauth_verifier
      手順2で取得したPINコードを指定します。

### Authorizationヘッダ文字列の作成
リクエストトークンの取得において、Authorizationヘッダ文字列の作成では下記のパラメータ群を用います。`oauth_signature`以外は前述の**署名の作成**と同じです。

- パラメータ群
    | パラメータ名 | 設定値 |
    | - | - |
    | oauth_consumer_key | (アプリ固有の文字列) |
    | oauth_signature_method | "HMAC-SHA1" |
    | oauth_version | "1.0" |
    | oauth_nonce | (任意) |
    | oauth_timestamp | (任意) |
    | oauth_signature | **署名の作成**で作成した署名 |

    - #### oauth_signature
      前述した**署名の作成**で作成した署名の文字列になります。

### 実装したコード
以上より、実際のアクセストークンの取得の処理の実装は下記のようになります。
``` sh
http_method="POST"
base_url="https://api.twitter.com/oauth/access_token"
oauth_nonce="$(generate_nonce)"
oauth_timestamp="$(generate_timestamp)"

# 署名の作成
oauth_signature="$(./mksignature.sh      \
  --oauth_nonce     "${oauth_nonce}"     \
  --oauth_timestamp "${oauth_timestamp}" \
  --oauth_token     "${request_token}"   \
  --oauth_verifier  "${pincode}"         \
  --http_method     "${http_method}"     \
  --base_url        "${base_url}"        \
)"

# リクエストの認証
authorization_header="$(./mkauthorization.sh \
  --oauth_nonce     "${oauth_nonce}"         \
  --oauth_timestamp "${oauth_timestamp}"     \
  --oauth_signature "${oauth_signature}"     \
)"

res="$(curl -s                                                                                                   \
  --request "${http_method}"                                                                                     \
  --url     "${base_url}?oauth_token=$(pct_encode "${request_token}")&oauth_verifier=$(pct_encode "${pincode}")" \
  --header  "Authorization: ${authorization_header}")"

oauth_token="$(echo "${res}" | cut -d "&" -f 1 | cut -d "=" -f 2)"
oauth_token_secret="$(echo "${res}" | cut -d "&" -f 2 | cut -d "=" -f 2)"

###
# 結果表示
###
echo "oauth_token        : ${oauth_token}"
echo "oauth_token_secret : ${oauth_token_secret}"
```

処理内容はstep1と同じで、各種パラメータを定義したのち、前述の`mksignature.sh`と`mkauthorization.sh`で署名とAuthorizationヘッダの文字列を作成し、curlでAPIを叩いています。

レスポンスは`oauth_token=XXX&oauth_token_secret=YYY&user_id=12345678&screen_name=ZZZ`のような文字列で帰ってくるため、そこからアクセストークンとアクセストークンシークレットだけを取得し、最後のそれらを表示して終了です。

# 最後に
本稿は以上になります。どなたかの参考になれば幸いです。

# 参考にさせていただいた記事など
https://syncer.jp/Web/API/Twitter/REST_API/

https://qiita.com/piroor/items/a999eeef9a67283b1e56

https://ka2n.dev/encode-uri-component-with-jq

https://jonlabelle.com/snippets/view/shell/url-encode-and-decode-values-in-bash

https://chitoku.jp/programming/bash-getopts-long-options