OAuth 2.1 + PKCE は、以前一度きちんと理解して資料まで作ったのに、しばらく触らないとすぐ忘れてしまいます。今回あらためて整理し直したので、自分が引っかかったところを中心にまとめておきます。

前半で全体像をざっと押さえ、後半で「ここで分からなくなった」という部分を順番に書いています。

概要

登場人物

名前役割
クライアントトークンを取りに行くアプリ
認可サーバー(Authorization Server)ログイン・同意を受けて、アクセストークンを発行する
リソースサーバー(Resource Server)アクセストークンを受け取って API を提供する
OpenID Provider(OP)認可サーバーに「誰がログインしたか」を証明する機能を足したもの
Relying Party(RP)OP の証明を受け取ってログインに使う側

ざっくり言うと、OAuth は「このアプリに何を許可するか」という認可の仕組みで、OpenID Connect(OIDC)はその上に「この人は誰か」という認証を足したものです。

PKCE の流れ

PKCE は、認可コードを横取りされても、それだけではトークンに交換できないようにするための仕組みです。

  1. クライアントが使い捨ての秘密 code_verifier(ランダム文字列)を作る
  2. そのハッシュ code_challenge = BASE64URL(SHA256(code_verifier)) を作る
  3. 認可リクエストで code_challenge と code_challenge_method=S256 を送る。認可サーバーは、発行する認可コードにこれを紐づけて保存する
  4. リダイレクトで認可コードを受け取る
  5. トークンリクエストで、認可コードと一緒に code_verifier の生の値を送る
  6. 認可サーバーは verifier をハッシュして、保存しておいた challenge と一致するか確認する
リソースサーバー認可サーバーブラウザクライアントリソースサーバー認可サーバーブラウザクライアントcode_verifier を作り、そのハッシュ code_challenge を計算認可コードに challenge を紐づけて保存verifier のハッシュと保存した challenge を照合認可リクエストへ移動(code_challenge, S256)/authorize?code_challenge=...ログイン・同意画面ログインして許可redirect_uri?code=認可コード認可コードを渡す/token(認可コード + code_verifier)アクセストークンAPI を呼ぶ(Bearer アクセストークン)データ

ポイントは「ハッシュを先に渡して、本体を後で見せる」ことです。ブラウザを通るのは challenge だけで、そこから verifier は逆算できません。

OAuth 2.0 ではパブリッククライアント向けの拡張扱いでしたが、OAuth 2.1 では全クライアントで必須になりました。plain 方式は非推奨で、実質 S256 一択です。

躓いたところ 1:PKCE がないと何が起きるのか

PKCE がないと起きうる攻撃には、方向が逆の 2 種類があります。ここを混同しやすいので、分けて整理します。

認可コードの横取り

被害者の認可コードを攻撃者が盗んで、トークンに交換してしまう攻撃です。

モバイルアプリで、悪意あるアプリが正規アプリと同じカスタム URL スキームを登録しておき、リダイレクトを横取りするのが典型例です。パブリッククライアントには client_secret がないので、コードさえあれば誰でもトークンに交換できます。結果として、攻撃者が被害者のデータにアクセスできるトークンを手に入れます。

PKCE があれば、攻撃者は verifier を持っていないので交換に失敗します。

認可サーバー悪意あるアプリ正規アプリ認可サーバー悪意あるアプリ正規アプリalt[PKCE なし][PKCE あり]認可リクエスト(被害者がログインして許可)リダイレクト(認可コード)を横取りされる/token(盗んだ認可コード)被害者のアクセストークンverifier が無いので拒否

認可コードの注入

こちらは、被害者のデータが攻撃者のところへ送られてしまう攻撃です。

  1. 攻撃者が自分のアカウントで認可フローを進め、自分用の認可コードを取る
  2. そのコードを含むコールバック URL を被害者に踏ませる
  3. 被害者のアプリがそのコードをトークンに交換し、被害者のアプリが攻撃者のストレージに紐づく
  4. 被害者は自分の Drive だと思ってファイルを保存し続けるが、実際には攻撃者の Drive に入っていく

トークンを盗むのではなく、被害者のアプリを攻撃者の口座に向ける攻撃です。データを被害者自身が送り込んでしまうので、気づきにくいのが厄介です。

PKCE があれば、攻撃者のコードは攻撃者が作った challenge に紐づいています。被害者のアプリは自分の verifier で交換しようとするので、一致せずに失敗します。

認可サーバー被害者のアプリ攻撃者認可サーバー被害者のアプリ攻撃者以後のファイルは攻撃者の Drive へalt[PKCE なし][PKCE あり]自分のアカウントで認可フロー攻撃者用の認可コード(攻撃者の challenge に紐づく)そのコードを含むコールバック URL を踏ませる/token(攻撃者のコード + 被害者のアプリの verifier)攻撃者のアカウントのトークンchallenge と合わないので拒否

躓いたところ 2:自分のサービスが「認証する側」になれるのか

これまでは「Google などで OAuth ログインさせる側」しか考えたことがありませんでした。では、自分のサービス自体が OAuth でトークンを出す側になれるのか、というのが次の疑問です。

OAuth でトークンを出す側は認可サーバーで、「誰がログインしたか」まで証明するなら OpenID Provider です。

プロトコルとアカウント管理は分けて考える

認可サーバー用のライブラリや製品を使う場合、それらは自分のサービスのアカウント一覧を知りません。担当範囲が分かれています。

