OAuth 2.0 の記事では、全体像をつかむために、認可コードの流れを大まかな図にしました。ただ、実際にアプリ登録をしたり、サインインがうまくいかないときにブラウザの通信を見たりすると、URL に state や code_challenge のような見慣れないパラメータがたくさん並んでいます。この記事では、認可コードフローを一歩ずつ分けて、それぞれのパラメータが何のためにあるのかを、RFC と Microsoft(Entra ID)のリクエスト例で確かめながら図にしました。
- 認可コードフローの5つの手順と、ブラウザ経由・アプリから直接の分かれ目
- 認可リクエストのパラメータ(client_id・redirect_uri・scope・state・code_challenge)の役目
- 認可コードは「期限が短く、一度しか使えない引換券」であること
- PKCE:コードを頼んだアプリと、引き換えに来たアプリが同じかを確かめるしくみ
- Web アプリ・SPA・スマホアプリでの違い
まずはたとえで:クロークの引換券
認可コードフローは、クロークの引換券に似ています。受付カウンターで「預けます」と言うと、品物の代わりに番号の書いた引換券が渡されます。品物は、受け取り窓口で引換券を見せたときに初めて受け取れます。
認可コードフローでは、受付カウンターが認可エンドポイント、引換券が認可コード、受け取り窓口がトークンエンドポイント、品物がアクセストークンです。いきなりトークンを渡さずに、いったん引換券を挟むのがこの方式の特徴です。
引換券を挟む理由は、受け渡しの道が2つに分かれているからです。認可コードは、ブラウザを通ってアプリに届きます。ブラウザを通る道は、アプリの外を通る道で、アプリからは守りきれません。そこで、ブラウザには期限が短く一度しか使えない引換券だけを通し、大事なトークンは、アプリがトークンエンドポイントから直接受け取ります。
全体の流れ:5つの手順
RFC 6749 は、認可コードフローを (A)〜(E) の5つの手順で説明しています。それぞれの矢印に、実際に送られるパラメータを書き込むと次のようになります。
| 手順 | 何をするか(RFC 6749) | 通り道 |
|---|---|---|
| (A) | アプリが、利用者のブラウザを認可エンドポイントへ案内する | ブラウザ経由 |
| (B) | 認可サーバが、ブラウザを通して利用者を確かめ(サインイン)、許可するかを聞く(同意) | ブラウザ経由 |
| (C) | 利用者が許可すると、認可サーバはブラウザをアプリへ戻す。そのとき認可コードを付ける | ブラウザ経由 |
| (D) | アプリが、認可コードを付けてトークンエンドポイントにトークンを頼む | アプリから直接 |
| (E) | 認可サーバが、アプリと認可コードを確かめ、リダイレクト URI が (C) と同じかも確かめて、アクセストークンを返す | アプリから直接 |
ここからは、Microsoft のドキュメントにある Entra ID の例で、手順ごとに中身を見ていきます。この例では、アプリが利用者のメールを読む許可(mail.read)を求めています。
手順ごとに見る
認可リクエストを送る(A)
アプリは、次のような URL を組み立てて、利用者のブラウザをそこへ移動させます(リダイレクト)。見やすさのために改行してありますが、実際は1本の URL です。
認可リクエストの例(Microsoft のドキュメントより)
// Line breaks for legibility only
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?
client_id=00001111-aaaa-2222-bbbb-3333cccc4444
&response_type=code
&redirect_uri=http%3A%2F%2Flocalhost%2Fmyapp%2F
&response_mode=query
&scope=https%3A%2F%2Fgraph.microsoft.com%2Fmail.read
&state=12345
&code_challenge=YTFjNjI1OWYzMzA3MTI4ZDY2Njg5M2RkNmVjNDE5YmEyZGRhOGYyM2IzNjdmZWFhMTQ1ODg3NDcxY2Nl
&code_challenge_method=S256
| パラメータ | 意味 |
|---|---|
| tenant | URL の中の {tenant}。誰がサインインできるかを決める。common、organizations、consumers、テナント ID など |
| client_id | アプリ登録で割り当てられたアプリの ID |
| response_type=code | 認可コードを返してほしい、という意味。認可コードフローでは code を含める |
| redirect_uri | 認可コードを返してほしい先。アプリ登録で登録したリダイレクト URI のどれかと完全に一致している必要がある(URL エンコードして送る) |
| scope | 利用者に同意してほしい範囲。スペースで区切って並べる |
| response_mode | 結果の返し方。query なら、リダイレクト URI の後ろに付けて返す |
| state | アプリが作った値。結果と一緒に同じ値が返ってくるので、アプリはそれを照合する |
| code_challenge | PKCE の値。アプリが手元に持つ code_verifier から計算したもの |
| code_challenge_method | 計算の方法。S256(SHA-256)を使う |
リダイレクト URI を完全一致で確かめるのは、認可コードを決まった場所にしか返さないためです。OAuth 2.0 のセキュリティのベストプラクティス(RFC 9700)も、認可サーバはリダイレクト URI を文字列の完全一致で比べるよう求めています。
サインインと同意(B)
ブラウザが認可エンドポイントに着くと、ここから先は認可サーバの画面です。アプリはこの間、何もしません。利用者のパスワードにも触れません。
- サインイン:まだサインインしていなければ、サインイン画面を出します。多要素認証などもここで行われます。
- 同意:利用者が
scopeの範囲にまだ同意していなければ、同意画面を出します。 - 管理者しか同意できない許可:組織のディレクトリに書き込む許可(
Directory.ReadWrite.Allなど)は、利用者が同意できません。利用者には、同意できない旨のエラーが表示されます。
認可コードと state が戻ってくる(C)
利用者がサインインして同意すると、認可サーバはブラウザを redirect_uri へ戻します。response_mode=query なら、URL の後ろに認可コードと state が付いてきます。
認可コードが返ってきたときの例(Microsoft のドキュメントより)
GET http://localhost? code=AwABAAAAvPM1KaPlrEqdFSBzjqfTGBCmLdgfSTLEMPGYuNHSUYBrq... &state=12345
| パラメータ | 意味 |
|---|---|
| code | 認可コード(引換券)。Microsoft の場合、期限はふつう1分ほど。RFC 6749 も、すぐに期限切れにすること(最長10分を推奨)と、一度しか使えないことを決めている |
| state | (A) で送った値と同じものが返る。アプリは同じかを確かめる |
state は、アプリが「自分が始めたサインインの戻りか」を確かめるための値です。RFC 6749 は、state をクロスサイトリクエストフォージェリ(CSRF)を防ぐために使うよう勧めています。Microsoft も、ランダムでほかと重ならない値を使うのが一般的だと書いています。
Microsoft は、state に URL などの機密になりうるデータをそのまま入れないよう注意しています。戻ったあとに使いたい情報があるときは、アプリの側に保存しておき、state にはその保存場所を指すキーだけを入れます。
コードをトークンに引き換える(D)
認可コードを受け取ったアプリは、トークンエンドポイントへ直接 POST します。ここはブラウザを通らず、アプリ(Web アプリならサーバ)から送ります。
トークンの要求の例(Microsoft のドキュメントより。シークレットを使う Web アプリの場合)
// Line breaks for legibility only
POST /{tenant}/oauth2/v2.0/token HTTP/1.1
Host: https://login.microsoftonline.com
Content-Type: application/x-www-form-urlencoded
client_id=11112222-bbbb-3333-cccc-4444dddd5555
&scope=https%3A%2F%2Fgraph.microsoft.com%2Fmail.read
&code=OAAABAAAAiL9Kn2Z27UubvWFPbm0gLWQJVzCTE9UkP3pSx1aXxUjq3n8b2JRLk4OxVXr...
&redirect_uri=http%3A%2F%2Flocalhost%2Fmyapp%2F
&grant_type=authorization_code
&code_verifier=ThisIsntRandomButItNeedsToBe43CharactersLong
&client_secret=sampleCredentia1s // NOTE: Only required for web apps. This secret needs to be URL-Encoded.
| パラメータ | 意味 |
|---|---|
| grant_type | authorization_code。認可コードで引き換える、という意味 |
| code | (C) で受け取った認可コード |
| redirect_uri | (A) で送ったものと同じ値。RFC 6749 は、(A) で送ったなら (D) でも送り、値が同じであることを求めている |
| code_verifier | PKCE の元の値。(A) の code_challenge はこれから計算したもの |
| client_secret | アプリ自身の証明。サーバで秘密を安全に持てる Web アプリだけが使う。SPA やスマホアプリでは使わない |
アプリ自身の証明には、シークレットの代わりに証明書も使えます。その場合は client_secret の代わりに、証明書で署名した値(client_assertion)を送ります。Microsoft は、より安全な証明書を使うことを勧めています。
トークンエンドポイントは、図6の4つをすべて確かめてからトークンを出します。RFC 6749 は、同じ認可コードを2回使おうとしたら断ること、できればそのコードで出したトークンも取り消すことを求めています。
トークンが届く(E)
トークンが返ってきたときの例(Microsoft のドキュメントより)
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q...",
"token_type": "Bearer",
"expires_in": 3599,
"scope": "https%3A%2F%2Fgraph.microsoft.com%2Fmail.read",
"refresh_token": "AwABAAAAvPM1KaPlrEqdFSBzjqfTGAMxZGUTdM0t4B4...",
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJhdWQiOiIyZDRkMTFhMi1mODE0LTQ2YTctOD..."
}
| 項目 | 意味 |
|---|---|
| access_token | API を呼ぶためのアクセストークン |
| token_type | トークンの種類。Entra ID は Bearer だけ |
| expires_in | アクセストークンの有効な秒数(この例では 3599 秒、約1時間) |
| scope | このアクセストークンで許された範囲 |
| refresh_token | アクセストークンを取り直すためのトークン。範囲に offline_access を入れたときだけ届く |
| id_token | 誰がサインインしたかを表す ID トークン。範囲に openid を入れたときだけ届く |
アプリは、アクセストークンを Authorization: Bearer ヘッダに付けて API を呼びます。ここから先は、OAuth 2.0 の記事で見た Bearer トークンの使い方と同じです。
PKCE:コードを頼んだアプリが、引き換えに来たかを確かめる
PKCE(Proof Key for Code Exchange)は、RFC 7636 で決められた、認可コードフローに足す守りです。RFC 7636 は、シークレットを持てない公開クライアント(スマホアプリなど)が、ブラウザやリダイレクトの途中で認可コードを横取りされる攻撃を受けやすいことを、PKCE が必要な理由に挙げています。
認可コードはブラウザやリダイレクトを通って届くので、本来のアプリ以外の手に渡る可能性をゼロにはできません。Web アプリならシークレットでアプリを確かめられますが、シークレットを持てないアプリでは、コードを持ってきた相手が本物かを区別しにくくなります。PKCE を使うと、最初にコードを頼んだアプリしか知らない値を持っていなければ、コードを引き換えられなくなります。
PKCE のしくみ
- ①② アプリは、毎回ランダムな
code_verifierを作って手元に保管し、そこからcode_challengeを計算します。code_verifierは 43〜128 文字の、推測できないランダムな文字列です。 - ③④ 認可リクエストでは、計算した結果の
code_challengeだけを、認可エンドポイントに送ります。認可サーバは認可コードを作るときに、受け取ったcode_challengeと計算の方法(S256)を、その認可コードに結び付けて記録します。 - ⑤ 認可コードがアプリに返ります。
- ⑥⑦ トークンの要求では、認可コードと一緒に、元の値の
code_verifierをトークンエンドポイントに送ります。認可サーバは、届いた認可コードに結び付けておいたcode_challengeを取り出し、code_verifierから計算し直した値と同じかを確かめます。
challenge を送っていないトークンエンドポイントで、なぜ比べられるのか
code_challenge を受け取るのは、認可エンドポイントだけです。それでもトークンエンドポイントで比べられるのは、2つの窓口が同じ認可サーバの窓口だからです。認可サーバは④で、code_challenge を認可コードに結び付けて記録しておきます。⑥で認可コードが届くと、そのコードを手がかりに、結び付けておいた code_challenge を探せます。認可コードが、「どの challenge と比べればよいか」を指す目印になっているわけです。
RFC 7636 は、記録の持ち方として2つを挙げています。よくあるのは、code_challenge を暗号化して、認可コードそのものの中に入れておくやり方です。もう1つは、認可サーバの中に「コードと challenge の対応」を保存しておくやり方です。どちらでも、アプリから見たしくみは変わりません。
先に見せるのは計算結果、後で見せるのは元の値、というのがポイントです。ただし、⑥でも code_verifier を送っているので、「結局どこかで見えてしまうのでは?」と思うかもしれません。ここで大事なのは、2つの値が通る道の違いです。
- ブラウザ・リダイレクト経由の道(③⑤):RFC 7636 が心配しているのは、この道です。認可コードは、ブラウザや、端末の中でのアプリどうしの受け渡しを通ってアプリに届きます。RFC 7636 はこれを「TLS で守られていない通り道」と書いています。同じリダイレクト先を複数のアプリが登録できてしまう端末では、別のアプリにコードが届くおそれもあります。この道を通るのは、計算結果の
code_challengeと、引換券の認可コードだけです。 - アプリから直接の道(⑥):元の値の
code_verifierは、ブラウザを通らず、アプリから認可サーバのトークンエンドポイントへ直接送ります。RFC 6749 は、トークンエンドポイントへのリクエストに TLS を求めています。RFC 7636 も、code_verifierは TLS で送られるので横取りされない、という前提に立っています。
つまり、横取りされうる道には「計算結果」と「引換券」しか流さず、「元の値」は守られた別の道でしか流さないように分けているのです。SHA-256 の計算結果から元の値を求めることはできないので、③⑤の道で code_challenge や認可コードが見えても、code_verifier は分かりません。
さらに、code_verifier は認可リクエストのたびに新しく作り、認可コードは一度しか使えません。⑥で引き換えた時点でそのコードは使い終わるので、組み合わせて使い回すこともできません。
一方で、PKCE で守れない場面もあります。RFC 7636 は、PKCE の安全性は「code_verifier を攻撃者に知られない、推測されないこと」に頼っていると書いています。アプリそのものが乗っ取られた場合や、TLS が破られた場合は、PKCE では守れません。PKCE が守るのは、あくまで③⑤の道です。RFC 7636 が、code_verifier を推測されにくい十分な長さのランダムな値(256 ビット以上のエントロピー)で作るよう勧めているのも、このためです。
S256 の計算は、「code_verifier を SHA-256 にかけ、その結果を Base64URL で文字列にする」です。RFC 7636 の付録にある例の値で、実際に計算してみました。RFC に書かれている code_challenge と同じ値になります。
RFC 7636 の例の code_verifier から code_challenge を計算した結果
$ python3 -c "import hashlib, base64; v='dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode())"
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
RFC 7636 には、計算しないでそのまま送る plain という方法もあります。ただし RFC 7636 は、S256 を使えるクライアントは S256 を使わなければならないとしています。plain だと、code_challenge がそのまま code_verifier になってしまうからです。RFC 7636 は、③の認可リクエストの中身まで見られてしまう場合も考えています。そのときでも、S256 なら見えるのは計算結果だけですが、plain では元の値が見えてしまい、守りになりません。
PKCE はもともと公開クライアントのために作られましたが、今はすべてのアプリで使うのが標準です。RFC 9700 は、公開クライアントには PKCE を必須、機密クライアントにも推奨としています。Microsoft も、すべての種類のアプリに PKCE を勧め、SPA では必須にしています。
アプリの種類による違い
認可コードフローは、Web アプリ・SPA(ブラウザだけで動くアプリ)・デスクトップやスマホのアプリで使えます。違いが出るのは、アプリ自身の証明と、リダイレクト URI です。
SPA(シングルページアプリケーション)は、最初に1回だけページを読み込み、あとはブラウザの中で動く JavaScript が API からデータを取って、画面の一部だけを書き換える Web アプリです。Microsoft のドキュメントでは、主に JavaScript で書かれ、Angular・React・Vue などのフレームワークを使うことが多いと説明されています。プログラムがすべて利用者のブラウザの中で動くので、Microsoft は SPA を、秘密を安全に保存できない公開クライアントとして扱います。
| アプリ | アプリ自身の証明 | そのほかの注意 |
|---|---|---|
| Web アプリ(サーバで動く) | シークレットか証明書をサーバに置く。Microsoft は証明書を推奨。PKCE も推奨 | (D) はサーバから送る |
| SPA(ブラウザで動く) | シークレットも証明書も使わない。ブラウザの中に安全に置けないため | Microsoft では PKCE が必須。リダイレクト URI の種類を spa にして登録する。spa の種類にしないと、ブラウザからトークンエンドポイントを呼ぶときに CORS のエラーになる。リフレッシュトークンの期限は24時間 |
| デスクトップ・スマホ | シークレットも証明書も使わない。端末に安全に置けないため | PKCE を使う。Microsoft は、システムのブラウザを使うアプリのリダイレクト URI に http://localhost を勧めている |
うまくいかないとき
Microsoft のドキュメントに載っているエラーのうち、よく出会いそうなものです。
| エラー | 意味 | 確かめること |
|---|---|---|
| access_denied | (C) で返る。利用者が同意しなかった、またはサインインをやめた | 同意しないと先に進めないことを利用者に伝える |
| invalid_grant | (E) で返る。認可コードか code_verifier が正しくない、または期限切れ | (A) からやり直す。code_verifier が (A) のときのものと同じか |
| invalid_client | (E) で返る。アプリの証明に失敗した | シークレットや証明書が正しいか、期限が切れていないか |
| invalid_scope | 範囲の指定が正しくない | scope の値 |
| consent_required interaction_required | 同意や、追加の操作(多要素認証など)が必要 | /authorize から、画面を出してやり直す |
| CORS のエラー | SPA で、ブラウザからトークンエンドポイントを呼べない | リダイレクト URI の種類が spa になっているか |
企業での例
| 場面 | アプリの種類 | 押さえどころ |
|---|---|---|
| 社内の申請システムに Entra ID でサインインし、本人のメールや予定も読む | Web アプリ(機密クライアント) | 証明書でアプリを証明し、PKCE も付ける。リダイレクト URI は本番の URL だけを登録する |
| ブラウザだけで動く社内ダッシュボードから API を呼ぶ | SPA(公開クライアント) | シークレットは持たない。リダイレクト URI を spa にして、PKCE で守る |
| 現場で使うスマホの業務アプリ | スマホアプリ(公開クライアント) | シークレットは持たない。PKCE を必ず使う |
まとめ
| 項目 | 内容 |
|---|---|
| たとえ | 認可コードは引換券、アクセストークンは品物。ブラウザには引換券だけを通す |
| 5つの手順 | (A) 認可リクエスト → (B) サインインと同意 → (C) コードが戻る → (D) コードを引き換える → (E) トークンが届く。A〜C はブラウザ経由、D・E はアプリから直接 |
| redirect_uri | 登録した値と完全一致。(A) と (D) で同じ値を送る |
| state | アプリが作ったランダムな値。戻ってきたら同じかを照合する |
| 認可コード | 期限が短く(Entra ID はふつう約1分)、一度しか使えない |
| PKCE | 先に code_challenge(計算結果)を認可エンドポイントへ、後で code_verifier(元の値)をトークンエンドポイントへ見せる。認可サーバは challenge を認可コードに結び付けて記録し、引き換えのときに比べる。S256 を使う。今はすべてのアプリで使う |
| アプリの種類 | サーバで動く Web アプリだけがシークレットや証明書を持つ。SPA とスマホアプリは持たない |
参考にした資料
この記事は、次の標準と公式ドキュメントで内容を確かめて書きました。仕様や対応状況は変わることがあるので、最新の情報はリンク先で確かめてください。
- RFC 6749: The OAuth 2.0 Authorization Framework(IETF):認可コードフローの (A)〜(E)、認可コードの期限と一度きりの使用、二重使用のときの扱い、redirect_uri が同じであること、機密クライアントの認証、トークンエンドポイントでの TLS、state
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients(IETF):PKCE が必要な理由、code_verifier の長さと文字、S256 と plain、challenge を認可コードに結び付けて記録することと記録の持ち方、サーバでの確かめ方、横取りが起きる通り道と code_verifier が TLS で送られる前提、plain が守りにならない場合、エントロピーの勧め、付録の例の値
- RFC 9700: Best Current Practice for OAuth 2.0 Security(IETF):PKCE(公開クライアントは必須、機密クライアントは推奨)、リダイレクト URI の完全一致
- Microsoft identity platform and OAuth 2.0 authorization code flow(Microsoft Learn):認可リクエスト・応答・トークンの要求・トークンの応答の例とパラメータ、認可コードの期限、管理者しか同意できない許可、PKCE の扱い、SPA の spa リダイレクト URI と CORS、証明書の推奨、エラーコード
- Application types for the Microsoft identity platform・Microsoft identity platform authentication libraries(Microsoft Learn):SPA の説明(JavaScript とフレームワーク、ブラウザの中だけで動くこと、公開クライアントであること)
あとがき
認可コードフローは、URL に並ぶパラメータの多さで身構えてしまいますが、ひとつずつ見ると「どのアプリか」「どこへ返すか」「何を許すか」「自分の戻りか」「頼んだ本人か」を確かめているだけでした。特に PKCE は、RFC の例の値で実際に計算して一致したのを見て、「先に計算結果、後で元の値」という発想がすっと入ってきました。サインインのトラブルでブラウザの通信を見るときも、この順番を知っていると、どこで止まっているのか見当をつけやすくなりそうです。
コメント