こんにちは!セキュリティの現場で日々泥臭くインシデントと戦っているホワイトハッカーの私ですが、今回は「OAuth 2.0認可コードフローにおけるPKCEの必須化と実装」について、初心者の方にもスッと腹落ちするように解説していきますね。
「PKCEってなんだか呪文みたいで難しそう……」
「OAuthとか認可コードとか、用語だけでお腹いっぱいだよ!」
そんな風に思っていませんか? 大丈夫です。一歩ずつ、私たちの身近な例えを交えながら紐解いていきましょう!
—
1. 家の合鍵泥棒に学ぶ「認可コード横取り攻撃」の仕組み
まずは、私たちが普段使っているWebサービスで、ログインや連携(「Googleでログイン」みたいなやつですアレです)の裏側で何が起きているのかをイメージしてみましょう。
従来の「お使い頼み」の仕組み
あなたが「スマホアプリ(またはSPAと呼ばれるブラウザアプリ)」から、とある便利なクラウドサービスにログインしたいとします。
1. あなたはアプリに「ちょっと私の代わりにあのサービスから鍵(トークン)をもんできて!」と頼みます。
2. アプリはブラウザを開き、認証サーバー(「はい、どうぞ」と通行手形をくれる場所)に行かせます。
3. あなたがログインに成功すると、認証サーバーはブラウザに対して「認可コード」という小さな引換券を渡します。
4. 本来なら、この引換券をアプリにこっそり渡して、アプリが「これと引き換えに本物の鍵をください」とサーバーにもらいに行くはずでした。
ここに潜む「泥棒」の影
もし、あなたのスマホやパソコンに悪意ある別のアプリ(偽物アプリ)が潜んでいたとしたらどうなるでしょうか?
この悪意あるアプリは、ブラウザが受け取った「認可コード(引換券)」をこっそり横取り(盗み見)できてしまうのです。
泥棒は、その盗んだ引換券を先に認証サーバーに持って行ってこう言います。
「ほら!さっきの引換券を持ってきたよ!僕に本物の鍵をちょうだい!」
認証サーバーは「お、引換券を持ってるから本人の代理だな」と勘違いし、泥棒に本物の鍵を渡してしまいます。これが「認可コード横取り攻撃」の正体です。
引換券さえあれば誰でも本物の鍵と交換できてしまう、これが従来の弱点だったんですね。
—
2. 秘密の合言葉「PKCE」という最強の防犯ロック
この泥棒の横取りを防ぐために登場したのが、PKCE(ピーシーと読みます。Proof Key for Code Exchangeの略です)です。
難しそうに聞こえますが、やっていることは非常にシンプル。要するに、「引換券を交換するときに、本人しか知らない『秘密の合い言葉』を一緒に提出する」というルールを追加しただけです。
泥棒が入れない理由
1. アプリは認証サーバーに行く前に、自分で勝手にランダムな文字列(これを code_verifier=「秘密の合い言葉」と呼びます)を作ります。
2. そして、その合い言葉をちょっと特殊な機械(ハッシュ関数 SHA-256)に通して、ぐちゃぐちゃに変換した「暗号の影(これを code_challenge=「合い言葉の影」と呼びます)」だけを先に認証サーバーに伝えます。
3. 認証サーバーは「なるほど、本人はこの『合い言葉の影』に対応する秘密の何かを持っているんだな」と覚えておきます。
4. さあ、ここで先ほどの泥棒が登場します。泥棒は運良く「認可コード(引換券)」を横取りすることに成功しました!
5. 泥棒は嬉々として認証サーバーに引換券を持ち込みますが、ここでサーバーがこう言います。
「お、引換券は合ってるね。じゃあ、それに対応する『秘密の合い言葉』も教えてよ」
6. 泥棒は合い言葉を知りません(アプリの奥底に隠されているからです)。答えられない泥棒は、ここで追い返されてゲームオーバーです!
本物のアプリだけが「秘密の合い言葉」を直接知っているので、最後にそれを提示して無事に本物の鍵をもらうことができる、というわけですね。
—
3. 【実装編】フロントエンドとサーバーの具体的な動き
それでは、実際にこのPKCEがどのようにコードとして書かれているのか、一般的な実装パターンを見てみましょう。今回はフロントエンド(JavaScript)から認証サーバーへリクエストを送るシーンを想定します。
ステップ1:秘密の合い言葉(Code Verifier)と影(Code Challenge)を作る
まずは、アプリ側でランダムな文字列を作り、それをハッシュ化して「影」を用意します。
// ランダムな秘密の文字列(Verifier)を生成する関数(例)
function generateCodeVerifier() {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
// Base64Urlエンコードして安全な文字列にする
return btoa(String.fromCharCode.apply(null, array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 秘密の文字列から「合い言葉の影(Challenge)」を作る関数
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
// SHA-256でハッシュ化
const digest = await window.crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 実際の利用フロー
const codeVerifier = generateCodeVerifier();
// この codeVerifier は、最後のトークン交換までブラウザの sessionStorage などにこっそり保存しておきます!
sessionStorage.setItem('code_verifier', codeVerifier);
// 影を作って認可リクエストに備える
const codeChallenge = await generateCodeChallenge(codeVerifier);
ステップ2:認証サーバーへ「合い言葉の影」と一緒に認可リクエストを送る
ブラウザを認証サーバーにリダイレクトさせる際、先ほど作った code_challenge と、それが何のアルゴリズムで作られたか(code_challenge_method=S256)をパラメータとして含めます。
// 認可サーバーのエンドポイント
const authEndpoint = 'https://auth.example.com/oauth/authorize';
const clientId = 'your_client_id_12345';
const redirectUri = 'https://myapp.com/callback';
// リクエストURLを組み立てる
const authUrl = `${authEndpoint}?response_type=code` +
`&client_id=${encodeURIComponent(clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&code_challenge=${encodeURIComponent(codeChallenge)}` + // ← ここがPKCEの影!
`&code_challenge_method=S256`; // ← SHA-256を使ったよという宣言
// 認証画面へユーザーを飛ばす
window.location.href = authUrl;
ステップ3:認可コードと「本物の合い言葉」をセットでサーバーに送って鍵をGETする
ユーザーが無事にログインを終えて、あなたのアプリ(redirect_uri)に戻ってきたとします。URLには code=xxxxxx (認可コード)が含まれています。
アプリはここで、横取りされたかもしれない「認可コード」と、自分がこっそり持っていた「秘密の合い言葉(code_verifier)」をセットにして、認証サーバーのトークンエンドポイントにPOSTリクエストを送ります。
// URLから認可コードを取り出す
const urlParams = new URLSearchParams(window.location.search);
const authorizationCode = urlParams.get('code');
// 先ほど保存しておいた「秘密の合い言葉」を引っ張り出す
const codeVerifier = sessionStorage.getItem('code_verifier');
// トークン交換リクエストのパラメータ
const tokenEndpoint = 'https://auth.example.com/oauth/token';
const bodyData = new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'your_client_id_12345',
redirect_uri: 'https://myapp.com/callback',
code: authorizationCode, // ゲットした引換券
code_verifier: codeVerifier // ★本物の秘密の合い言葉をここで初めて露出する!
});
// サーバーへ送信してアクセストークンを取得する
fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: bodyData
})
.then(response => response.json())
.then(data => {
console.log('アクセストークンゲット!:', data.access_token);
// もう用が済んだのでverifierは削除
sessionStorage.removeItem('code_verifier');
})
.catch(error => {
console.error('トークン交換失敗…', error);
});
—
まとめ:これからの開発における必須マナー
いかがでしょうか? PKCEと聞くとなんだか難解な暗号技術のように感じますが、やっている本質は「引換券(コード)だけでなく、自分だけが知っているパスワードの切れ端(Verifier)を最後に突き合わせる」という非常に泥臭くも確実な防犯対策です。
現代のOAuth 2.0セキュリティでは、シングルページアプリケーション(SPA)やモバイルアプリに限らず、ネイティブ・Webを問わずすべてのクライアントでPKCEを必須(Mandatory)とするのがデファクトスタンダード(業界標準)になっています。
「とりあえず動けばいいや」でコードを書いていると、思わぬところで泥棒に扉を開けてしまう原因になります。セキュリティは難しく考えず、「自分の家をどうやって守るか」という防犯の意識をコードに落とし込むだけです。
ぜひ、次のプロジェクトでOAuth 2.0を実装する際は、「あ、あそこのコードに code_challenge と code_verifier を入れなきゃな!」と思い出して、安全で強固なアプリケーションを作ってくださいね。応援しています!
コメント