あるサービスに保存した予定や写真を、別のアプリから読みたいことがあります。アプリにそのサービスの ID とパスワードを入力させれば実現できそうですが、これは危険です。OAuth (オーオース) は、パスワードを渡さずに、決めた範囲のアクセスだけをほかのアプリに許可するための仕組みです。ここでは、OAuth の考え方と、今の主流の使い方を整理します。

パスワードを渡す方法の問題

アプリにパスワードを渡すと、次のような問題があります。

  • アプリは、そのパスワードで削除や設定の変更まで、何でもできてしまう
  • あるアプリだけ許可を取り消すには、パスワードを変えるしかない。変えると、ほかのアプリもすべて使えなくなる
  • アプリがパスワードを保存し、それが漏れるおそれがある

OAuth では、アプリはパスワードの代わりに「アクセストークン」を受け取ります。アクセストークンは、許可した範囲 (スコープ) と有効期限を持つ、API にアクセスするための文字列です。要らなくなったら、そのアプリの分だけ取り消せます。

登場人物

OAuth 2.0 の仕様 (RFC 6749) では、次の 4 つの役割で説明します。

  • リソースオーナー (利用者): データの持ち主で、アクセスを許可する人
  • クライアント: 利用者のデータを使いたいアプリ。前もってサービスに登録し、クライアント ID を受け取っておく
  • 認可サーバー: 利用者を確認して同意を得て、アクセストークンを発行するサーバー
  • リソースサーバー: データを持つ API のサーバー。アクセストークンを確かめてデータを返す

認可コードと PKCE の流れ

今いちばん使われているのは、「認可コード」を使う流れです。Web アプリの場合、大まかに次のように進みます。

  1. クライアントが、利用者のブラウザーを認可サーバーの画面へ移動させる
  2. 利用者がログインし、許可を求める画面で同意する
  3. 認可サーバーが、ブラウザーをクライアントの戻り先 (リダイレクト URI) へ戻す。URL には、一度だけ使える「認可コード」が付く
  4. クライアントは、サーバーどうしの通信で、認可コードをアクセストークンに引き換える
  5. クライアントは、アクセストークンを付けて API を呼ぶ

1 で移動させる URL は、次のような形です (見やすいように改行しています)。

https://auth.example.com/authorize?response_type=code
  &client_id=example-app
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=read
  &state=(推測できない乱数)
  &code_challenge=(code_verifier から作った値)
  &code_challenge_method=S256

state は、戻ってきたリクエストが自分で始めた流れのものかを確かめるための値です。利用者に意図しない操作をさせる攻撃 (CSRF) を防ぎます。

PKCE (ピクシー) は、認可コードを横取りされても使えないようにする仕組みです。クライアントは毎回ランダムな文字列 (code_verifier) を作り、その SHA-256 のハッシュ値から作った値 (code_challenge) を最初のリクエストに付けます。認可コードを引き換えるときには、元の code_verifier を送ります。認可サーバーは 2 つが対応しているかを確かめるので、認可コードだけを盗んでも、アクセストークンには引き換えられません。もとはスマートフォンのアプリのためのものでしたが、今は Web アプリでも使うことが勧められています。

<?php
// base64url で符号化し、末尾の = を取り除く
function base64url(string $bytes): string
{
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

$verifier  = base64url(random_bytes(32));                // セッションに保存しておく
$challenge = base64url(hash('sha256', $verifier, true)); // 認可のリクエストに付ける

アクセストークンを受け取ったら、API を呼ぶときに Authorization ヘッダーに付けて送ります。

<?php
// $accessToken は、認可コードと引き換えに受け取ったアクセストークン
$context = stream_context_create([
    'http' => [
        'header'  => "Authorization: Bearer {$accessToken}\r\nAccept: application/json\r\n",
        'timeout' => 10,
    ],
]);
$json = file_get_contents('https://api.example.com/v1/me', false, $context);

なお、トークンを URL で直接受け取る方式 (インプリシット) や、クライアントが利用者のパスワードを受け取る方式も仕様にはありますが、今は勧められていません。

OAuth 1.0a と OAuth 2.0 の違い

OAuth 1.0a は、最初の版のセキュリティ上の問題を直したもので、のちに RFC 5849 として文書になりました。API へのリクエストのたびに、秘密の値を使って署名 (HMAC-SHA1 など) を作って付けます。通信が暗号化されていなくても改ざんを見分けられますが、署名の作り方が細かく、実装でつまずきやすい方式でした。

OAuth 2.0 は、署名をやめ、通信の保護を HTTPS に任せました。API を呼ぶときは、アクセストークンを Authorization: Bearer ... のヘッダーで送るだけです。Bearer は「持参人」という意味で、トークンを持っていれば誰でも使えます。そのため、HTTPS 以外で送らない、ログや URL に残さない、といった扱いが大切です。

また、OAuth は「何を許可するか」(認可) の仕組みで、「誰であるか」(認証) を確かめる仕組みではありません。「○○でログイン」のように利用者を確かめたいときは、OAuth 2.0 の上に作られた OpenID Connect を使います。

PHP では、ライブラリを使う

署名の計算や state の確認、トークンの引き換えを自分で書くと、間違いがそのままセキュリティの穴になります。PHP では、Composer で入れられる OAuth のライブラリ (OAuth 2.0 のクライアントなら league/oauth2-client など) や、サービスが公式に出している SDK を使うのが安全です。

ライブラリを使う場合も、次の点は自分で気をつけます。

  • クライアントシークレットやトークンは、コードに直接書かず、公開されない場所 (環境変数や、ドキュメントルートの外の設定ファイル) に置く
  • スコープは、必要な最小限にする
  • リダイレクト URI は、サービスに登録したものと完全に一致させる

OAuth 1.0a の署名の細かい点は、OAuth 1.0a の署名でつまずきやすいところ で取り上げています。

まとめ

  • OAuth は、パスワードを渡さずに、範囲と期限を決めたアクセスを許可する仕組み
  • 登場人物は、利用者 (リソースオーナー)、クライアント、認可サーバー、リソースサーバー
  • OAuth 1.0a はリクエストごとに署名し、OAuth 2.0 は HTTPS と Bearer トークンを使う
  • 今の主流は、OAuth 2.0 の認可コードと PKCE。ログインに使うなら OpenID Connect
  • PHP では、署名やトークンの処理を自分で書かず、ライブラリや公式の SDK を使う

参考