OAuth 2.1 + PKCE は、以前一度きちんと理解して資料まで作ったのに、しばらく触らないとすぐ忘れてしまいます。今回あらためて整理し直したので、自分が引っかかったところを中心にまとめておきます。
前半で全体像をざっと押さえ、後半で「ここで分からなくなった」という部分を順番に書いています。
概要
登場人物
| 名前 | 役割 |
|---|---|
| クライアント | トークンを取りに行くアプリ |
| 認可サーバー(Authorization Server) | ログイン・同意を受けて、アクセストークンを発行する |
| リソースサーバー(Resource Server) | アクセストークンを受け取って API を提供する |
| OpenID Provider(OP) | 認可サーバーに「誰がログインしたか」を証明する機能を足したもの |
| Relying Party(RP) | OP の証明を受け取ってログインに使う側 |
ざっくり言うと、OAuth は「このアプリに何を許可するか」という認可の仕組みで、OpenID Connect(OIDC)はその上に「この人は誰か」という認証を足したものです。
PKCE の流れ
PKCE は、認可コードを横取りされても、それだけではトークンに交換できないようにするための仕組みです。
- クライアントが使い捨ての秘密
code_verifier(ランダム文字列)を作る - そのハッシュ
code_challenge = BASE64URL(SHA256(code_verifier))を作る - 認可リクエストで
code_challengeとcode_challenge_method=S256を送る。認可サーバーは、発行する認可コードにこれを紐づけて保存する - リダイレクトで認可コードを受け取る
- トークンリクエストで、認可コードと一緒に
code_verifierの生の値を送る - 認可サーバーは verifier をハッシュして、保存しておいた challenge と一致するか確認する
ポイントは「ハッシュを先に渡して、本体を後で見せる」ことです。ブラウザを通るのは challenge だけで、そこから verifier は逆算できません。
OAuth 2.0 ではパブリッククライアント向けの拡張扱いでしたが、OAuth 2.1 では全クライアントで必須になりました。plain 方式は非推奨で、実質 S256 一択です。
躓いたところ 1:PKCE がないと何が起きるのか
PKCE がないと起きうる攻撃には、方向が逆の 2 種類があります。ここを混同しやすいので、分けて整理します。
認可コードの横取り
被害者の認可コードを攻撃者が盗んで、トークンに交換してしまう攻撃です。
モバイルアプリで、悪意あるアプリが正規アプリと同じカスタム URL スキームを登録しておき、リダイレクトを横取りするのが典型例です。パブリッククライアントには client_secret がないので、コードさえあれば誰でもトークンに交換できます。結果として、攻撃者が被害者のデータにアクセスできるトークンを手に入れます。
PKCE があれば、攻撃者は verifier を持っていないので交換に失敗します。
認可コードの注入
こちらは、被害者のデータが攻撃者のところへ送られてしまう攻撃です。
- 攻撃者が自分のアカウントで認可フローを進め、自分用の認可コードを取る
- そのコードを含むコールバック URL を被害者に踏ませる
- 被害者のアプリがそのコードをトークンに交換し、被害者のアプリが攻撃者のストレージに紐づく
- 被害者は自分の Drive だと思ってファイルを保存し続けるが、実際には攻撃者の Drive に入っていく
トークンを盗むのではなく、被害者のアプリを攻撃者の口座に向ける攻撃です。データを被害者自身が送り込んでしまうので、気づきにくいのが厄介です。
PKCE があれば、攻撃者のコードは攻撃者が作った challenge に紐づいています。被害者のアプリは自分の verifier で交換しようとするので、一致せずに失敗します。
躓いたところ 2:自分のサービスが「認証する側」になれるのか
これまでは「Google などで OAuth ログインさせる側」しか考えたことがありませんでした。では、自分のサービス自体が OAuth でトークンを出す側になれるのか、というのが次の疑問です。
OAuth でトークンを出す側は認可サーバーで、「誰がログインしたか」まで証明するなら OpenID Provider です。
プロトコルとアカウント管理は分けて考える
認可サーバー用のライブラリや製品を使う場合、それらは自分のサービスのアカウント一覧を知りません。担当範囲が分かれています。
ライブラリや製品がやること
- トークンの発行・リフレッシュ・保存、PKCE の検証
- クライアント登録と redirect_uri のチェック
- メタデータ(
/.well-known/oauth-authorization-server)の公開
自分のサービスがやること
/authorizeに来たときのログイン画面と同意画面- 「ログイン済みのこの人が許可した」という結果を渡す処理
アカウントの管理とログイン確認は、今までどおり自分のサービスがやります。認可サーバーの仕組みは、その結果を受け取ってトークンという形にするだけです。
自分の API にアクセスさせるだけなら、認可サーバーとリソースサーバーで足ります。他のサイトに「自分のサービスでログイン」ボタンを置いてもらうなら、次に説明する OpenID Provider の機能が必要になります。
躓いたところ 3:OpenID Provider って結局何をするのか
認可サーバーとリソースサーバーは分かったものの、OP の役割だけがピンときませんでした。最初は「叩くと誰か分かるエンドポイントのこと?」と思っていましたが、それは半分だけ正解でした。
OAuth のアクセストークンでログインしてはいけない理由
アクセストークンは「この API を叩いていい」とリソースサーバーに示すためのものです。クライアントに「この人は誰か」を伝えるためのものではありません。
これをログインに流用すると、次のような攻撃が成立します。
登場人物は、OP(認証サーバー)、A(自分のアプリ)、E(攻撃者が作った別アプリ)の 3 つです。
- 被害者が、何も知らずに E に「OP でログイン」する
- E は、被害者のアクセストークン(E 宛てに発行されたもの)を手に入れる
- E はそのトークンを A のサーバーに持ち込み、「これでログインさせて」と頼む
- A がそのトークンで
/userinfoを叩くと、「被害者です」と返ってくる - A は E を被害者としてログインさせてしまう
/userinfo は「このトークンの持ち主は誰か」しか答えません。そのトークンがどのアプリ宛てに発行されたかは分からないので、こうした持ち込みを見抜けません。
この攻撃が問題になるのは、スマホアプリや SPA が取ったトークンをバックエンドに送ってログインさせる構成です。バックエンドから見ると、そのトークンがどこで取られたものか分からないからです。
OIDC が足す id_token
OP は、アクセストークンとは別に id_token を発行します。中身には「誰が」「どのアプリ宛てに」「いつ」ログインしたかが書かれていて、OP の署名がついています。
A は受け取った id_token の宛先(aud)を見て、自分宛てでなければ拒否します。E が持っている被害者の id_token は E 宛てなので、A では使えません。
/userinfo はメールアドレスや表示名などの追加情報が欲しいときに叩く補助で、ログインの判定そのものは id_token で行う、という分担です。
躓いたところ 4:JWT の形式と「E 用の署名」
id_token は JWT 形式で返ってきます。ドットで区切った 3 つの部分を、それぞれ BASE64URL エンコードしたものです。
ヘッダー . ペイロード . 署名
デコードすると、たとえばこうなります。
// ヘッダー
{ "alg": "RS256", "kid": "k1" }
// ペイロード
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "client_A",
"exp": 1761700000,
"iat": 1761696400,
"nonce": "n-0S6_WzA2Mj"
}
| 項目 | 意味 |
|---|---|
iss | 誰が発行したか(OP) |
sub | ユーザーの一意な ID |
aud | どのクライアント宛てか(client_id) |
exp / iat | 有効期限と発行時刻 |
nonce | そのログイン要求に紐づくワンタイム値 |
署名の仕組みで押さえておきたいのは次の点です。
- OP の秘密鍵は基本的に 1 本(ローテーション用に複数あれば
kidで区別)で、クライアントごとに鍵が分かれているわけではない - 署名は 3 つ目の部分で、「ヘッダー.ペイロード」全体に対して作られる
audもペイロードの一部なので、宛先も署名の対象に含まれている
E が持っている被害者の id_token は、aud: client_E という中身に OP が正しく署名したものです。A で検証すると、署名チェック自体は通ります。そのうえで A は aud を見て、自分宛てではないので拒否します。
E が aud を client_A に書き換えると、ペイロードが変わるので署名と合わなくなります。正しい署名を作り直すには OP の秘密鍵が必要なので、E にはできません。
つまり、署名で「OP が発行した中身から変わっていない」ことを確認し、そのあと aud で「自分宛てか」を確認する、という 2 段構えです。
躓いたところ 5:検証するのは誰で、検証後に何を返すのか
署名を検証するのは、id_token を受け取ってログインに使う A(RP)です。それも、A のサーバー側で行うのが基本です。ブラウザやアプリ側での検証結果は改ざんできるので、ログインの判定には使えません。
公開鍵は OP が JWKS として公開していて、誰でも取れます。署名は秘密を隠すためのものではなく、「中身が変わっていない」ことを誰でも確かめられるようにするためのものです。
では、検証が終わったあと、A はクライアントに何を返すのか。アクセストークンでも id_token でもなく、A 自身のログイン状態を返します。
id_tokenを検証する(署名、iss、aud、exp、nonce)issとsubの組で自分の DB のユーザーを探す。いなければ新規登録する- A 自身のセッションを発行する(ブラウザならセッション Cookie、アプリなら A が発行したトークン)
ユーザーの特定にメールアドレスではなく sub を使うのは、メールアドレスは変わることがあり、乗っ取りの原因にもなるからです。
id_token はログインの瞬間に「この人は誰か」を確かめた時点で役目を終えます。A のセッションの寿命は A が自分で決めるので、OP のトークンが切れてもアプリのログインは続きます。
まとめ
最終的に、自分の中ではこう落ち着きました。
- PKCE は「ハッシュを先に渡して、本体を後で見せる」ことで、認可コードの横取りと注入を防ぐ
- 認可サーバーとリソースサーバーは、ライブラリを使えば思ったより手軽に作れる。アカウント管理は自分のサービスの担当
- OP は「誰が、どのサービス宛てに、いつログインしたか」を署名つきで渡すところまでが仕事
- その中身が本物で、自分宛てかを確かめるのは、受け取った各サービスの責任
実際の事故も、OP 側より RP 側の検証漏れ(aud を見ていない、署名を検証せずにデコードして読んでいるだけ、など)が多いそうです。自前で検証を書くより、OIDC 対応のライブラリに任せて、iss と aud の期待値だけを正しく渡す形にしておくのが安全です。
また忘れたら、この記事を読み返すことにします。