API を呼ぶプログラムを資料どおりに書いたつもりなのに、認証に失敗したり、期待しない応答が返ったりすることがあります。原因が URL のわずかな違いだった、ということは珍しくありません。特に OAuth 1.0a では URL も署名に含まれるため、1 文字の違いで署名が合わなくなります。

違いが出やすいところ

見た目はほとんど同じでも、次の URL は、サーバーから見ると別のものとして扱われることがあります。

https://example.com/api/items        (基準)
http://example.com/api/items         (スキームが違う)
https://www.example.com/api/items    (ホスト名が違う)
https://example.com/api/items/       (末尾に / がある)
https://example.com/API/Items        (パスの大文字と小文字が違う)
https://example.com:443/api/items    (既定のポート番号を書いている)
  • スキーム (https など) とホスト名は、大文字と小文字を区別しない。パスは、区別するサーバーが多い
  • 既定のポート番号 (http は 80、https は 443) は、書いても書かなくても接続先は同じ
  • 末尾の / の有無は、別の URL になる。片方へ転送する設定のサーバーもよくある
  • クエリの順番 (?a=1&b=2 と ?b=2&a=1) は、多くのサーバーで同じ意味になるが、文字列としては別物。キャッシュなどでは区別されることがある

OAuth 1.0a では URL も署名に含まれる

OAuth 1.0a の署名のもとになる文字列には、リクエストの URL が含まれます。サーバーは、自分が受け取ったリクエストから URL を組み立て直して署名を計算するので、署名に使った URL と実際に送った URL が違えば、署名は合いません (署名の作り方は OAuth 1.0a の署名でつまずきやすいところ を参照してください)。

署名に使う URL は、次の決まりで正規化 (表記をそろえること) します。

  • スキームとホスト名は小文字にする
  • ポート番号は、既定のもの (http の 80、https の 443) なら書かない。それ以外なら書く
  • パスはそのまま使う (大文字と小文字も変えない)
  • クエリと # 以降は含めない。クエリのパラメーターは、署名のパラメーターの側に入れる
<?php
// 署名に使う URL (base string URI) を作る
function oauth_base_url(string $url): string
{
    $p = parse_url($url);
    if ($p === false || !isset($p['scheme'], $p['host'])) {
        throw new InvalidArgumentException('URL を読み取れません: ' . $url);
    }
    $scheme = strtolower($p['scheme']);
    $host = strtolower($p['host']);
    $port = $p['port'] ?? null;
    $path = $p['path'] ?? '/';

    // 既定のポート番号は書かない
    $isDefault = ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443);
    $portPart = ($port !== null && !$isDefault) ? ':' . $port : '';

    return "{$scheme}://{$host}{$portPart}{$path}";
}

echo oauth_base_url('HTTPS://Example.com:443/api/items?page=2');
// https://example.com/api/items

正規化しても、http と https、www の有無、末尾の / の違いは残ります。これらは「別の URL」なので、資料のとおりの URL へ送り、その URL で署名するしかありません。

転送 (301・302) で起きること

違う URL へ送ってしまっても、サーバーが正しい URL へ転送 (リダイレクト) してくれれば動きそうに思えます。しかし、API の呼び出しでは、転送が別の問題を起こします。

  • メソッドが変わる: 歴史的な経緯から、多くのクライアントは 301・302 の転送先へ、POST ではなく GET で送り直す。本文も送られない。メソッドと本文を保ったまま転送するのは 307・308
  • Authorization ヘッダーが落ちる: 認証の情報を別のサイトに漏らさないため、転送先のホストが違うとヘッダーを送らないクライアントが多い
  • 署名が合わない: OAuth 1.0a では、ヘッダーが残っても、署名は転送前の URL で作ったものなので、転送先では合わない
  • 情報が漏れる: http で送った最初のリクエストは暗号化されていないので、トークンなどが経路の途中で読まれるおそれがある。最終的に動いていても、安全ではない

「POST したのに一覧が返ってくる」「認証の情報を付けたのに 401 になる」といった症状は、転送を疑ってみる価値があります。

API の URL の扱い方

  • API の資料に書かれた URL を、スキームから末尾の / まで、そのまま使う
  • 基準になる URL は、設定や定数の 1 か所にだけ書き、URL を組み立てる処理を共通にする
  • API の呼び出しでは転送に自動で従わず、3xx が返ったら記録して気づけるようにする
<?php
// $authorization は、作っておいた Authorization ヘッダーの値
$ch = curl_init('https://example.com/api/items');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => false, // 転送には自動で従わない
    CURLOPT_HTTPHEADER     => ['Authorization: ' . $authorization],
    CURLOPT_TIMEOUT        => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 300 && $status < 400) {
    // 転送されたら、URL を見直すきっかけにする
    error_log("API の URL が転送されました: {$status} " . curl_getinfo($ch, CURLINFO_REDIRECT_URL));
}

コマンドラインで確かめるなら、curl で応答のヘッダーを表示して、ステータスコードと Location ヘッダー (転送先) を見ます。

# 本文は捨てて、応答のヘッダーだけを表示する
curl -s -o /dev/null -D - http://example.com/api/items

Web サイトの側で www の有無をそろえる転送の設定については、www 付きの URL を www 無しの URL にリダイレクトする で取り上げています。

まとめ

  • http と https、www の有無、末尾の /、パスの大文字と小文字の違いで、別の URL になる
  • OAuth 1.0a では URL も署名に含まれる。スキームとホスト名を小文字にし、既定のポートとクエリを除いた URL で署名する
  • 転送されると、メソッドが GET に変わったり、Authorization ヘッダーが落ちたりする。http で送れば情報が漏れるおそれもある
  • API の資料に書かれた URL をそのまま使い、転送には自動で従わない

参考