PKCE
📍 このドキュメントの位置づけ
対象読者: PKCEの実装詳細を理解したい開発者
このドキュメントで学べること:
- PKCE (Proof Key for Code Exchange) の仕組み
- Code Verifier / Code Challenge の生成・検証
- plain / S256 メソッドの違い
- 認可コードフローでのPKCE検証実装
- モバイルアプリ・SPAでのPKCE適用パターン
前提知識:
- basic-08: 認可コードフローの理解
- OAuth 2.0 の基礎知識
🏗️ PKCEとは
PKCE (Proof Key for Code Exchange) は、認可コードフローにおける認可コード盗難攻撃を防ぐセキュリ ティ拡張です。
なぜPKCEが必要か
通常の認可コードフローの問題点:
1. 攻撃者がリダイレクトURI を傍受
→ 認可コード (code=xxx) を盗む
2. 攻撃者がトークンエンドポイントに認可コードを送信
→ アクセストークンを取得(Publicクライアントの場合)
PKCEによる防御:
1. クライアントが code_verifier を生成(ランダム文字列)
2. code_verifier から code_challenge を計算(SHA-256ハッシュ)
3. 認可リクエストに code_challenge を含める
4. 認可コードを取得
5. トークンリクエストに code_verifier を含める
6. サーバーが code_verifier を検証
→ SHA-256(code_verifier) == code_challenge ?
7. 一致した場合のみトークン発行
攻撃者は認可コードを盗んでも、code_verifierがないためトークンを取得できません。
📋 PKCE フロー
1. Code Verifier 生成
クライアントがランダムな文字列を生成します。
code_verifier = BASE64URL(RANDOM(32オクテット))
= "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
要件:
- 長 さ: 43〜128文字
- 文字種:
[A-Za-z0-9-._~]のみ - ランダム性: 暗号学的に安全な乱数生成器を使用
参考実装:
public class CodeVerifier {
String value;
public boolean exists() {
return Objects.nonNull(value) && !value.isEmpty();
}
public boolean isShorterThan43() {
return value.length() < 43;
}
public boolean isLongerThan128() {
return value.length() > 128;
}
}
参考実装: CodeVerifier.java:21
2. Code Challenge 生成
code_verifier から code_challenge を計算します。
S256 メソッド(推奨)
code_challenge = BASE64URL(SHA256(code_verifier))
実装:
public class CodeChallengeCalculator implements MessageDigestable, Base64Codeable {
CodeVerifier codeVerifier;
public CodeChallenge calculateWithS256() {
// 1. SHA-256 ハッシュ計算
byte[] bytes = digestWithSha256(codeVerifier.value());
// 2. Base64URL エンコード
String encodedValue = encodeWithUrlSafe(bytes);
return new CodeChallenge(encodedValue);
}
}
参考実装: CodeChallengeCalculator.java:24
plain メソッド(非推奨)
code_challenge = code_verifier
実装:
public CodeChallenge calculateWithPlain() {
return new CodeChallenge(codeVerifier.value());
}
⚠️ 注意: plain メソッドはセキュリティが低いため、S256の使用を強く推奨します。FAPI BaselineではS256が必須です。
3. 認可リクエスト
クライアントが code_challenge と code_challenge_method を認可リクエストに含めます。
GET /authorize?
response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https://client.example.com/cb
&scope=openid profile
&state=xyz
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
4. 認可コード発行
認可サーバーは、code_challenge と code_challenge_method を認可コードと紐付けて保存します。
// AuthorizationGrant に保存
AuthorizationCodeGrant grant = AuthorizationCodeGrant.builder()
.code(authorizationCode)
.codeChallenge(codeChallenge)
.codeChallengeMethod(codeChallengeMethod)
.build();
5. トークンリクエスト
クライアントが code_verifier をトークンリクエストに含めます。
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://client.example.com/cb
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
6. Code Verifier 検証
認可サーバーが code_verifier を検証します。
public class AuthorizationCodeGrantPkceVerifier
implements AuthorizationCodeGrantExtensionVerifierInterface {
@Override
public void verify(
TokenRequestContext tokenRequestContext,
AuthorizationRequest authorizationRequest,
AuthorizationCodeGrant authorizationCodeGrant,
ClientCredentials clientCredentials) {
// 1. code_verifier が含まれているか確認
throwExceptionIfNotContainsCodeVerifier(tokenRequestContext);
// 2. code_verifier が code_challenge と一致するか確認
throwExceptionIfUnMatchCodeVerifier(tokenRequestContext, authorizationRequest);
// 3. code_verifier のフォーマット検証
throwExceptionIfInvalidCodeVerifierFormat(tokenRequestContext);
}
}
参考実装: AuthorizationCodeGrantPkceVerifier.java:29
🔐 検証ロジック詳細
1. code_verifier 存在確認
void throwExceptionIfNotContainsCodeVerifier(TokenRequestContext tokenRequestContext) {
if (!tokenRequestContext.hasCodeVerifier()) {
throw new TokenBadRequestException(
"authorization request has code_challenge, but token request does not contain code_verifier");
}
}
エラー条件:
- 認可リクエストに
code_challengeがあるのに、トークンリクエストにcode_verifierがない
2. code_verifier 一致確認
void throwExceptionIfUnMatchCodeVerifier(
TokenRequestContext tokenRequestContext,
AuthorizationRequest authorizationRequest) {
// S256 メソッドの場合
if (authorizationRequest.isPkceWithS256()) {
CodeVerifier codeVerifier = tokenRequestContext.codeVerifier();
CodeChallengeCalculator calculator = new CodeChallengeCalculator(codeVerifier);
CodeChallenge calculatedChallenge = calculator.calculateWithS256();
if (!calculatedChallenge.equals(authorizationRequest.codeChallenge())) {
throw new TokenBadRequestException(
"code_verifier of token request does not match code_challenge of authorization request");
}
return;
}
// plain メソッドの場合
CodeChallengeCalculator calculator =
new CodeChallengeCalculator(tokenRequestContext.codeVerifier());
CodeChallenge calculatedChallenge = calculator.calculateWithPlain();
if (!calculatedChallenge.equals(authorizationRequest.codeChallenge())) {
throw new TokenBadRequestException(
"code_verifier of token request does not match code_challenge of authorization request");
}
}
検証フロー:
1. トークンリクエストから code_verifier 取得
2. code_challenge_method に応じて code_challenge を計算
- S256: BASE64URL(SHA256(code_verifier))
- plain: code_verifier
3. 計算した code_challenge と保存された code_challenge を比較
4. 一致しない場合はエラー
3. code_verifier フォーマット検証
void throwExceptionIfInvalidCodeVerifierFormat(TokenRequestContext tokenRequestContext) {
CodeVerifier codeVerifier = tokenRequestContext.codeVerifier();
// 長さチェック: 最低43文字
if (codeVerifier.isShorterThan43()) {
throw new TokenBadRequestException("code_verifier must be at least 43 characters");
}
// 長さチェック: 最大128文字
if (codeVerifier.isLongerThan128()) {
throw new TokenBadRequestException("code_verifier must be at most 128 characters");
}
// 文字種チェック: [A-Za-z0-9-._~] のみ
if (!codeVerifier.value().matches("^[A-Za-z0-9\\-._~]+$")) {
throw new TokenBadRequestException("code_verifier contains invalid characters");
}
}
RFC 7636 要件:
- 最小長: 43文字
- 最大長: 128文字
- 文字種:
[A-Za-z0-9-._~](unreserved characters)
📱 実装パターン
パターン1: モバイルアプリ(Native App)
特徴:
- Publicクライアント(client_secretなし)
- PKCEが必須
実装例(iOS/Swift):
// 1. Code Verifier 生成
func generateCodeVerifier() -> String {
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
return Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
.trimmingCharacters(in: .whitespaces)
}
// 2. Code Challenge 生成(S256)
func generateCodeChallenge(verifier: String) -> String {
guard let data = verifier.data(using: .utf8) else { return "" }
var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
}
return Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// 3. 認可リクエスト
let codeVerifier = generateCodeVerifier()
let codeChallenge = generateCodeChallenge(verifier: codeVerifier)
let authURL = "https://idp.example.com/authorize?" +
"response_type=code" +
"&client_id=mobile-app" +
"&redirect_uri=myapp://callback" +
"&scope=openid%20profile" +
"&code_challenge=\(codeChallenge)" +
"&code_challenge_method=S256"
// 4. トークンリクエスト(認可コード取得後)
let tokenParams = [
"grant_type": "authorization_code",
"code": authorizationCode,
"redirect_uri": "myapp://callback",
"client_id": "mobile-app",
"code_verifier": codeVerifier
]