PKCE (Proof Key for Code Exchange)

PKCE ("pixie" diye okunur), OAuth 2.0 authorization code flow'una eklenen bir güvenlik katmanıdır. Authorization code interception saldırılarını engeller.


Neden PKCE Gerekli?

Problem: Authorization Code Interception

Klasik OAuth flow'da authorization code URL'de döner:

https://myapp.com/callback?code=SENSITIVE_CODE&state=xyz

Bu code yakalanabilir:

  • 🔴 Mobil cihazlarda custom URL scheme hijacking
  • 🔴 Browser history
  • 🔴 Referrer header
  • 🔴 Proxy logs

Saldırgan code'u ele geçirip kendi client_secret'ıyla token alabilir (confidential client'larda).

Çözüm: PKCE

PKCE, her authorization isteği için tek kullanımlık bir gizli anahtar oluşturur. Code intercept edilse bile, saldırgan bu anahtara sahip olmadığı için token alamaz.


PKCE Nasıl Çalışır?

┌──────────┐                                ┌──────────────┐
│  Client  │                                │  Atlantic ID │
└────┬─────┘                                └──────┬───────┘
     │                                             │
     │ 1. Generate Random Verifier                │
     │    verifier = random_43_128_chars()        │
     │                                             │
     │ 2. Create Challenge                        │
     │    challenge = base64url(sha256(verifier)) │
     │                                             │
     │ 3. Authorization Request                   │
     │    + code_challenge                        │
     │    + code_challenge_method=S256            │
     │ ────────────────────────────────────────────▶
     │                                             │
     │              Atlantic ID stores             │
     │              challenge for this code        │
     │                                             │
     │ 4. Returns authorization code              │
     │◀────────────────────────────────────────────│
     │                                             │
     │ 5. Token Request                           │
     │    + code                                  │
     │    + code_verifier (original random)       │
     │ ────────────────────────────────────────────▶
     │                                             │
     │    Atlantic ID verifies:                   │
     │    sha256(code_verifier) == stored_challenge
     │                                             │
     │ 6. Returns tokens (if verified)            │
     │◀────────────────────────────────────────────│

Implementation

Step 1: Generate Code Verifier

Gereksinimler:

  • 43-128 karakter arası
  • [A-Z], [a-z], [0-9], -, ., _, ~ karakterleri
  • Kriptografik olarak güvenli rastgele

JavaScript/TypeScript

function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

function base64UrlEncode(buffer) {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

const verifier = generateCodeVerifier();
// Örnek: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

Python

import secrets
import base64

def generate_code_verifier():
    code_verifier = base64.urlsafe_b64encode(
        secrets.token_bytes(32)
    ).decode('utf-8')
    return code_verifier.rstrip('=')

verifier = generate_code_verifier()

PHP

function generateCodeVerifier(): string {
    $randomBytes = random_bytes(32);
    return rtrim(strtr(base64_encode($randomBytes), '+/', '-_'), '=');
}

$verifier = generateCodeVerifier();

Java

import java.security.SecureRandom;
import java.util.Base64;

public static String generateCodeVerifier() {
    SecureRandom secureRandom = new SecureRandom();
    byte[] code = new byte[32];
    secureRandom.nextBytes(code);
    return Base64.getUrlEncoder()
        .withoutPadding()
        .encodeToString(code);
}

Go

import (
    "crypto/rand"
    "encoding/base64"
)

func generateCodeVerifier() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(b), nil
}

.NET/C

using System;
using System.Security.Cryptography;

public static string GenerateCodeVerifier()
{
    var bytes = new byte[32];
    using (var rng = RandomNumberGenerator.Create())
    {
        rng.GetBytes(bytes);
    }
    return Convert.ToBase64String(bytes)
        .TrimEnd('=')
        .Replace('+', '-')
        .Replace('/', '_');
}

Step 2: Generate Code Challenge

Code verifier'ın SHA256 hash'ini alın ve base64url encode edin.

JavaScript/TypeScript

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

const challenge = await generateCodeChallenge(verifier);
// Örnek: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

Python

import hashlib

def generate_code_challenge(verifier):
    digest = hashlib.sha256(verifier.encode('utf-8')).digest()
    challenge = base64.urlsafe_b64encode(digest).decode('utf-8')
    return challenge.rstrip('=')

challenge = generate_code_challenge(verifier)

PHP

function generateCodeChallenge(string $verifier): string {
    $hash = hash('sha256', $verifier, true);
    return rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
}

$challenge = generateCodeChallenge($verifier);

Java

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public static String generateCodeChallenge(String verifier) 
        throws NoSuchAlgorithmException {
    byte[] bytes = verifier.getBytes(StandardCharsets.US_ASCII);
    MessageDigest md = MessageDigest.getInstance("SHA-256");
    byte[] digest = md.digest(bytes);
    return Base64.getUrlEncoder()
        .withoutPadding()
        .encodeToString(digest);
}

Go

import (
    "crypto/sha256"
    "encoding/base64"
)

func generateCodeChallenge(verifier string) string {
    h := sha256.New()
    h.Write([]byte(verifier))
    return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}

.NET/C

using System.Security.Cryptography;
using System.Text;

public static string GenerateCodeChallenge(string verifier)
{
    using (var sha256 = SHA256.Create())
    {
        var hash = sha256.ComputeHash(
            Encoding.UTF8.GetBytes(verifier)
        );
        return Convert.ToBase64String(hash)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
    }
}

Step 3: Store Code Verifier

Authorization request yapmadan önce verifier'ı güvenli bir yere kaydedin:

Browser (Frontend)

// Session Storage (önerilen - tab kapanınca sil in ir)
sessionStorage.setItem('pkce_verifier', verifier);

// Local Storage (KULLANMAYIN - güvensiz)
// localStorage.setItem('pkce_verifier', verifier);

Backend (Server-Side)

// Express.js
req.session.pkceVerifier = verifier;

// PHP
$_SESSION['pkce_verifier'] = $verifier;

// Django
request.session['pkce_verifier'] = verifier

Mobile (Native Apps)

// iOS - Keychain
KeychainService.save(key: "pkce_verifier", value: verifier)

// Android - EncryptedSharedPreferences
EncryptedSharedPreferences.edit()
    .putString("pkce_verifier", verifier)
    .apply()

Step 4: Authorization Request

GET /oauth/authorize?
    client_id=cli_abc123&
    response_type=code&
    scope=openid%20profile%20email&
    redirect_uri=https://myapp.com/callback&
    state=xyz789&
    code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
    code_challenge_method=S256 HTTP/1.1
Host: id.codeatlantis.com

Parametreler:

  • code_challenge: Hesaplanan challenge değeri
  • code_challenge_method: S256 (SHA256) veya plain (önerilmez)

Step 5: Token Exchange with Verifier

Authorization code aldıktan sonra, token exchange'de orijinal code verifier'ı gönderin:

POST /oauth/token HTTP/1.1
Host: id.codeatlantis.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AQCxxx...xxx&
redirect_uri=https://myapp.com/callback&
client_id=cli_abc123&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Atlantic ID Doğrulama:

// Pseudo-code
stored_challenge = db.get_challenge(code)
calculated_challenge = base64url(sha256(code_verifier))

if (calculated_challenge !== stored_challenge) {
  return error('invalid_grant')
}

Code Challenge Methods

Atlantic ID iki method destekler:

S256 (Önerilen)

SHA256 hash kullanır:

challenge = base64url(sha256(verifier))

Avantajları:

  • ✅ Kriptografik olarak güvenli
  • ✅ Verifier'ı tersine çevirmek imkansız
  • ✅ OAuth 2.1'de zorunlu

Plain (Önerilmez)

Verifier'ı direkt gönderir:

challenge = verifier

Dezavantajları:

  • ❌ Challenge yakalanırsa verifier'da yakalanmış olur
  • ❌ Ek güvenlik sağlamaz
  • ❌ Sadece eski client'lar için backward compat

Uyarı: Production'da her zaman S256 kullanın!


PKCE Zorunluluğu

Atlantic ID'de PKCE kullanımı:

Public Clients

  • ZORUNLU
  • Client secret olmadığı için PKCE tek koruma
  • PKCE olmadan request reddedilir

Confidential Clients

  • 📝 ÖNERİLEN
  • Client secret zaten var ama PKCE ekstra koruma sağlar
  • Defense-in-depth prensibi

Güvenlik Faydaları

1. Authorization Code Interception Koruması

Code yakalanabilir ama verifier olmadan kullanılamaz:

Saldırgan:
  ✅ Authorization code'u ele geçirdi
  ❌ Code verifier'a erişemedi
  ❌ Token alamadı

2. Client Impersonation Koruması

Başka bir client'ın code'unu kullanamazsınız:

Meşru Client A:
  code_challenge_A = sha256(verifier_A)

Saldırgan Client B:
  Tries: code + verifier_B
  Result: sha256(verifier_B) ≠ code_challenge_A
  Error: invalid_grant

3. Replay Attack Koruması

Her verifier tek kullanımlık:

1st Request: code + verifier_1 → Success
2nd Request: code + verifier_1 → Error (code already used)

Common Mistakes

❌ Verifier'ı Tekrar Kullanma

// YANLIŞ
const STATIC_VERIFIER = "always_same_verifier";

Her authorization request için yeni verifier oluşturun!

❌ Verifier'ı URL'de Göndermek

// YANLIŞ
window.location.href = `/callback?code=${code}&verifier=${verifier}`;

Verifier asla URL'de olmamalı (query param, hash, vb.).

❌ Plain Method Kullanmak

// YANLIŞ
code_challenge_method: 'plain'

Her zaman S256 kullanın.

❌ Verifier'ı LocalStorage'da Saklamak

// YANLIŞ
localStorage.setItem('verifier', verifier);

XSS saldırılarına açık. sessionStorage veya backend session kullanın.


Testing PKCE

Online Tools

Generate PKCE Pair:

Manual Testing

# 1. Generate verifier
VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
echo "Verifier: $VERIFIER"

# 2. Generate challenge
CHALLENGE=$(echo -n $VERIFIER | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_')
echo "Challenge: $CHALLENGE"

# 3. Test authorization
curl "https://id.codeatlantis.com/oauth/authorize?\
client_id=cli_abc123&\
response_type=code&\
scope=openid&\
redirect_uri=http://localhost:3000/callback&\
code_challenge=$CHALLENGE&\
code_challenge_method=S256"

# 4. Exchange token
curl -X POST https://id.codeatlantis.com/oauth/token \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "client_id=cli_abc123" \
  -d "redirect_uri=http://localhost:3000/callback" \
  -d "code_verifier=$VERIFIER"

Troubleshooting

invalid_request - code_challenge required

Sorun: PKCE parametreleri eksik

Çözüm:

// Şunları eklediğinizden emin olun:
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');

invalid_grant - code_verifier verification failed

Sorun: Verifier eşleşmiyor

Nedenleri:

  • Challenge oluştururken farklı verifier kullanıldı
  • Verifier storage'dan yanlış alındı
  • Base64 encoding hatalı

Çözüm:

// Same verifier for challenge and exchange
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);

sessionStorage.setItem('pkce_verifier', verifier); // Save it!

// Later...
const savedVerifier = sessionStorage.getItem('pkce_verifier');
// Use savedVerifier in token exchange

Referanslar


İleri Okuma