「Google でログイン」を押した瞬間、裏で何がやり取りされているのか?
「Google でログイン」ボタンを設置したことがあるはずだ。client_id と client_secret を環境変数に入れ、コールバック URL を Google Console に登録すれば動く。だが画面がなぜ 2 回もリダイレクトするのか、なぜ一度で済ませずに「コード」をもらってから改めて「トークン」と交換するのか、説明できるだろうか。
- OAuth 2.0 の認可コードフローは、ブラウザの URL に乗って漏れやすい「認可コード」と、サーバー間の TLS 通信でしかやり取りしない「アクセストークン」を分離しており、コードだけ盗まれても交換できない設計になっている
stateパラメータは CSRF 対策、redirect_uriの完全一致検証は横取り対策で、どちらも「認可コードが正しい相手にだけ返ってくる」ことを別々の角度から守っている- スマホアプリや SPA のように
client_secretを安全に持てないクライアントは PKCE を使い、OAuth はそもそも「認可」しか扱わないため「認証」が要る場面は OpenID Connect の ID Token が補う
登場人物とリダイレクトの全体像
OAuth 2.0(RFC 6749)には 4 つの役割がある。ログインしようとしている本人であるリソースオーナー、あなたが作った Web アプリであるクライアント、Google や GitHub 側の認可サーバー、そしてユーザーのデータを実際に持つリソースサーバーだ。認可コードフローでは、クライアントはまずユーザーのブラウザを認可サーバーの /authorize エンドポイントへresponse_type=code・client_id・redirect_uri・scope・state 付きでリダイレクトする。ユーザーが認可サーバー上でログインし同意すると、認可サーバーはあらかじめ登録された redirect_uri へ認可コード(短命で一度しか使えないランダムな文字列)を付けてブラウザを送り返す。
┌────────┐ ①/authorize ┌──────────┐
│ Browser │────────────▶│認可サーバー│
└────────┘ └──────────┘
│②code(URLに乗る) │
▼ │
┌────────┐ │
│ App │③code→token(back)│
│(server) │◀─────────────────┘
└────────┘
│④access token/id_token
▼
┌──────────────┐
│ リソースサーバー │
└──────────────┘
ここまでで手元にあるのはまだ「コード」だけであり、API を叩けるアクセストークンではない。次の一手が、このフローの設計で最も重要な部分になる。
なぜコードとトークンを分けるのか — フロントチャネルとバックチャネル
一見、認可コードをもらった時点でそのままログイン完了にしてしまえば早い。しかし認可コードはブラウザのリダイレクト(フロントチャネル)を経由して渡ってくる。URL はブラウザ履歴・プロキシのアクセスログ・Referer ヘッダーなど、TLS で暗号化された通信路の外側にも残りうる経路だ。ここに長生きするアクセストークンそのものを乗せてしまうと、漏洩したログ 1 行でアカウントを乗っ取られかねない。
そこで OAuth はもう一段階を挟む。クライアントのサーバーが受け取った認可コードを、今度はブラウザを介さずサーバー間の TLS 通信(バックチャネル)で認可サーバーの /token エンドポイントへ直接送り、client_secret(またはこの後説明する PKCE の証明)とセットで初めてアクセストークンと交換する。コードが URL に一瞬乗って見えたとしても、それを再利用するには「秘密」を追加で提示できないと成立しない。これが冒頭の問いへの答えだ — コードとトークンを分けているのは、経路の安全性が異なる 2 つの区間をまたぐからである。
この設計をさらに補強するのが state パラメータと redirect_uri の検証だ。state はクライアントが発行時に生成したランダム値で、コールバック時に同じ値が返ってくることを確認する。これがないと、攻撃者が自分のセッションの認可コードを被害者のブラウザに踏ませて「攻撃者のアカウントでログインした状態」を強制する CSRF が成立する。一方 redirect_uri は、認可サーバーが「事前に登録された値と完全一致するか」を照合する。一致確認がなければ、認可コードを攻撃者の管理するコールバック URL に横流しさせることができてしまう。state と redirect_uri 検証は守っている対象が別物であり、片方だけでは不十分だ。
クロークで手荷物を預けるときを思い浮かべるとよい。荷物を預けるとその場では「引換券」だけを渡される。引換券自体は他人に見られてもさほど困らない――それだけでは荷物を受け取れないからだ。実際に荷物を取り出すときは、引換券に加えて「預けたときの控え(合言葉)」を裏口の係員に示す必要がある。認可コードが引換券、client_secret や PKCE の証明が控えにあたる。引換券だけを盗み見た人がいても、控えを持っていなければ荷物(アクセストークン)は渡されない。
秘密を持てないクライアントのための PKCE
SPA やモバイルアプリはコードがユーザーの端末上で動くため、client_secret をアプリに埋め込んでも解析されればすぐ漏れる。「秘密を安全に保管できないクライアント」向けに RFC 7636 が定義したのが PKCE(Proof Key for Code Exchange)だ。仕組みは client_secret の代わりに、リクエストごとに使い捨てる証明を作る。まず認可リクエストを送る前にランダムな code_verifier(43〜128 文字)をアプリ内だけで生成し、それを SHA-256 でハッシュして base64url エンコードした code_challenge を /authorize に送る。認可サーバーはこの challenge を認可コードと結びつけて記憶する。
# code_verifier を生成(実際の SDK もこれと同じ処理をしている) VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n') # S256 で code_challenge を導出 CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary \ | openssl base64 | tr -d '=\n' | tr '+/' '-_')
トークン交換のリクエストでは、challenge ではなく元の code_verifier を送る。認可サーバーは受け取った verifier を同じ手順でハッシュし、記憶しておいた challenge と一致するかを照合する。認可コードを横取りした攻撃者は challenge しか見ておらず、SHA-256 は一方向関数なので challenge から verifier を逆算できない。code_challenge_method には plain(verifier をそのまま challenge にする)も規格上は存在するが、これは challenge を見れば verifier がそのままわかってしまうため、制約の強い環境向けの後方互換にすぎず、通常は S256 を使う。
OAuth は認可、認証を足すのは OpenID Connect
ここまでの仕組みが守っているのは「このクライアントがこのユーザーの代わりに API を叩いてよいか」という認可(authorization)であり、「このユーザーは本当に本人か」という認証(authentication)ではない。RFC 6749 自体には身元情報を返す標準的な方法が定義されていない。ここに OpenID Connect(OIDC)が OAuth の上に ID Token という JWT を追加し、認証の役割を担わせる。
ID Token には iss(発行者)・sub(ユーザーを一意に指す識別子)・aud(想定するクライアントの client_id)・exp(有効期限)・iat(発行時刻)が必須クレームとして入っており、クライアントアプリはこれを検証して「誰がいつログインしたか」を確認する。一方のアクセストークンは API(リソースサーバー)側が「何をしてよいか」を判断するためのもので、JWT 形式のこともあれば意味を持たない不透明な文字列(opaque token)のこともある。同じレスポンスに両方入っていても、ID Token を読むのはクライアント、アクセストークンを読むのは API という受け手が違う。
Auth0 や Okta、Firebase Auth のような IdP を組み込むとき、コンソールに登録する Authorized redirect URI はここまでの redirect_uri 完全一致検証そのものだ。ステージング環境の URL を一つ登録し忘れて「Google ログインだけ本番で 400 になる」という事象に遭遇したら、まずここを疑うとよい。また GitHub App や Slack App のような外部連携を実装する際、要求する scope は最小限にとどめる方が安全であり、後から広げる方が事故は少ない。SPA でバックエンドを持たない構成なら client_secret を置けないので PKCE 対応の SDK(@auth0/auth0-spa-js 等)を使うのが定石になる。
実際の OAuth クライアント SDK が PKCE で行っている「verifier の生成」と「S256 での challenge 導出」を、openssl だけで再現して確かめる。
# code_verifier を生成(43〜128文字のランダム文字列という規格に沿う) VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n') echo "code_verifier ($(echo -n "$VERIFIER" | wc -c) 文字): $VERIFIER" # SHA-256 でハッシュして base64url エンコード = code_challenge CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary \ | openssl base64 | tr -d '=\n' | tr '+/' '-_') echo "code_challenge (S256): $CHALLENGE"
code_verifier はちょうど 43 文字になり(32 バイトを base64url した長さ)、RFC 7636 が定める最小長と一致する。code_challenge は verifier と見た目がまったく異なる文字列になっているはずだ。実際の認可リクエストではこの challenge だけが URL に乗って外に出て、verifier は最後までクライアントの中に留まる――だからこそ challenge を盗み見ても何の役にも立たない。
- client_secret さえ守っていれば OAuth は安全 — client_secret はトークン交換の認証にしか関与しない。
stateがなければ CSRF、redirect_uriの完全一致検証がなければコードの横流しが別経路で成立してしまう。3 つがそろって初めて安全なフローになる。 - 「OAuth でログインしている」 — OAuth 2.0 自体はユーザーの身元を証明する仕組みを持たない、あくまで認可のプロトコルだ。「ログインした」と判断できるのは OpenID Connect が追加した ID Token を検証した結果であり、両者は別の規格の話をしている。
- JWT 形式のトークンなら中身を見れば信頼してよい — JWT はただの署名付きデータ構造であり、署名を発行者の公開鍵(JWKS)で検証して初めて改ざんされていないと言える。検証を省いてペイロードをそのまま信用するのは、封を確認せず中身だけ読むのと同じだ。
- 認可コード
- 認可サーバーがユーザーの同意後に発行する、短命で一度しか使えないランダムな文字列。これ単体ではアクセストークンと交換できるだけで API は叩けない。
- state パラメータ
- クライアントが認可リクエスト時に生成するランダム値。コールバックで同じ値が返るかを確認し、CSRF を防ぐ。
- PKCE
- client_secret を安全に保持できないクライアント向けに、使い捨ての code_verifier / code_challenge のペアで認可コードの横取りを防ぐ仕組み。
- ID Token
- OpenID Connect が OAuth に追加する JWT。iss・sub・aud・exp・iat などのクレームでユーザーの認証結果を表す。
- アクセストークン
- リソースサーバー(API)が「何を許可されているか」を判断するためのトークン。JWT の場合も opaque な文字列の場合もある。
- RFC 6749, Section 4.1 — Authorization Code Grant — 認可コードフローの各ステップとパラメータを定義する OAuth 2.0 の一次仕様。
- RFC 7636 — Proof Key for Code Exchange — PKCE の code_verifier / code_challenge の生成規則と、防いでいる攻撃を定義する一次仕様。
- OpenID Connect Core 1.0 — ID Token — ID Token の必須クレーム(iss/sub/aud/exp/iat)を定義する OIDC の一次仕様。