ライブラリや製品がやること

  • トークンの発行・リフレッシュ・保存、PKCE の検証
  • クライアント登録と redirect_uri のチェック
  • メタデータ(/.well-known/oauth-authorization-server)の公開

自分のサービスがやること

  • /authorize に来たときのログイン画面と同意画面
  • 「ログイン済みのこの人が許可した」という結果を渡す処理

アカウントの管理とログイン確認は、今までどおり自分のサービスがやります。認可サーバーの仕組みは、その結果を受け取ってトークンという形にするだけです。

自分のサービス(ログイン・同意画面)認可サーバーのライブラリクライアント自分のサービス(ログイン・同意画面)認可サーバーのライブラリクライアントクライアントと redirect_uri を確認自分のアカウント DB でログイン確認PKCE を検証してトークンを発行・保存/authorize(client_id, redirect_uri, code_challenge)ログイン・同意画面を出して「ユーザー X が許可した」認可コード/token(認可コード + code_verifier)アクセストークン

自分の API にアクセスさせるだけなら、認可サーバーとリソースサーバーで足ります。他のサイトに「自分のサービスでログイン」ボタンを置いてもらうなら、次に説明する OpenID Provider の機能が必要になります。

躓いたところ 3:OpenID Provider って結局何をするのか

認可サーバーとリソースサーバーは分かったものの、OP の役割だけがピンときませんでした。最初は「叩くと誰か分かるエンドポイントのこと?」と思っていましたが、それは半分だけ正解でした。

OAuth のアクセストークンでログインしてはいけない理由

アクセストークンは「この API を叩いていい」とリソースサーバーに示すためのものです。クライアントに「この人は誰か」を伝えるためのものではありません。

これをログインに流用すると、次のような攻撃が成立します。

登場人物は、OP(認証サーバー)、A(自分のアプリ)、E(攻撃者が作った別アプリ)の 3 つです。

  1. 被害者が、何も知らずに E に「OP でログイン」する
  2. E は、被害者のアクセストークン(E 宛てに発行されたもの)を手に入れる
  3. E はそのトークンを A のサーバーに持ち込み、「これでログインさせて」と頼む
  4. A がそのトークンで /userinfo を叩くと、「被害者です」と返ってくる
  5. A は E を被害者としてログインさせてしまう

/userinfo は「このトークンの持ち主は誰か」しか答えません。そのトークンがどのアプリ宛てに発行されたかは分からないので、こうした持ち込みを見抜けません。

この攻撃が問題になるのは、スマホアプリや SPA が取ったトークンをバックエンドに送ってログインさせる構成です。バックエンドから見ると、そのトークンがどこで取られたものか分からないからです。

OIDC が足す id_token

OP は、アクセストークンとは別に id_token を発行します。中身には「誰が」「どのアプリ宛てに」「いつ」ログインしたかが書かれていて、OP の署名がついています。

A は受け取った id_token の宛先(aud)を見て、自分宛てでなければ拒否します。E が持っている被害者の id_token は E 宛てなので、A では使えません。

自分のアプリ AOP攻撃者のアプリ E被害者自分のアプリ AOP攻撃者のアプリ E被害者署名は正しいがaud が client_E で自分宛てではないalt[アクセストークンでログインを判定する場合][id_token で判定する場合]「OP でログイン」を押す認可フロー(被害者がログインして許可)被害者のアクセストークンと id_token(どちらも E 宛て)このアクセストークンでログインさせて/userinfo(アクセストークン)「被害者です」被害者としてログイン成立この id_token でログインさせて拒否

/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 自身のログイン状態を返します。

  1. id_token を検証する(署名、iss、aud、exp、nonce)
  2. iss と sub の組で自分の DB のユーザーを探す。いなければ新規登録する
  3. A 自身のセッションを発行する(ブラウザならセッション Cookie、アプリなら A が発行したトークン)
OPA のサーバー(RP)ブラウザOPA のサーバー(RP)ブラウザ署名・iss・aud・exp・nonce を検証iss + sub で DB のユーザーを探す(いなければ新規登録)以後は A のセッションだけで動く(id_token の役目はここで終わり)ログイン(認可コード + PKCE)認可コードコールバック(認可コード)/token(認可コード + code_verifier)id_token(+ アクセストークン)JWKS(公開鍵)を取得A 自身のセッション Cookie

ユーザーの特定にメールアドレスではなく sub を使うのは、メールアドレスは変わることがあり、乗っ取りの原因にもなるからです。

id_token はログインの瞬間に「この人は誰か」を確かめた時点で役目を終えます。A のセッションの寿命は A が自分で決めるので、OP のトークンが切れてもアプリのログインは続きます。

まとめ

最終的に、自分の中ではこう落ち着きました。

  • PKCE は「ハッシュを先に渡して、本体を後で見せる」ことで、認可コードの横取りと注入を防ぐ
  • 認可サーバーとリソースサーバーは、ライブラリを使えば思ったより手軽に作れる。アカウント管理は自分のサービスの担当
  • OP は「誰が、どのサービス宛てに、いつログインしたか」を署名つきで渡すところまでが仕事
  • その中身が本物で、自分宛てかを確かめるのは、受け取った各サービスの責任

実際の事故も、OP 側より RP 側の検証漏れ(aud を見ていない、署名を検証せずにデコードして読んでいるだけ、など)が多いそうです。自前で検証を書くより、OIDC 対応のライブラリに任せて、iss と aud の期待値だけを正しく渡す形にしておくのが安全です。

また忘れたら、この記事を読み返すことにします。