OAuth 1.0a では、API へのリクエストのたびに署名を作って付けます。サーバーは、受け取ったリクエストから同じ手順で署名を作り直し、一致するかを確かめます。1 文字でも違えば「署名が正しくない」として拒否されますが、どこが違うかは教えてもらえません。ここでは、署名の作り方と、つまずきやすい点を順に見ていきます。

署名の作り方の流れ

HMAC-SHA1 (秘密の鍵と SHA-1 を組み合わせて、改ざんを見分ける値を作る方式) の署名は、次の手順で作ります。

  1. 署名に使うパラメーターを集める (oauth_ で始まるもの、URL のクエリ、フォームの形式で送る本文のパラメーター)
  2. 名前と値をそれぞれパーセント符号化し、並べ替えて、名前=値 を & でつなぐ
  3. HTTP メソッド、URL (クエリを除く)、2 の文字列を、それぞれパーセント符号化して & でつなぐ。これが署名のもとになる文字列 (signature base string) になる
  4. コンシューマーシークレットとトークンシークレットを、それぞれ符号化して & でつなぎ、鍵にする
  5. 3 の文字列を 4 の鍵で HMAC-SHA1 にかけ、結果を Base64 にする

oauth_signature 自身と、Authorization ヘッダーの realm は署名に含めません。本文が JSON のときも、本文は署名に含めません。

<?php
// OAuth 1.0a の HMAC-SHA1 署名を作る
// $url はクエリを除いた URL、$params はクエリや本文を含むすべてのパラメーター
function oauth_signature(string $method, string $url, array $params, string $consumerSecret, string $tokenSecret = ''): string
{
    // 名前と値を、それぞれ RFC 3986 に沿って符号化する
    $pairs = [];
    foreach ($params as $name => $value) {
        $pairs[] = [rawurlencode((string) $name), rawurlencode((string) $value)];
    }
    // 名前で並べ、名前が同じなら値で並べる (バイト順で比べる)
    usort($pairs, fn(array $a, array $b): int => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]));
    $normalized = implode('&', array_map(fn(array $p): string => $p[0] . '=' . $p[1], $pairs));

    // 署名のもとになる文字列: メソッド & URL & パラメーター (それぞれをもう一度符号化する)
    $base = strtoupper($method) . '&' . rawurlencode($url) . '&' . rawurlencode($normalized);
    // 鍵: トークンシークレットが無いときも、間の & は省かない
    $key = rawurlencode($consumerSecret) . '&' . rawurlencode($tokenSecret);

    return base64_encode(hash_hmac('sha1', $base, $key, true));
}

この関数は、パラメーターを連想配列で受け取るため、同じ名前のパラメーターが複数ある場合は扱えません。そのような API では、名前と値の組の配列で受け取るように書き換えてください。

符号化は rawurlencode で行う

OAuth 1.0a の符号化は、英数字と - . _ ~ の 4 つの記号だけをそのまま残し、それ以外をすべて %XX (16 進数は大文字) にする決まりです。これは RFC 3986 で「予約されていない文字」とされているものに当たります。日本語は、UTF-8 のバイト列を符号化します。

PHP でこの決まりに合うのは rawurlencode() です。urlencode() は HTML のフォームの送信形式に合わせた関数で、スペースを + にし、~ も符号化するため、署名には使えません。

<?php
echo rawurlencode('a b~c'); // a%20b~c (署名に使える)
echo urlencode('a b~c');    // a+b%7Ec (署名に使えない)

http_build_query() も、既定では urlencode() と同じ形式で符号化します。第 4 引数に PHP_QUERY_RFC3986 を指定すると、rawurlencode() と同じ形式になります。実際に送るクエリと署名とで、符号化の方法を食い違わせないようにします。

並べ替えは、符号化したあとの文字列のバイト順で行います。PHP の比較は、数字だけの文字列どうしを数として比べることがあるため ("10" と "9" など)、strcmp() で比べると確実です。

