認可コードフロー
この章でわかること:
- 認可コードフローの全体の流れと、各段階のHTTPメッセージ
- 認可リクエストのパラメータ(
response_type、client_id、redirect_uri、scope、state)の意味 - リダイレクトURIの登録と完全一致の比較が必要な理由
- アクセストークンを直接返さず、認可コードを1度挟む理由
stateパラメータが防ぐ攻撃(CSRF)と、エラーレスポンスの扱い
全体の流れ
2章の抽象フローでは、クライアントが権限付与を受け取り、 それをアクセストークンと交換しました。認可コードフロー(authorization code flow)は、 権限付与として認可コード(authorization code)を使う手順です。認可コードは、 認可サーバがリソースオーナーの許可を得た後に発行する、有効期間が短く1回しか使えない値です。 RFC 6749ではこの手順を「Authorization Code Grant」(4.1節)と呼んでいます。
例は前章と同じで、aliceが印刷サービス(クライアント、client.example.com)に、
写真共有サービスの写真の読み出しを許可します。認可サーバはserver.example.comです。
ホスト名はRFC 6749の例に合わせています。
(1)から(4)はブラウザを経由するフロントチャネル、(5)はクライアントと認可サーバが 直接通信するバックチャネルです。(1)と(2)が認可リクエスト、(3)の応答が認可レスポンス、 (5)がトークンリクエストとトークンレスポンスです。以下、メッセージを順に説明し、 最後に1つのプロセスの中でこの流れ全体を再現します。
フローで使うURLには名前があります。RFC 6749の3節は、認可サーバ側の2つのエンドポイントと、 クライアント側の1つのエンドポイントを定めています。エンドポイント(endpoint)は、 特定の役割を持つHTTPのURLのことです。
- 認可エンドポイント(authorization endpoint): 認可サーバのURLで、ブラウザ経由で
リソースオーナーに画面を表示し、権限付与を得るために使います。例では
https://server.example.com/authorizeです - トークンエンドポイント(token endpoint): 認可サーバのURLで、クライアントが
権限付与をアクセストークンと交換するために使います。例では
https://server.example.com/tokenです - リダイレクトエンドポイント(redirection endpoint): クライアントのURLで、認可サーバが
ブラウザ経由で認可レスポンスを返す先です。例では
https://client.example.com/cbです
リダイレクトエンドポイントのURIをリダイレクトURI(redirection URI)と呼びます。
クライアントは事前に認可サーバへ登録しておく必要があります。登録の手順は
RFC 6749の範囲外で、多くの認可サーバでは管理画面から登録します。登録すると
認可サーバはクライアントを識別するための文字列を発行します。これがクライアントIDで、
例ではs6BhdRkqt3です(RFC 6749の例の値です)。
認可リクエスト
フローは、利用者がクライアントの「写真共有サービスから取り込む」のようなボタンを押すところから始まります。 クライアントは認可エンドポイントのURLにパラメータを付けたURLを組み立て、ブラウザにそこへ 移動するよう指示します。
HTTP/1.1 302 Found
Location: https://server.example.com/authorize?response_type=code&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&scope=photos%3Aread&state=DbKQVE43RWfoeIXLycA2zA
Set-Cookie: client_sid=wjnmgQOYuW3neGnWXPSyow; Path=/; Secure; HttpOnly; SameSite=Lax
ステータスコード302(Found)は、リソースが一時的に別のURLにあることを示すステータスコードで、
ブラウザはLocationヘッダのURLを自動で開きます。このようにレスポンスで別のURLを開かせることを
リダイレクト(redirect)と呼びます。RFC 6749は例に302を使っていますが、1.7節で
ブラウザを移動させる手段は302に限らず実装の詳細である、と書いています。
ブラウザはLocationのURLを開き、認可エンドポイントに次のリクエストが届きます。
GET /authorize?response_type=code&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&scope=photos%3Aread&state=DbKQVE43RWfoeIXLycA2zA HTTP/1.1
Host: server.example.com
URLの?より後ろの部分をクエリ文字列(query string)と呼びます。
名前=値の組を&で区切って並べる形式で、認可リクエストのパラメータはすべてここに入ります。
クライアントから認可サーバへのメッセージが、ブラウザが開くURLとして送られています。
これがフロントチャネルの具体的な形です。
RFCを読む: RFC 6749 4.1.1節
認可リクエストのパラメータは4.1.1節が定めています。
The client constructs the request URI by adding the following parameters to the query component of the authorization endpoint URI using the "application/x-www-form-urlencoded" format, per Appendix B:
(RFC 6749 4.1.1節)
この文は、パラメータを認可エンドポイントのURIのクエリ部分に、
application/x-www-form-urlencoded形式で付け加えると定めています。この形式は次節で説明します。
パラメータは5つです。
response_type REQUIRED. Value MUST be set to "code".
client_id REQUIRED. The client identifier as described in Section 2.2.
redirect_uri OPTIONAL. As described in Section 3.1.2.
scope OPTIONAL. The scope of the access request as described by Section 3.3.
state RECOMMENDED. An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in Section 10.12.
(RFC 6749 4.1.1節)
response_type(レスポンス種別)は、認可エンドポイントが何を返すかを指定するパラメータです。
必須(REQUIRED)で、認可コードフローでは値をcodeにしなければなりません(MUST)。
RFC 6749はこのほかにtoken(インプリシットフロー、19章)を定めています。
client_id(クライアントID)は、認可サーバがクライアントの登録時に発行した識別子です。
必須です。2.2節は、クライアントIDはリソースオーナーにも見える公開の値なので、
クライアントIDだけでクライアントを認証してはならない(MUST NOT)、と定めています。
実際、上のURLにはクライアントIDがそのまま現れていて、ブラウザを使う誰もが読めます。
redirect_uri(リダイレクトURI)は、認可レスポンスを返す先のURIです。4.1.1節では任意(OPTIONAL)ですが、
これは登録済みのリダイレクトURIが1つだけで、それを使う場合に省略できるという意味です。
省略できる条件と、認可サーバの比較方法は3.1.2節が定めていて、後で読みます。
scope(スコープ)は、クライアントが求めるアクセスの範囲を指定するパラメータです。
値は空白で区切った文字列の並びで、例のphotos:readのように、どの文字列がどの範囲を意味するかは
認可サーバが決めます。スコープの設計は4章で扱います。
state(state パラメータ)は、クライアントが自分で生成して認可リクエストに付ける値で、
認可サーバはそれを認可レスポンスにそのまま付けて返します。RFCは推奨(RECOMMENDED)とし、
CSRFを防ぐために使うべきである(SHOULD)と書いています。原文の「opaque value」は、
認可サーバにとって中身の意味を持たない値であることを指しています。認可サーバは
stateを解釈せず、受け取った値を返すだけです。stateの役割はこの章の後半で説明します。
RFCの例は次のとおりです(表示のために改行が入っています)。
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com
(RFC 6749 4.1.1節)
3.1節は、認可エンドポイント全般の規則も定めています。認可エンドポイントへのリクエストでは 利用者の認証が行われ、資格情報が平文で送られるため、認可サーバはTLSの使用を 求めなければなりません(MUST)。値のないパラメータは省略されたものとして扱わなければならず(MUST)、 認識できないパラメータは無視しなければならず(MUST)、同じパラメータを2回以上含めてはなりません(MUST NOT)。
URLエンコードとapplication/x-www-form-urlencoded
認可リクエストのredirect_uriの値は、https%3A%2F%2Fclient.example.com%2Fcbのように
%と16進数の並びに置き換えられていました。
URLエンコード(URL encoding)は、URLの中で特別な意味を持つ文字や、URLに使えない文字を、
%の後にそのバイト値を16進数2桁で書いた形に置き換える変換です。RFC 3986では
パーセントエンコーディング(percent-encoding)と呼ばれます。例えば:(0x3A)は%3A、
/(0x2F)は%2Fになります。redirect_uriの値はそれ自体がURLなので、
:や/や?や&を含みます。そのまま入れると、外側のURLの区切りと区別できなくなります。
application/x-www-form-urlencodedは、HTMLのフォームの送信に由来する、
名前=値の組を&で連結する形式です。名前と値はURLエンコードし、空白は+で表します。
RFC 6749は、認可リクエストのクエリ、認可レスポンスのクエリ、トークンリクエストのボディの
すべてでこの形式を使います。RFC 6749の付録Bは、この形式の扱いを補っています。
To address this shortcoming when generating payloads using this media type, names and values MUST be encoded using the UTF-8 character encoding scheme [RFC3629] first; the resulting octet sequence then needs to be further encoded using the escaping rules defined in [W3C.REC-html401-19991224].
(RFC 6749 付録B)
この文は、元になったHTML 4.01の定義がASCII以外の文字を考慮していなかったため、
名前と値をまずUTF-8でバイト列にし(MUST)、そのバイト列をHTMLの規則でエスケープする、と定めています。
付録Bには、空白、%、&、+、ポンド記号、ユーロ記号の6文字の例があります。
実験: application/x-www-form-urlencodedの符号化と解析
付録Bの例を再現し、redirect_uriの符号化と、符号化しない場合に何が起きるかを確かめます。
符号化と解析にはform_urlencodedクレートを使います。
use percent_encoding::percent_decode_str;
use std::collections::HashMap;
fn encode(value: &str) -> String {
form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
fn main() {
println!("[1] RFC 6749 付録B の例");
let value = " %&+\u{00A3}\u{20AC}";
let bytes: Vec<String> = value.bytes().map(|b| format!("{b:02X}")).collect();
println!(" UTF-8 のバイト列: {}", bytes.join(" "));
let encoded = encode(value);
println!(" エンコード結果 : {encoded}");
println!(" RFC の値と一致 : {}", encoded == "+%25%26%2B%C2%A3%E2%82%AC");
println!("\n[2] redirect_uri の値をクエリに入れる");
let redirect_uri = "https://client.example.com/cb";
let ours = encode(redirect_uri);
let rfc = "https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb";
println!(" このコード : {ours}");
println!(" RFC 4.1.1節 : {rfc}");
let decoded_rfc = percent_decode_str(rfc).decode_utf8().unwrap();
println!(" 復号すると同じ値か: {}", decoded_rfc == redirect_uri);
println!("\n[3] エンコードしないと、値の中の & と = が区切りとして解釈される");
let state = "a&scope=admin";
let broken = format!("response_type=code&state={state}");
let correct = format!("response_type=code&state={}", encode(state));
for (label, q) in [("エンコードなし", &broken), ("エンコードあり", &correct)] {
let pairs: Vec<(String, String)> = form_urlencoded::parse(q.as_bytes()).into_owned().collect();
println!(" {label}: {q}");
println!(" 解析結果: {pairs:?}");
}
println!("\n[4] 同じパラメータが2回現れるクエリ");
let q = "client_id=s6BhdRkqt3&scope=photos%3Aread&scope=admin";
let pairs: Vec<(String, String)> = form_urlencoded::parse(q.as_bytes()).into_owned().collect();
let last_wins: HashMap<String, String> = pairs.iter().cloned().collect();
println!(" 解析結果 (順序どおり): {pairs:?}");
println!(" HashMap に入れると : scope = {:?}", last_wins["scope"]);
let mut seen = std::collections::HashSet::new();
let dup = pairs.iter().find(|(k, _)| !seen.insert(k.clone()));
match dup {
Some((k, _)) => println!(" 重複を検出: {k} (invalid_request として拒否すべき)"),
None => println!(" 重複なし"),
}
}
出力の[1]は、付録Bの6文字が+%25%26%2B%C2%A3%E2%82%ACになり、RFCの値と一致することを示しています。
空白が+に、+自体は%2Bになる点が、URLのパス部分のパーセントエンコーディングとの違いです。
[2]では、このクレートの出力とRFC 4.1.1節の例の値が異なっています。RFCの例は.も
%2Eに変換していますが、.はエンコードしなくてもよい文字なので、このクレートは変換しません。
復号すると同じ値になるので、どちらも正しい符号化です。このため、受信側は
復号してから値を比べる必要があります。受け取った文字列をそのまま比べると、同じ値を異なると判定します。
[3]は、stateの値に&と=が含まれる場合です。エンコードせずに連結すると、
解析した側はscope=adminという別のパラメータがあると解釈します。値の中身を攻撃者が
制御できる場合、この誤りはパラメータの追加に利用されます。URLの組み立てでは、
form_urlencoded::Serializerやurlクレートのquery_pairs_mutのような
符号化を行う関数を使い、文字列の連結で組み立てないでください。
[4]は、同じパラメータが2回現れるクエリです。HashMapに集めると後の値で上書きされ、
scopeはadminになります。解析の方法によって最初の値と最後の値のどちらが使われるかが変わるため、
RFC 6749の3.1節は、パラメータを2回以上含めてはならないと定め、4.1.2.1節は
パラメータの重複をinvalid_requestの条件に含めています。
認可レスポンス
認可エンドポイントは、リクエストを検証した後、ブラウザにログイン画面を表示して aliceを認証し、続けて同意画面を表示します。同意画面には、どのクライアントが どの範囲のアクセスを求めているかを表示します。ログインの方法と同意画面の内容は RFC 6749の範囲外で、認可サーバごとに異なります。
aliceが許可すると、認可サーバは認可コードを生成し、リダイレクトURIにパラメータを付けて ブラウザをリダイレクトさせます。
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=0gIyJ8LSMAvBSFepD9I7Rw&state=DbKQVE43RWfoeIXLycA2zA
ブラウザはこのURLを開き、クライアントのリダイレクトエンドポイントに次のリクエストが届きます。
GET /cb?code=0gIyJ8LSMAvBSFepD9I7Rw&state=DbKQVE43RWfoeIXLycA2zA HTTP/1.1
Host: client.example.com
Cookie: client_sid=wjnmgQOYuW3neGnWXPSyow
認可サーバからクライアントへのメッセージ(認可コード)が、ブラウザが開くURLとして送られています。 クライアントのセッションCookieも付いているので、クライアントはこのリクエストが どのブラウザのセッションから来たかを特定できます。
RFCを読む: RFC 6749 4.1.2節
認可レスポンスのパラメータは2つです。codeの定義には、認可コードの性質に関する規則がまとまっています。
code REQUIRED. The authorization code generated by the authorization server. The authorization code MUST expire shortly after it is issued to mitigate the risk of leaks. A maximum authorization code lifetime of 10 minutes is RECOMMENDED. The client MUST NOT use the authorization code more than once. If an authorization code is used more than once, the authorization server MUST deny the request and SHOULD revoke (when possible) all tokens previously issued based on that authorization code. The authorization code is bound to the client identifier and redirection URI.
(RFC 6749 4.1.2節)
この節は、codeパラメータ(認可コード)について、次のことを定めています。
- 漏洩の危険を減らすため、認可コードは発行後すぐに期限切れにならなければなりません(MUST)。 最長でも10分にすることが推奨されます(RECOMMENDED)
- クライアントは認可コードを2回以上使ってはなりません(MUST NOT)
- 2回以上使われた場合、認可サーバはリクエストを拒否しなければならず(MUST)、 可能ならそのコードに基づいて発行したすべてのトークンを失効させるべきです(SHOULD)
- 認可コードは、クライアントIDとリダイレクトURIに結びつけられています
最後の規則は、トークンリクエストの検証で使われます。認可サーバは、コードを発行したときの クライアントIDとリダイレクトURIを記録しておき、トークンリクエストの値と照合します。
stateは、認可リクエストにstateがあった場合は必須(REQUIRED)で、
クライアントから受け取った値をそのまま返します。
4.1.2節は続けて、クライアントは認識できないレスポンスパラメータを無視しなければならない(MUST)こと、 認可コードの長さはRFCでは定めないことを書いています。
RFCを読む: RFC 6749 3.1.2節
リダイレクトURIの規則は3.1.2節とその下位の節が定めています。まず形式の規則です。
The redirection endpoint URI MUST be an absolute URI as defined by [RFC3986] Section 4.3. The endpoint URI MAY include an "application/x-www-form-urlencoded" formatted (per Appendix B) query component ([RFC3986] Section 3.4), which MUST be retained when adding additional query parameters. The endpoint URI MUST NOT include a fragment component.
(RFC 6749 3.1.2節)
この節は、リダイレクトURIはスキームを含む絶対URIでなければならないこと(MUST)、
クエリを含んでもよいが(MAY)、認可サーバがパラメータを追加するときは既存のクエリを
保持しなければならないこと(MUST)、#以降のフラグメントを含んではならないこと(MUST NOT)を定めています。
RFC 3986 4.3節の絶対URIはスキーム:で始まるURIで、ホスト部を持つかどうかはスキームによって決まります。
例えば、RFC 8252 7.1節はネイティブアプリ向けにcom.example.app:/oauth2redirect/example-providerのような
ホスト部のないリダイレクトURIを示しています。
登録と比較の規則は3.1.2.2節と3.1.2.3節にあります。
The authorization server SHOULD require the client to provide the complete redirection URI (the client MAY use the "state" request parameter to achieve per-request customization).
(RFC 6749 3.1.2.2節)
If the client registration included the full redirection URI, the authorization server MUST compare the two URIs using simple string comparison as defined in [RFC3986] Section 6.2.1.
(RFC 6749 3.1.2.3節)
3.1.2.2節は、認可サーバはクライアントにリダイレクトURIの全体を登録させるべきである(SHOULD)と定め、
リクエストごとに変えたい情報はstateに入れればよい、と補っています。3.1.2.3節は、
全体が登録されている場合、認可サーバはリクエストのredirect_uriと登録値を
単純な文字列比較で比べなければならない(MUST)、と定めています。
この規則は、RFC 6749では「全体を登録させるべき(SHOULD)」にとどまっていて、 スキームとホストとパスだけを登録させ、クエリの違いを許す運用も認めていました。 その後、前方一致や部分一致で比較する実装への攻撃が多数報告され、 RFC 9700(2025年1月、BCP)はこの規則を強めています。
When comparing client redirection URIs against pre-registered URIs, authorization servers MUST utilize exact string matching except for port numbers in localhost redirection URIs of native apps (see Section 4.1.3).
(RFC 9700 2.1節)
この文は、認可サーバはリダイレクトURIを登録値と完全一致で比較しなければならない(MUST)、 と定めています。例外は、ネイティブアプリがループバックアドレスで受ける場合のポート番号だけです (24章)。RFC 9700はRFC 6749を更新する(Updates)文書なので、 現在の実装はこの規則に従います。前方一致の比較で何が起きるかは 22章で実験します。
リダイレクトURIが不正な場合の扱いは3.1.2.4節が定めています。
If an authorization request fails validation due to a missing, invalid, or mismatching redirection URI, the authorization server SHOULD inform the resource owner of the error and MUST NOT automatically redirect the user-agent to the invalid redirection URI.
(RFC 6749 3.1.2.4節)
この節は、リダイレクトURIが欠けている、不正である、または登録値と一致しない場合、 認可サーバはリソースオーナーにエラーを知らせるべきであり(SHOULD)、 その不正なリダイレクトURIへブラウザを自動でリダイレクトさせてはならない(MUST NOT)、と定めています。 不正なリダイレクトURIへエラーを返すと、攻撃者が指定したURLへブラウザを送る手段を 攻撃者に与えることになります。このように任意のURLへブラウザを移動させられる仕組みは オープンリダイレクタと呼ばれ、22章で扱います。
RFCを読む: RFC 6749 4.1.2.1節
認可エンドポイントでの処理が失敗する場合は2種類あり、RFC 6749はそれぞれ扱いを分けています。
If the request fails due to a missing, invalid, or mismatching redirection URI, or if the client identifier is missing or invalid, the authorization server SHOULD inform the resource owner of the error and MUST NOT automatically redirect the user-agent to the invalid redirection URI.
If the resource owner denies the access request or if the request fails for reasons other than a missing or invalid redirection URI, the authorization server informs the client by adding the following parameters to the query component of the redirection URI using the "application/x-www-form-urlencoded" format, per Appendix B:
(RFC 6749 4.1.2.1節)
この節は、失敗の扱いを次の2つに分けています。
- リダイレクトURIまたはクライアントIDに問題がある場合: エラーを返す先が信頼できないので、 リダイレクトせずに利用者へ直接表示します
- それ以外の場合(利用者が拒否した、パラメータが不正など): リダイレクトURIにエラーを付けて クライアントへ知らせます
このため、認可エンドポイントはclient_idとredirect_uriを最初に検証し、
それが通った後で他のパラメータを検証する順序で実装します。
エラーを知らせるパラメータはerror(必須)、error_description(任意、人が読む説明)、
error_uri(任意、説明のページのURI)、state(認可リクエストにあった場合は必須)です。
errorパラメータの値はエラーコードで、次の7つが定められています。
| エラーコード | 意味 |
|---|---|
invalid_request |
必須のパラメータがない、値が不正、パラメータの重複など、リクエストの形式が不正 |
unauthorized_client |
このクライアントは、この方法で認可コードを要求することを許されていない |
access_denied |
リソースオーナーまたは認可サーバが要求を拒否した |
unsupported_response_type |
認可サーバがこのresponse_typeをサポートしていない |
invalid_scope |
要求されたスコープが不正、未知、または形式が誤っている |
server_error |
認可サーバの内部エラー(リダイレクトでは500を返せないため) |
temporarily_unavailable |
過負荷や保守で一時的に処理できない(リダイレクトでは503を返せないため) |
500 Internal Server Errorは、サーバ内部の予期しない障害でリクエストを処理できなかったことを表すステータスコードです。
server_errorとtemporarily_unavailableの注記は、フロントチャネルの性質を示しています。
認可サーバからクライアントへのメッセージはブラウザのリダイレクトとして届くので、
HTTPのステータスコードでは伝えられず、クエリのパラメータで伝える必要があります。
RFCの例は次のとおりです。
HTTP/1.1 302 Found
Location: https://client.example.com/cb?error=access_denied&state=xyz
(RFC 6749 4.1.2.1節)
error_descriptionはクライアントの開発者のための情報で、利用者に見せるためのものではありません。
またフロントチャネルを通る値なので、攻撃者が偽の値を作ってリダイレクトエンドポイントを
開かせることもできます。クライアントはerror_descriptionの内容をそのまま画面に表示しないでください。
トークンリクエスト
リダイレクトエンドポイントで認可コードを受け取ったクライアントは、 ブラウザへの応答を保留したまま、トークンエンドポイントへ直接リクエストを送ります。 これがバックチャネルの通信です。
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=0gIyJ8LSMAvBSFepD9I7Rw&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
ボディはapplication/x-www-form-urlencoded形式で、3つのパラメータがあります。
grant_type(グラントタイプ)は、どの種類の権限付与を提示しているかを示すパラメータで、
認可コードの場合はauthorization_codeです。codeは受け取った認可コード、
redirect_uriは認可リクエストで送ったものと同じ値です。
Authorizationヘッダは、クライアント自身の認証です。クライアント認証(client authentication)は、
トークンエンドポイントへのリクエストを送ってきたのが、登録されたクライアント本人であることを
認可サーバが確かめる処理です。RFC 6749の2.3.1節は、クライアントにクライアントシークレット
(client secret)が発行されている場合、1章のBasic認証で
クライアントIDをユーザー名、クライアントシークレットをパスワードとして送る方法を定めています。
クライアントシークレットは、認可サーバとクライアントだけが保持する秘密の文字列で、
クライアントにとってのパスワードにあたります。2.3.1節は、クライアントIDとクライアントシークレットを
それぞれ付録Bのapplication/x-www-form-urlencodedの規則で符号化してから、
ユーザー名とパスワードとして使うと定めています。このため、ヘッダの値は
「符号化したクライアントID、コロン、符号化したクライアントシークレット」を連結してBase64にしたものになり、
受信側はBase64を復号して最初のコロンで分けた後、それぞれを復号します。
例のs6BhdRkqt3とgX1fBat3bVは符号化しても変わらない文字だけなので、例のヘッダの値は
s6BhdRkqt3:gX1fBat3bVをBase64にしたものと同じです(RFC 6749の例の値です)。
クライアント認証の方法はこのほかにもあり、ボディにclient_secretを入れる方法、
秘密鍵で署名したJWT(7章)を送る方法などがあります。秘密を安全に保持できないクライアントもあります。
これらは13章で扱います。
RFCを読む: RFC 6749 4.1.3節と4.1.4節
redirect_uri REQUIRED, if the "redirect_uri" parameter was included in the authorization request as described in Section 4.1.1, and their values MUST be identical.
client_id REQUIRED, if the client is not authenticating with the authorization server as described in Section 3.2.1.
(RFC 6749 4.1.3節)
この節は、認可リクエストにredirect_uriを含めた場合は、トークンリクエストにも含めることが必須で、
2つの値は同一でなければならない(MUST)、と定めています。また、クライアント認証をしない
クライアントはclient_idを送ることが必須です。
認可サーバが行う検証は、次のように列挙されています。
The authorization server MUST:
o require client authentication for confidential clients or for any client that was issued client credentials (or with other authentication requirements),
o authenticate the client if client authentication is included,
o ensure that the authorization code was issued to the authenticated confidential client, or if the client is public, ensure that the code was issued to "client_id" in the request,
o verify that the authorization code is valid, and
o ensure that the "redirect_uri" parameter is present if the "redirect_uri" parameter was included in the initial authorization request as described in Section 4.1.1, and if included ensure that their values are identical.
(RFC 6749 4.1.3節)
この5項目は、認可サーバが必ず行わなければならない(MUST)検証です。言い換えると、 認可サーバは次の5つを行います。
- 認証が必要なクライアントにはクライアント認証を求めます
- 認証情報が提示された場合は、それを検証します
- 認可コードがそのクライアントに発行されたものかを確かめます
- 認可コードが有効か(期限内で未使用か)を確かめます
- 認可リクエストに
redirect_uriがあった場合は、トークンリクエストにもredirect_uriがあり、 2つの値が同一であることを確かめます
原文のconfidential client(機密クライアント)とpublic client(パブリッククライアント)は、 秘密を保持できるかどうかによるクライアントの区別で、13章で扱います。
4.1.4節は、検証に成功したら5.1節の形式でアクセストークンを返し、 失敗したら5.2節の形式でエラーを返すと定めています。成功時のレスポンスは次の形です。
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
(RFC 6749 4.1.4節)
レスポンスの各項目とCache-Controlヘッダの意味は4章で読みます。
エラーの場合の代表的な値がinvalid_grantで、認可コードが無効、期限切れ、使用済み、
リダイレクトURIの不一致、別のクライアントに発行されたもの、のいずれかを表すエラーコードです。
なぜコードを1度挟むのか
認可サーバは、aliceの許可を得た時点でアクセストークンを発行できます。 それをリダイレクトURIに付けて返せば、トークンリクエストの往復を省けます。 それでも認可コードフローがコードを1度挟むのは、フロントチャネルとバックチャネルの 性質の違いによるものです。
フロントチャネルを通る値は、次の場所に残り、また読まれる可能性があります。
- ブラウザの履歴:
https://client.example.com/cb?code=...というURLが履歴に記録されます - Refererヘッダ: リダイレクトエンドポイントの応答のページから外部のリソースを読み込むと、 ブラウザが送るRefererヘッダ(遷移元のページのURLを伝えるヘッダ)にURLが含まれることがあります
- サーバやプロキシのアクセスログ: URLはログに記録されるのが一般的です
- 拡張機能と他のアプリケーション: ブラウザの拡張機能や端末上の他のアプリケーションが、URLを読み取れる場合があります
RFC 6749の10.5節は、このことを次のように書いています。
Since authorization codes are transmitted via user-agent redirections, they could potentially be disclosed through user-agent history and HTTP referrer headers.
(RFC 6749 10.5節)
認可コードがこれらの経路で漏れても、攻撃者がそれだけでアクセストークンを得られないように、 認可コードには次の制約が付いています。
- 有効期間が短く、10分以内が推奨されています
- 1回しか使えません
- 発行先のクライアントIDとリダイレクトURIに結びついています
- 本章の例のように秘密を保持できるクライアント(機密クライアント)では、 トークンエンドポイントでの交換にクライアント認証も必要です
攻撃者が漏れた認可コードを入手しても、クライアントシークレットを知らなければ トークンエンドポイントで交換できません。クライアントが先に交換していれば、コードは使用済みです。 一方、アクセストークンはバックチャネルで、TLSで保護された1回の応答としてクライアントに直接届き、 ブラウザのURLには一度も現れません。RFC 6749の1.3.1節は、認可コードの利点を次のように要約しています。
The authorization code provides a few important security benefits, such as the ability to authenticate the client, as well as the transmission of the access token directly to the client without passing it through the resource owner's user-agent and potentially exposing it to others, including the resource owner.
(RFC 6749 1.3.1節)
この文は、認可コードを使う利点として、クライアントを認証できること、 アクセストークンをリソースオーナーのユーザーエージェントを経由させずにクライアントへ直接渡せることを 挙げています。
クライアントシークレットを持たないクライアント(スマートフォンのアプリなど)では、 4つ目の制約が成り立ちません。この場合に認可コードの横取りを防ぐ仕組みがPKCEで、 9章で扱います。RFC 9700は、クライアントシークレットを持つ クライアントにもPKCEの使用を推奨しています。
実験: 認可コードフローを1プロセスで再現する
ここまでのメッセージを、1つのプロセスの中で再現します。ブラウザ、クライアント、認可サーバを
それぞれ関数と構造体で表し、ブラウザはHostヘッダの値に応じて宛先の関数を呼び出します。
各リクエストとレスポンスはHTTP/1.1のテキストとして表示し、フロントチャネルか
バックチャネルかを示します。確かめることは2つで、各段階のメッセージの形と、
アクセストークンがブラウザの開くURLに一度も現れないことです。
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use std::collections::HashMap;
use std::time::{Duration, Instant};
use url::Url;
// HTTP メッセージを文字列として扱うための最小限の型
struct Request {
method: &'static str,
url: Url,
headers: Vec<(String, String)>,
body: String,
}
struct Response {
status: &'static str,
headers: Vec<(String, String)>,
body: String,
}
fn request_text(r: &Request) -> String {
let target = match r.url.query() {
Some(q) => format!("{}?{q}", r.url.path()),
None => r.url.path().to_string(),
};
let mut s = format!("{} {target} HTTP/1.1\nHost: {}", r.method, r.url.host_str().unwrap());
for (k, v) in &r.headers {
s += &format!("\n{k}: {v}");
}
if !r.body.is_empty() {
s += &format!("\n\n{}", r.body);
}
s
}
fn response_text(r: &Response) -> String {
let mut s = format!("HTTP/1.1 {}", r.status);
for (k, v) in &r.headers {
s += &format!("\n{k}: {v}");
}
if !r.body.is_empty() {
s += &format!("\n\n{}", r.body);
}
s
}
fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn h(k: &str, v: impl Into<String>) -> (String, String) {
(k.to_string(), v.into())
}
fn random_value() -> String {
let mut bytes = [0u8; 16];
rand::fill(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn form_encode(pairs: &[(&str, &str)]) -> String {
let mut s = form_urlencoded::Serializer::new(String::new());
for (k, v) in pairs {
s.append_pair(k, v);
}
s.finish()
}
// 同じ名前が2回以上現れたら None を返す (RFC 6749 3.1節、3.2節)
fn form_decode(s: &str) -> Option<HashMap<String, String>> {
form_urlencoded::parse(s.as_bytes())
.into_owned()
.try_fold(HashMap::new(), |mut map, (k, v)| map.insert(k, v).is_none().then_some(map))
}
// RFC 6749 2.3.1節: client_id と client_secret は、それぞれ付録Bの規則で符号化してから連結する
fn form_component_encode(s: &str) -> String {
form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
fn form_component_decode(s: &str) -> Option<String> {
let plus_as_space = s.replace('+', " ");
percent_encoding::percent_decode_str(&plus_as_space)
.decode_utf8()
.ok()
.map(|c| c.into_owned())
}
fn bad_request(body: &str) -> Response {
Response { status: "400 Bad Request", headers: vec![], body: body.to_string() }
}
// やり取りを表示する。経路がフロントチャネルかバックチャネルかも示す
fn show_request(no: u32, route: &str, req: &Request) {
println!("==> [{no}] {route}");
println!("{}\n", request_text(req));
}
fn show_response(no: u32, res: &Response) {
println!("<== [{no}]");
println!("{}\n", response_text(res));
}
// ---------------- 認可サーバ (server.example.com) ----------------
struct Grant {
client_id: String,
redirect_uri: String,
user: String,
scope: String,
issued_at: Instant,
}
struct AuthServer {
// client_id → (クライアントシークレット, 登録済みリダイレクトURI)
clients: HashMap<String, (String, String)>,
codes: HashMap<String, Grant>,
}
impl AuthServer {
fn handle(&mut self, req: &Request) -> Response {
match (req.method, req.url.path()) {
("GET", "/authorize") => self.authorize_get(req),
("POST", "/authorize") => self.authorize_post(req),
("POST", "/token") => self.token(req),
_ => Response { status: "404 Not Found", headers: vec![], body: String::new() },
}
}
// 認可リクエストを検証し、ログインと同意の画面を返す
fn authorize_get(&self, req: &Request) -> Response {
// 重複したパラメータは信頼できないので、リダイレクトせずに利用者へ表示する
let Some(q) = form_decode(req.url.query().unwrap_or_default()) else {
return bad_request("invalid_request: パラメータが重複しています");
};
let client_id = q.get("client_id").cloned().unwrap_or_default();
let redirect_uri = q.get("redirect_uri").cloned().unwrap_or_default();
match self.clients.get(&client_id) {
Some((_, registered)) if *registered == redirect_uri => {}
_ => {
// client_id や redirect_uri が不正なら、リダイレクトせずに利用者へ表示する
return bad_request("不正なクライアントです");
}
}
let hidden = form_encode(&[
("response_type", &q["response_type"]),
("client_id", &client_id),
("redirect_uri", &redirect_uri),
("scope", &q["scope"]),
("state", &q["state"]),
]);
Response {
status: "200 OK",
headers: vec![h("Content-Type", "text/html; charset=utf-8")],
body: format!(
"<p>{client_id} が「{}」へのアクセスを求めています</p>\n\
<form method=\"post\" action=\"/authorize\"> ID・パスワード欄、許可ボタン、\
隠しフィールド: {hidden} </form>",
q["scope"]
),
}
}
// ログインと同意の結果を受け取り、認可コードを付けてクライアントへリダイレクトする
fn authorize_post(&mut self, req: &Request) -> Response {
let Some(f) = form_decode(&req.body) else {
return bad_request("invalid_request: パラメータが重複しています");
};
let user_ok = f["username"] == "alice" && f["password"] == "dummy-password";
let client_ok = self
.clients
.get(&f["client_id"])
.is_some_and(|(_, uri)| *uri == f["redirect_uri"]);
if !user_ok || !client_ok {
return bad_request("ログインに失敗しました");
}
let code = random_value();
self.codes.insert(
code.clone(),
Grant {
client_id: f["client_id"].clone(),
redirect_uri: f["redirect_uri"].clone(),
user: f["username"].clone(),
scope: f["scope"].clone(),
issued_at: Instant::now(),
},
);
let mut location = Url::parse(&f["redirect_uri"]).unwrap();
location
.query_pairs_mut()
.append_pair("code", &code)
.append_pair("state", &f["state"]);
Response {
status: "302 Found",
headers: vec![h("Location", location.as_str())],
body: String::new(),
}
}
// トークンエンドポイント: クライアント認証、コードの検証、トークンの発行
fn token(&mut self, req: &Request) -> Response {
let error = |status, code: &str| Response {
status,
headers: vec![
h("Content-Type", "application/json"),
h("Cache-Control", "no-store"),
h("Pragma", "no-cache"),
],
body: format!("{{\"error\":\"{code}\"}}"),
};
// スキーム名は大文字小文字を区別せずに照合する (RFC 9110 11.1節)
let authenticated = header(&req.headers, "Authorization")
.and_then(|v| v.split_once(' '))
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Basic"))
.and_then(|(_, b64)| STANDARD.decode(b64.trim()).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| {
let (id, secret) = s.split_once(':')?;
let (id, secret) = (form_component_decode(id)?, form_component_decode(secret)?);
let (registered_secret, _) = self.clients.get(&id)?;
(*registered_secret == secret).then_some(id)
});
let Some(client_id) = authenticated else {
let mut res = error("401 Unauthorized", "invalid_client");
res.headers.push(h("WWW-Authenticate", "Basic realm=\"token\""));
return res;
};
let Some(f) = form_decode(&req.body) else {
return error("400 Bad Request", "invalid_request");
};
// 必須パラメータの欠落は invalid_request、未対応の値は unsupported_grant_type (5.2節)
match f.get("grant_type").map(String::as_str) {
None | Some("") => return error("400 Bad Request", "invalid_request"),
Some("authorization_code") => {}
Some(_) => return error("400 Bad Request", "unsupported_grant_type"),
}
// remove で取り出すので、同じコードは2度使えない
let Some(grant) = f.get("code").and_then(|c| self.codes.remove(c)) else {
return error("400 Bad Request", "invalid_grant");
};
let expired = grant.issued_at.elapsed() > Duration::from_secs(600);
if expired || grant.client_id != client_id || Some(&grant.redirect_uri) != f.get("redirect_uri") {
return error("400 Bad Request", "invalid_grant");
}
let body = serde_json::json!({
"access_token": random_value(),
"token_type": "Bearer",
"expires_in": 3600,
"scope": grant.scope,
});
println!("(認可サーバ内部: {} が {} に許可した範囲 {} でトークンを発行)\n",
grant.user, client_id, grant.scope);
Response {
status: "200 OK",
headers: vec![
h("Content-Type", "application/json"),
h("Cache-Control", "no-store"),
h("Pragma", "no-cache"),
],
body: serde_json::to_string_pretty(&body).unwrap(),
}
}
}
// ---------------- クライアント (client.example.com) ----------------
struct Client {
client_id: String,
client_secret: String,
redirect_uri: String,
// クライアントのセッションID → 認可リクエストに付けた state
pending: HashMap<String, String>,
// クライアントのセッションID → 取得したアクセストークン
tokens: HashMap<String, String>,
}
impl Client {
fn handle(&mut self, req: &Request, server: &mut AuthServer, log_no: &mut u32) -> Response {
match req.url.path() {
"/login" => self.start(),
"/cb" => self.callback(req, server, log_no),
_ => Response { status: "404 Not Found", headers: vec![], body: String::new() },
}
}
// ログイン開始: state を作ってセッションに保存し、認可エンドポイントへリダイレクトする
fn start(&mut self) -> Response {
let sid = random_value();
let state = random_value();
self.pending.insert(sid.clone(), state.clone());
let mut url = Url::parse("https://server.example.com/authorize").unwrap();
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", &self.redirect_uri)
.append_pair("scope", "photos:read")
.append_pair("state", &state);
Response {
status: "302 Found",
headers: vec![
h("Location", url.as_str()),
h("Set-Cookie", format!("client_sid={sid}; Path=/; Secure; HttpOnly; SameSite=Lax")),
],
body: String::new(),
}
}
// リダイレクトエンドポイント: state を照合し、バックチャネルでコードをトークンに交換する
fn callback(&mut self, req: &Request, server: &mut AuthServer, log_no: &mut u32) -> Response {
let Some(q) = form_decode(req.url.query().unwrap_or_default()) else {
return bad_request("パラメータが重複しています");
};
let sid = header(&req.headers, "Cookie")
.and_then(|c| c.strip_prefix("client_sid="))
.unwrap_or_default()
.to_string();
let expected = self.pending.remove(&sid);
if expected.is_none() || expected.as_ref() != q.get("state") {
return bad_request("state 不一致");
}
let credentials = STANDARD.encode(format!(
"{}:{}",
form_component_encode(&self.client_id),
form_component_encode(&self.client_secret)
));
let token_req = Request {
method: "POST",
url: Url::parse("https://server.example.com/token").unwrap(),
headers: vec![
h("Authorization", format!("Basic {credentials}")),
h("Content-Type", "application/x-www-form-urlencoded"),
],
body: form_encode(&[
("grant_type", "authorization_code"),
("code", &q["code"]),
("redirect_uri", &self.redirect_uri),
]),
};
*log_no += 1;
let no = *log_no;
show_request(no, "クライアント → 認可サーバ (バックチャネル)", &token_req);
let token_res = server.handle(&token_req);
show_response(no, &token_res);
let json: serde_json::Value = serde_json::from_str(&token_res.body).unwrap();
self.tokens.insert(sid, json["access_token"].as_str().unwrap().to_string());
Response {
status: "302 Found",
headers: vec![h("Location", "https://client.example.com/photos")],
body: String::new(),
}
}
}
// ---------------- ブラウザ ----------------
fn main() {
let mut server = AuthServer { clients: HashMap::new(), codes: HashMap::new() };
server.clients.insert(
"s6BhdRkqt3".to_string(),
("gX1fBat3bV".to_string(), "https://client.example.com/cb".to_string()),
);
let mut client = Client {
client_id: "s6BhdRkqt3".to_string(),
client_secret: "gX1fBat3bV".to_string(),
redirect_uri: "https://client.example.com/cb".to_string(),
pending: HashMap::new(),
tokens: HashMap::new(),
};
let mut log_no = 0;
let mut history: Vec<String> = vec![];
let mut client_cookie = String::new();
// ブラウザの1回の遷移: Host の値で宛先を決めて送り、Set-Cookie を保存する
let mut browse = |req: Request, client: &mut Client, server: &mut AuthServer, log_no: &mut u32| {
history.push(req.url.to_string());
let host = req.url.host_str().unwrap().to_string();
*log_no += 1;
let no = *log_no;
show_request(no, &format!("ブラウザ → {host} (フロントチャネル)"), &req);
let res = if host == "client.example.com" {
let res = client.handle(&req, server, log_no);
if let Some(c) = header(&res.headers, "Set-Cookie") {
client_cookie = c.split(';').next().unwrap().to_string();
}
res
} else {
server.handle(&req)
};
show_response(no, &res);
(res, client_cookie.clone())
};
// 1. 利用者がクライアントの「ログイン」を押す
let (res, cookie) = browse(
Request { method: "GET", url: Url::parse("https://client.example.com/login").unwrap(), headers: vec![], body: String::new() },
&mut client, &mut server, &mut log_no,
);
// 2. ブラウザが Location に従い、認可エンドポイントを開く
let authz_url = Url::parse(header(&res.headers, "Location").unwrap()).unwrap();
let q: HashMap<String, String> = authz_url.query_pairs().into_owned().collect();
browse(
Request { method: "GET", url: authz_url, headers: vec![], body: String::new() },
&mut client, &mut server, &mut log_no,
);
// 3. 利用者がログインして許可する (フォームの送信)
let (res, _) = browse(
Request {
method: "POST",
url: Url::parse("https://server.example.com/authorize").unwrap(),
headers: vec![h("Content-Type", "application/x-www-form-urlencoded")],
body: form_encode(&[
("username", "alice"),
("password", "dummy-password"),
("response_type", &q["response_type"]),
("client_id", &q["client_id"]),
("redirect_uri", &q["redirect_uri"]),
("scope", &q["scope"]),
("state", &q["state"]),
]),
},
&mut client, &mut server, &mut log_no,
);
// 4. ブラウザがリダイレクトエンドポイントへ戻る (Cookie を付ける)
let cb_url = Url::parse(header(&res.headers, "Location").unwrap()).unwrap();
browse(
Request { method: "GET", url: cb_url, headers: vec![h("Cookie", cookie)], body: String::new() },
&mut client, &mut server, &mut log_no,
);
println!("ブラウザが開いた URL:");
for (i, u) in history.iter().enumerate() {
println!(" {}. {u}", i + 1);
}
let token = client.tokens.values().next().unwrap();
let leaked = history.iter().any(|u| u.contains(token.as_str()));
println!("\nアクセストークンがブラウザの URL に現れたか: {leaked}");
println!("クライアントが保持するアクセストークン: {token}");
}
state、認可コード、セッションID、アクセストークンは乱数から作るため、出力は実行ごとに変わります。
[1]は、ブラウザがクライアントの/loginを開いた結果です。クライアントはstateを生成して
自分のセッションに保存し、302 Foundで認可エンドポイントへリダイレクトさせています。
LocationのURLのクエリには、4.1.1節の5つのパラメータが並んでいます。redirect_uriの値は
URLエンコードされ、スコープの:も%3Aになっています。同時にSet-Cookieで
クライアントのセッションIDを渡しています。
[2]は、ブラウザが認可エンドポイントを開いた結果です。認可サーバはclient_idと
redirect_uriを登録値と照合した後、ログインと同意の画面を返しています。
画面には、どのクライアントがどのスコープを求めているかが表示されます。
[3]は、aliceがログインして許可した結果です。フォームの送信先は認可サーバなので、
パスワードは認可サーバにだけ届きます。認可サーバは認可コードを生成し、redirect_uriに
codeとstateを付けてリダイレクトさせています。stateの値は[1]で生成されたものと同じです。
[4]は、ブラウザがリダイレクトエンドポイントを開いたリクエストです。Cookieヘッダで
クライアントのセッションIDが付いています。クライアントはセッションに保存したstateと
クエリのstateを照合し、一致したので処理を続けます。
[5]は、[4]の処理の途中でクライアントが送ったトークンリクエストです。経路はバックチャネルで、
Authorizationヘッダの値czZCaGRSa3F0MzpnWDFmQmF0M2JWは、RFC 6749 4.1.3節の例と一致しています。
認可サーバはクライアント認証、認可コードの存在、発行先のクライアント、redirect_uriの一致を
確かめた後、認可コードをストアから削除して、アクセストークンを返しています。
レスポンスにはCache-Control: no-storeが付いています。この例の出力には現れませんが、
認可サーバはスキーム名Basicを大文字小文字を区別せずに照合し、パラメータの重複や
grant_typeの欠落にはinvalid_requestを、未対応のgrant_typeにはunsupported_grant_typeを返します
(RFC 6749 5.2節)。認可エンドポイントとリダイレクトエンドポイントも、重複したパラメータを含む
リクエストを拒否します。
[4]の応答は、トークンを受け取った後にクライアントがブラウザへ返したリダイレクトです。
行き先は/photosで、トークンは含まれていません。
最後の一覧は、ブラウザが開いたURLのすべてです。認可コードは4番目のURLに現れていますが、
アクセストークンはどのURLにも現れていません(false)。アクセストークンを保持しているのは、
バックチャネルで受け取ったクライアントだけです。
このスニペットの構造(HTTPメッセージを文字列として組み立て、ロールごとの関数が処理する形)は、 後の章でも使います。OpenID Connectで何が追加されるかは6章、 PKCEを加えると何が変わるかは9章で、同じ形の実験で確かめます。
state: CSRF対策の基本
CSRF(cross-site request forgery)は、攻撃者が用意したページやリンクによって、 被害者のブラウザに、被害者が意図していないリクエストを送らせる攻撃です。 1章で見たとおり、ブラウザはリクエストを誰が起こしたかに関係なく 宛先のサーバのCookieを付けるので、サーバは被害者本人の操作と区別できません。
認可コードフローのリダイレクトエンドポイントは、CSRFの対象になります。RFC 6749の10.12節は、 攻撃を次のように説明しています。
A CSRF attack against the client's redirection URI allows an attacker to inject its own authorization code or access token, which can result in the client using an access token associated with the attacker's protected resources rather than the victim's (e.g., save the victim's bank account information to a protected resource controlled by the attacker).
(RFC 6749 10.12節)
この攻撃では、攻撃者はまず自分のアカウントでクライアントの認可コードフローを開始し、
認可サーバで自分のアカウントにログインして許可します。リダイレクトエンドポイントへの
最後の遷移(/cb?code=攻撃者のコード)だけを止めて、そのURLを被害者に開かせます。
クライアントは被害者のセッションで攻撃者のコードを交換し、被害者のセッションに
攻撃者の写真アカウントへのアクセストークンを結びつけます。被害者が「写真共有サービスへ保存」を
実行すると、データは攻撃者のアカウントに保存されます。
stateはこの攻撃を防ぎます。クライアントは認可リクエストを送るときに推測できないstateを生成して
ブラウザのセッションに保存し、リダイレクトエンドポイントでクエリのstateと照合します。
攻撃者が被害者に開かせるURLのstateは、攻撃者のセッションで生成されたもので、
被害者のセッションに保存された値とは一致しません。RFC 6749の10.12節は、
この値は推測できないものでなければならない(MUST)とし、クライアントは
リダイレクトURIへのCSRF対策を実装しなければならない(MUST)と定めています。
RFC 9700の2.1節は、この規則を更新し、CSRF対策の手段としてPKCEやOpenID Connectの
nonceも認めています。3つの値がそれぞれ何を結びつけるかは9章で、
ログインの場面で同じ攻撃がどう現れるか(ログインCSRF)とあわせて詳しく扱います。
実験: エラーと攻撃をリダイレクトエンドポイントで扱う
認可エンドポイントのエラーの扱いと、リダイレクトエンドポイントでのstateの照合、
トークンエンドポイントでのコードの検証を、[1]から[7]と[3']の8つの場合について確かめます。
use std::collections::HashMap;
use url::Url;
const CLIENT_ID: &str = "s6BhdRkqt3";
const REGISTERED_REDIRECT: &str = "https://client.example.com/cb";
// 認可エンドポイントの結果: リダイレクトするか、利用者に直接エラーを表示するか
enum AuthzResult {
Redirect(String),
ShowError(&'static str),
}
struct AuthServer {
codes: HashMap<String, (String, String)>, // code → (client_id, redirect_uri)
next: u32,
}
impl AuthServer {
fn authorize(&mut self, query: &str, user_approves: bool) -> AuthzResult {
let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes()).into_owned().collect();
let count = |k: &str| pairs.iter().filter(|(name, _)| name == k).count();
let q: HashMap<String, String> = pairs.iter().cloned().collect();
let get = |k: &str| q.get(k).map(String::as_str).unwrap_or("");
// 1. client_id と redirect_uri を先に検証する。不正ならリダイレクトしない
if count("client_id") > 1 || count("redirect_uri") > 1 {
return AuthzResult::ShowError("client_id または redirect_uri が重複しています");
}
if get("client_id") != CLIENT_ID {
return AuthzResult::ShowError("client_id が不正です");
}
// 登録値との完全一致 (単純な文字列比較)
if get("redirect_uri") != REGISTERED_REDIRECT {
return AuthzResult::ShowError("redirect_uri が登録値と一致しません");
}
// 2. ここから先のエラーはリダイレクトURIへ error として返す
let redirect = |pairs: &[(&str, &str)]| {
let mut url = Url::parse(REGISTERED_REDIRECT).unwrap();
url.query_pairs_mut().extend_pairs(pairs);
if let Some(state) = q.get("state") {
url.query_pairs_mut().append_pair("state", state);
}
AuthzResult::Redirect(url.to_string())
};
// 重複、欠落、空の値は invalid_request。値が空のパラメータは省略として扱う (3.1節)
if pairs.iter().any(|(k, _)| count(k) > 1) || get("response_type").is_empty() {
return redirect(&[("error", "invalid_request")]);
}
if get("response_type") != "code" {
return redirect(&[("error", "unsupported_response_type")]);
}
if !user_approves {
return redirect(&[("error", "access_denied")]);
}
self.next += 1;
let code = format!("dummy-code-{}", self.next);
self.codes.insert(code.clone(), (CLIENT_ID.to_string(), get("redirect_uri").to_string()));
redirect(&[("code", &code)])
}
fn token(&mut self, client_id: &str, code: &str, redirect_uri: &str) -> Result<&'static str, &'static str> {
// コードは取り出した時点でストアから消える (1回限り)
let (issued_to, uri) = self.codes.remove(code).ok_or("invalid_grant")?;
if issued_to != client_id || uri != redirect_uri {
return Err("invalid_grant");
}
Ok("dummy-access-token")
}
}
// クライアントのリダイレクトエンドポイント: error の確認と state の照合
fn client_callback(location: &str, expected_state: &str) -> Result<String, String> {
let url = Url::parse(location).unwrap();
let q: HashMap<String, String> = url.query_pairs().into_owned().collect();
if q.get("state").map(String::as_str) != Some(expected_state) {
return Err("state が一致しないため破棄".to_string());
}
if let Some(e) = q.get("error") {
return Err(format!("認可サーバがエラーを返した: {e}"));
}
q.get("code").cloned().ok_or_else(|| "code がない".to_string())
}
fn authz_query(response_type: &str, redirect_uri: &str, state: &str) -> String {
form_urlencoded::Serializer::new(String::new())
.append_pair("response_type", response_type)
.append_pair("client_id", CLIENT_ID)
.append_pair("redirect_uri", redirect_uri)
.append_pair("state", state)
.finish()
}
fn show(title: &str, r: &AuthzResult) {
match r {
AuthzResult::Redirect(loc) => println!("{title}\n 302 Found\n Location: {loc}"),
AuthzResult::ShowError(msg) => println!("{title}\n 400 Bad Request (リダイレクトせず利用者に表示): {msg}"),
}
}
fn main() {
let mut server = AuthServer { codes: HashMap::new(), next: 0 };
let r = server.authorize(&authz_query("code", REGISTERED_REDIRECT, "xyz"), true);
show("[1] 正常なリクエスト", &r);
let AuthzResult::Redirect(loc) = &r else { unreachable!() };
let code = client_callback(loc, "xyz").unwrap();
println!(" クライアント: code = {code}");
println!();
let r = server.authorize(&authz_query("code", "https://attacker.example/cb", "xyz"), true);
show("[2] 登録されていない redirect_uri", &r);
println!();
let r = server.authorize(&authz_query("token", REGISTERED_REDIRECT, "xyz"), true);
show("[3] サポートしない response_type", &r);
let AuthzResult::Redirect(loc) = &r else { unreachable!() };
println!(" クライアント: {:?}", client_callback(loc, "xyz"));
let r = server.authorize(&authz_query("", REGISTERED_REDIRECT, "xyz"), true);
show("[3'] response_type が空 (省略と同じ扱い)", &r);
println!();
let r = server.authorize(&authz_query("code", REGISTERED_REDIRECT, "xyz"), false);
show("[4] 利用者が拒否", &r);
let AuthzResult::Redirect(loc) = &r else { unreachable!() };
println!(" クライアント: {:?}", client_callback(loc, "xyz"));
println!();
println!("[5] 攻撃者が自分のセッションで得た code を、被害者のブラウザで開かせる");
let forged = "https://client.example.com/cb?code=dummy-code-attacker&state=abc";
println!(" Location: {forged}");
println!(" クライアント (被害者のセッションの state は xyz): {:?}", client_callback(forged, "xyz"));
println!();
println!("[6] トークンエンドポイントで同じ code を2回使う");
println!(" 1回目: {:?}", server.token(CLIENT_ID, &code, REGISTERED_REDIRECT));
println!(" 2回目: {:?}", server.token(CLIENT_ID, &code, REGISTERED_REDIRECT));
println!();
println!("[7] 認可リクエストと異なる redirect_uri でトークンを要求する");
let r = server.authorize(&authz_query("code", REGISTERED_REDIRECT, "xyz"), true);
let AuthzResult::Redirect(loc) = &r else { unreachable!() };
let code = client_callback(loc, "xyz").unwrap();
println!(" {:?}", server.token(CLIENT_ID, &code, "https://client.example.com/other"));
}
[1]は正常な場合で、リダイレクトURIにcodeとstateが付いています。
[2]は、登録されていないredirect_uri(https://attacker.example/cb)を指定した場合です。
認可サーバはリダイレクトせず、400 Bad Request(リクエストの形式や内容に誤りがあることを表すステータスコード)を返して利用者に表示しています。3.1.2.4節と4.1.2.1節のとおり、
不正なリダイレクトURIへは、エラーであっても送りません。ここでリダイレクトしていれば、
攻撃者は認可サーバを経由して利用者を任意のサイトへ移動させられます。
[3]と[4]は、リダイレクトURIへエラーを返す場合です。サポートしないresponse_typeには
unsupported_response_type、利用者の拒否にはaccess_deniedが返り、どちらにもstateが付いています。
どちらの場合も、クライアントはstateを照合した後でerrorを確認しています。
[3']は、response_typeの値が空の場合です。3.1節により値のないパラメータは省略されたものとして
扱うので、必須のパラメータの欠落としてinvalid_requestが返ります。unsupported_response_typeは、
値はあるが認可サーバがサポートしない場合のエラーです。認可サーバは、パラメータが重複している場合も
invalid_requestを返します。重複したのがclient_idかredirect_uriであれば、返す先が定まらないので
リダイレクトせずに利用者へ表示します。
[5]は、前節のCSRFです。攻撃者のコードと攻撃者のセッションのstate(abc)を含むURLを、
被害者のブラウザで開かせています。被害者のセッションに保存されたstateはxyzなので、
クライアントはコードを交換せずに破棄します。
[6]は、同じ認可コードを2回使った場合で、2回目はinvalid_grantになります。
認可サーバはコードを取り出すときにストアから削除しているので、2回目の検索は失敗します。
[7]は、認可リクエストと異なるredirect_uriでトークンを要求した場合で、これもinvalid_grantになります。
4.1.3節の5項目めの検証です。この検証がないと、攻撃者は別のリダイレクトURIで
受け取った認可コードを正規のものとして交換できる可能性があります。
まとめ
- 認可コードフローは、認可エンドポイントへの認可リクエスト、リダイレクトエンドポイントへの 認可レスポンス、トークンエンドポイントへのトークンリクエストの3段階で進みます
- 認可リクエストと認可レスポンスはブラウザのリダイレクト(フロントチャネル)で送られ、
パラメータは
application/x-www-form-urlencoded形式でクエリに入ります。値は必ず符号化します - リダイレクトURIは事前に登録し、認可サーバは完全一致で比較します(RFC 9700)。 不正なリダイレクトURIへはエラーも返しません
- 認可コードは有効期間が短く1回しか使えず、クライアントとリダイレクトURIに結びついています。 アクセストークンはバックチャネルで受け取るため、ブラウザのURLに現れません
stateは、認可リクエストとリダイレクトエンドポイントへの戻りを同じブラウザのセッションに 結びつけ、攻撃者のコードを注入するCSRFを防ぎます
次章では、トークンエンドポイントが返すレスポンスの中身と、クライアントがアクセストークンを リソースサーバに提示する方法(Bearerトークン)を読み、スコープとリフレッシュトークンの役割を説明します。