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 をそのまま使い、転送には自動で従わない