二重に符号化する部分

いちばん混乱しやすいのが、符号化が 2 回入る点です。手順 2 でパラメーターの名前と値を 1 回符号化し、手順 3 で、つないだ文字列全体をもう一度符号化します。そのため、値の中のスペースは、%20 を経て %2520 になります。

GET https://api.example.com/1/items?q=web%20design&page=2 というリクエストの場合、次のようになります。

正規化したパラメーター (1 回目の符号化):
oauth_consumer_key=ckey&oauth_nonce=4f1c2a9e&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1700000000&oauth_token=tkey&oauth_version=1.0&page=2&q=web%20design

署名のもとになる文字列 (全体をもう一度符号化する):
GET&https%3A%2F%2Fapi.example.com%2F1%2Fitems&oauth_consumer_key%3Dckey%26oauth_nonce%3D4f1c2a9e%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1700000000%26oauth_token%3Dtkey%26oauth_version%3D1.0%26page%3D2%26q%3Dweb%2520design

よくある失敗は、次のようなものです。

  • 2 回目の符号化を忘れる、または符号化済みの値をさらに符号化して 3 回にしてしまう (関数に渡す値は、符号化する前の値にする)
  • URL にクエリを付けたまま署名する (クエリはパラメーターの側に入れる)
  • Base64 の署名に含まれる + / = を、Authorization ヘッダーに入れるときに符号化し忘れる

コンシューマーシークレットを csecret、トークンシークレットを tsecret とすると、上の例の署名は ufZgXQijPxECUkRekiHFPIiYI7A= です。ヘッダーには、値を符号化して入れます。

Authorization: OAuth oauth_consumer_key="ckey", oauth_nonce="4f1c2a9e", oauth_signature="ufZgXQijPxECUkRekiHFPIiYI7A%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1700000000", oauth_token="tkey", oauth_version="1.0"

タイムスタンプと nonce

oauth_timestamp は、1970 年 1 月 1 日 0 時 (UTC) からの秒数 (Unix 時間) で、PHP では time() で得られます。送る側のサーバーの時計がずれていると、署名が正しくても拒否されます。サーバーの時刻は NTP で合わせておきます。ミリ秒の値を入れてしまう失敗にも気をつけます (JavaScript の Date.now() はミリ秒です。JavaScript で Unix 時間を取る方法 も参照してください)。

oauth_nonce は、リクエストごとに変える使い捨ての文字列です。盗み見たリクエストをそのまま送り直す攻撃 (リプレイ攻撃) を防ぐためのもので、同じ値を使い回すと拒否されます。bin2hex(random_bytes(16)) のように、推測できない乱数から作ります。失敗したリクエストを送り直すときも、タイムスタンプと nonce を作り直し、署名もやり直します。

合わないときの調べ方

署名が合わないときは、署名のもとになる文字列をログに出して、1 文字ずつ確かめるのが近道です。API の資料に、決まった値で計算した例が載っていれば、同じ値で計算して比べます。HTTP メソッドが大文字か、URL がリクエスト先と完全に同じかも見ておきます。URL の違いについては API の URL のわずかな違いで署名が合わない で取り上げています。

なお、OAuth 2.0 では署名は不要で、HTTPS の上で Bearer トークンを送るだけです。API が 2.0 に対応しているなら、そちらを使い、トークンの処理もライブラリに任せるのが楽で安全です (OAuth の基本)。

まとめ

  • 署名のもとになる文字列は「メソッド & URL & 並べ替えたパラメーター」で、それぞれを符号化してつなぐ
  • 符号化は RFC 3986 に沿う。PHP では rawurlencode() を使い、urlencode() は使わない
  • パラメーターの値は、つなぐ前に 1 回、つないだあとにもう 1 回符号化される
  • タイムスタンプは秒単位の Unix 時間、nonce はリクエストごとに変える
  • 新しく作るなら、署名の要らない OAuth 2.0 を使う

参考