PKCE (RFC 7636) 상세
logi는 S256만 수락합니다. plain은 거부됩니다 (boundary-safe 아님).
왜 PKCE가 필수인가
OAuth 2.0 Authorization Code는 네트워크/브라우저를 거쳐 제휴사 앱으로 돌아옵니다. 중간에서 code가 탈취되면:
- PKCE 없이: 공격자가 훔친 code + 훔친 client_secret으로 토큰 교환 가능
- PKCE 있음: 탈취해도
code_verifier(원본 난수, 서버에 전달된 적 없음)가 없어invalid_grant
모바일 앱/SPA는 client_secret을 안전하게 보관할 수 없으므로 PKCE가 더더욱 필수입니다.
생성 공식
verifier = 43 ~ 128자 URL-safe 랜덤 (unreserved 문자만)
challenge = BASE64URL-no-pad(SHA256(verifier))RFC 7636 Appendix B 검증 벡터
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"이 벡터는 logi RSpec에서도 사용합니다 (spec/lib/oauth/rfc7636_pkce_vectors_spec.rb).
언어별 구현
ts
async function generatePKCE() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(hash));
return { verifier, challenge };
}
function base64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}swift
import CryptoKit
struct PKCE {
let verifier: String
let challenge: String
static func generate() -> PKCE {
let random = (0..<32).map { _ in UInt8.random(in: 0...255) }
let verifier = Data(random).base64URL
let hash = SHA256.hash(data: Data(verifier.utf8))
let challenge = Data(hash).base64URL
return PKCE(verifier: verifier, challenge: challenge)
}
}
extension Data {
var base64URL: String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}ruby
require "securerandom"
require "digest"
require "base64"
verifier = SecureRandom.urlsafe_base64(32).delete("=")
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)python
import secrets, hashlib, base64
verifier = secrets.token_urlsafe(32).rstrip("=")
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()kotlin
import java.security.MessageDigest
import android.util.Base64
val verifier = (1..32).map { ('A'..'Z') + ('a'..'z') + ('0'..'9') + '-' + '_' }
.flatten().shuffled().take(43).joinToString("")
val sha = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
val challenge = Base64.encodeToString(sha, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)저장 위치
- Web:
sessionStorage(tab 단위) — 탭 닫으면 자동 소멸 - iOS: 메모리만 (플로우가 한 액터/화면 내에서 끝남) — Keychain 저장 불필요
- 서버 사이드 render SSR: 서명된 쿠키 또는 세션 store
verifier를 로그에 남기지 마세요
code 교환 직전까지만 필요한 값입니다. API 클라이언트 로거/에러 리포터에서 masking 확인하세요.
검증 실패 예시
bash
# 의도적으로 잘못된 verifier
curl -X POST $LOGI/oauth/token \
-d grant_type=authorization_code \
-d code=$CODE \
-d redirect_uri=$REDIRECT \
-d code_verifier="wrong" \
-d client_id=$ID -d client_secret=$SECRET
# 응답: 400
# {"error":"invalid_grant","error_description":"PKCE verifier mismatch"}