İçeriğe geç
academia.sh

Ders 08 / 23

OpenID Connect

Yetki devri protokolünün üzerine kimlik katmanının eklenmesi: openid kapsamı ile üretilen kimlik belirteci, erişim belirtecinden hedef kitleyle ayrılması, keşif belgesi ve anahtar kümesi üzerinden asimetrik imza doğrulaması ve nonce ile yayıncı, hedef kitle, süre adımları.

İçindekiler

Önceki dersin akışlarının hiçbiri “bu kullanıcı kim?” sorusunu yanıtlamadı. Kaynak sunucusu belirteçteki sub alanını okudu ama o alanın hangi kullanıcıya karşılık geldiği, belirtecin gerçekten bir kimlik doğrulama sonucunda üretilip üretilmediği yetki devri protokolünün kapsamı dışındadır. Erişim belirteci “bu istek şu kaynaklara erişebilir” der; “şu kişi kimliğini az önce doğruladı” demez.

OpenID Connect, bu boşluğu yetki devri protokolünün üzerine bir kimlik katmanı ekleyerek doldurur. İstemci openid kapsamını istediğinde, belirteç yanıtına ikinci bir belirteç eklenir: kimlik belirteci (ID token). Bu belirteç kaynak sunucusuna değil, istemciye aittir ve kullanıcının kim olduğunu, ne zaman doğrulandığını bildirir.

İki Belirteç, İki Alıcı

Ayrım aud iddiasında görünür. Erişim belirtecinin hedef kitlesi kaynak sunucusudur; kimlik belirtecinin hedef kitlesi istemcinin kendisidir. Bundan iki kural çıkar: istemci kimlik belirtecini API’ye göndermez, kaynak sunucusu da kimlik belirtecini kabul etmez. İkisi karıştırıldığında, bir servis için üretilmiş belirteç başka bir servise kanıt olarak sunulmuş olur.

Kimlik belirteci ayrıca doğrulanmak üzere üretilir, taşınmak üzere değil. İstemci onu bir kez doğrular, içindeki bilgiyi kendi oturumuna yazar ve belirteci saklamak zorunda kalmaz.

Sağlayıcı

Aşağıdaki sağlayıcı, önceki dersin yetki kodu akışını kimlik katmanıyla genişletir: openid kapsamı ve nonce parametresi zorunludur, imza asimetriktir ve açık anahtarlar bir anahtar kümesi (JSON Web Key Set) ucundan yayımlanır.

// saglayici.mjs — kimlik katmani: kesif belgesi, anahtar kumesi, kimlik belirteci, kullanici ucu
// Kullanim: node saglayici.mjs [port]     (ontanimli 8561)
import { createServer } from "node:http";
import { generateKeyPairSync, createHash, randomBytes, sign, verify, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8561);
const YAYINCI = `http://127.0.0.1:${PORT}`;
const ISTEMCI = { kimlik: "okuma-uygulamasi", yonlendirme: "http://127.0.0.1:8563/geri" };
const KULLANICI = { sub: "U-1001", ad: "Deniz Aral", eposta: "[email protected]", sube: "merkez" };

// Iki anahtar: yururlukteki ve emekliye ayrilmis. Ikisi de anahtar kumesinde durur.
const anahtarYap = (kid) => ({ kid, ...generateKeyPairSync("rsa", { modulusLength: 2048 }) });
const ANAHTARLAR = [anahtarYap("2026-a"), anahtarYap("2025-b")];
const YURURLUKTE = ANAHTARLAR[0];

const KODLAR = new Map();
const b64 = (v) => Buffer.from(v).toString("base64url");
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const esitMi = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

const jwsUret = (anahtar, iddialar) => {
  const veri = `${b64(JSON.stringify({ alg: "RS256", typ: "JWT", kid: anahtar.kid }))}.${b64(JSON.stringify(iddialar))}`;
  return `${veri}.${sign("sha256", Buffer.from(veri), anahtar.privateKey).toString("base64url")}`;
};

const jwsDogrula = (belirtec) => {
  const [b, g, i] = (belirtec ?? "").split(".");
  if (!b || !g || !i) return null;
  const baslik = JSON.parse(Buffer.from(b, "base64url").toString("utf8"));
  const anahtar = ANAHTARLAR.find((a) => a.kid === baslik.kid);
  if (!anahtar || baslik.alg !== "RS256") return null;
  if (!verify("sha256", Buffer.from(`${b}.${g}`), anahtar.publicKey, Buffer.from(i, "base64url"))) return null;
  const iddialar = JSON.parse(Buffer.from(g, "base64url").toString("utf8"));
  return Math.floor(Date.now() / 1000) < iddialar.exp ? iddialar : null;
};

const formOku = (istek) => new Promise((coz) => {
  let v = ""; istek.on("data", (p) => (v += p));
  istek.on("end", () => coz(Object.fromEntries(new URLSearchParams(v))));
});

createServer(async (istek, yanit) => {
  yanit.sendDate = false;
  const adres = new URL(istek.url, YAYINCI), s = adres.searchParams;
  const json = (kod, govde) => {
    yanit.writeHead(kod, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
    yanit.end(JSON.stringify(govde));
  };

  if (adres.pathname === "/.well-known/openid-configuration") {
    return json(200, {
      issuer: YAYINCI, authorization_endpoint: `${YAYINCI}/yetkilendir`,
      token_endpoint: `${YAYINCI}/belirtec`, userinfo_endpoint: `${YAYINCI}/kullanici`,
      jwks_uri: `${YAYINCI}/anahtarlar`, id_token_signing_alg_values_supported: ["RS256"],
      scopes_supported: ["openid", "profile", "odunc:oku"], response_types_supported: ["code"],
    });
  }

  if (adres.pathname === "/anahtarlar") {
    return json(200, {
      keys: ANAHTARLAR.map((a) => ({
        ...a.publicKey.export({ format: "jwk" }), kid: a.kid, alg: "RS256", use: "sig",
      })),
    });
  }

  if (istek.method === "GET" && adres.pathname === "/yetkilendir") {
    if (s.get("client_id") !== ISTEMCI.kimlik || s.get("redirect_uri") !== ISTEMCI.yonlendirme) {
      return json(400, { error: "invalid_request" });
    }
    const kapsam = (s.get("scope") ?? "").split(" ");
    // Kimlik katmani yalnizca "openid" kapsami istendiginde devreye girer.
    if (!kapsam.includes("openid")) return json(400, { error: "invalid_scope", detay: "openid kapsami yok" });
    if (!s.get("state") || !s.get("nonce") || !s.get("code_challenge")) return json(400, { error: "invalid_request" });

    const kod = randomBytes(24).toString("base64url");
    KODLAR.set(kod, { meydan: s.get("code_challenge"), nonce: s.get("nonce"), kapsam, kullanildi: false });
    const u = new URL(ISTEMCI.yonlendirme);
    u.searchParams.set("code", kod); u.searchParams.set("state", s.get("state"));
    yanit.writeHead(302, { location: u.toString() });
    return yanit.end();
  }

  if (istek.method === "POST" && adres.pathname === "/belirtec") {
    const g = await formOku(istek);
    const k = KODLAR.get(g.code ?? "");
    if (!k || k.kullanildi) return json(400, { error: "invalid_grant" });
    if (!g.code_verifier || !esitMi(s256(g.code_verifier), k.meydan)) return json(400, { error: "invalid_grant" });
    k.kullanildi = true;

    const simdi = Math.floor(Date.now() / 1000);
    // Erisim belirteci kaynak sunucusu icin; kimlik belirteci istemci icin uretilir.
    const erisim = jwsUret(YURURLUKTE, { iss: YAYINCI, sub: KULLANICI.sub, aud: "odunc-servisi",
      scope: k.kapsam.join(" "), exp: simdi + 600, iat: simdi });
    const kimlik = jwsUret(YURURLUKTE, { iss: YAYINCI, sub: KULLANICI.sub, aud: ISTEMCI.kimlik,
      nonce: k.nonce, auth_time: simdi, exp: simdi + 600, iat: simdi,
      name: KULLANICI.ad, email: KULLANICI.eposta });
    return json(200, { access_token: erisim, id_token: kimlik, token_type: "Bearer", expires_in: 600 });
  }

  if (adres.pathname === "/kullanici") {
    // Kullanici ucu erisim belirteci ister; kimlik belirteci burada gecerli degildir.
    const b = istek.headers.authorization ?? "";
    if (!b.startsWith("Bearer ")) return json(401, { error: "invalid_token" });
    const iddialar = jwsDogrula(b.slice(7));
    if (!iddialar) return json(401, { error: "invalid_token" });
    if (iddialar.aud !== "odunc-servisi") return json(401, { error: "invalid_token", detay: "hedef kitle bu uc degil" });
    return json(200, KULLANICI);
  }
  json(404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => console.log(`saglayici ${YAYINCI}`));

Anahtar kümesi iki anahtar yayımlar: yürürlükte olan ve emekliye ayrılmış olan. Anahtar değişimi bu yüzden kesintisiz yapılabilir — yeni anahtar kümeye eklenir, imzalama ona geçirilir, eski anahtar yürürlükteki belirteçlerin ömrü bitene kadar kümede kalır ve sonra çıkarılır. Başlıktaki kid alanı doğrulayana hangi anahtarı seçeceğini söyler.

node saglayici.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1
curl -s http://127.0.0.1:8561/anahtarlar | sed 's/"n":"[^"]\{20\}[^"]*"/"n":"…"/g'
echo
kill $S
{"keys":[{"kty":"RSA","n":"…","e":"AQAB","kid":"2026-a","alg":"RS256","use":"sig"},{"kty":"RSA","n":"…","e":"AQAB","kid":"2025-b","alg":"RS256","use":"sig"}]}

Kümede yalnız açık anahtarlar vardır (n kısaltılmıştır). Doğrulayan taraf özel anahtarı hiç görmez; belirteç üretme gücü sağlayıcıda kalır. JSON web belirteçleri dersinde paylaşılan sırla imzalamanın yarattığı ölçek sorunu böyle çözülür.

İstemci ve Doğrulama Adımları

İstemci hiçbir adresi kendi içine yazmaz; keşif belgesinden okur. Belirteci aldıktan sonra doğrulama, sırayla yanıtlanan sorulardan oluşur.

// istemci.mjs — kesif belgesini okur, akisi yurutur, kimlik belirtecini adim adim dogrular
// Kullanim: node saglayici.mjs 8561 &  calisirken  node istemci.mjs
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";

const SAGLAYICI = "http://127.0.0.1:8561";
const ISTEMCI_KIMLIGI = "okuma-uygulamasi";
const YONLENDIRME = "http://127.0.0.1:8563/geri";
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const coz = (p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8"));

// 1) Kesif belgesi: uc adresleri ve desteklenen algoritmalar buradan okunur.
const kesif = await (await fetch(`${SAGLAYICI}/.well-known/openid-configuration`)).json();
const anahtarKumesi = await (await fetch(kesif.jwks_uri)).json();
console.log("yayıncı           :", kesif.issuer);
console.log("imza algoritmaları:", kesif.id_token_signing_alg_values_supported.join(", "));
console.log("anahtar kümesi    :", anahtarKumesi.keys.map((a) => `${a.kid} (${a.kty})`).join(", "));

// 2) Akis: istemci state, nonce ve PKCE dogrulayicisini kendi uretir ve saklar.
const state = randomBytes(9).toString("base64url");
const nonce = randomBytes(9).toString("base64url");
const dogrulayici = randomBytes(32).toString("base64url");
const u = new URL(kesif.authorization_endpoint);
for (const [ad, deger] of Object.entries({ response_type: "code", client_id: ISTEMCI_KIMLIGI,
  redirect_uri: YONLENDIRME, scope: "openid profile odunc:oku", state, nonce,
  code_challenge: s256(dogrulayici), code_challenge_method: "S256" })) u.searchParams.set(ad, deger);

const yon = await fetch(u, { redirect: "manual" });
const geri = new URL(yon.headers.get("location"));
if (geri.searchParams.get("state") !== state) throw new Error("state eşleşmedi");

const belirtecler = await (await fetch(kesif.token_endpoint, {
  method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ grant_type: "authorization_code", code: geri.searchParams.get("code"),
    redirect_uri: YONLENDIRME, client_id: ISTEMCI_KIMLIGI, code_verifier: dogrulayici }),
})).json();

// 3) Kimlik belirtecinin dogrulanmasi. Her adim ayri bir soruya karsilik gelir.
const kimlikDogrula = (belirtec, { beklenenNonce } = {}) => {
  const [b, g, i] = (belirtec ?? "").split(".");
  if (!b || !g || !i) return "biçim";
  const baslik = coz(b);
  if (!kesif.id_token_signing_alg_values_supported.includes(baslik.alg)) return "algoritma";
  const jwk = anahtarKumesi.keys.find((a) => a.kid === baslik.kid && a.alg === baslik.alg);
  if (!jwk) return "anahtar tanınmıyor";
  const acikAnahtar = createPublicKey({ key: jwk, format: "jwk" });
  if (!verify("sha256", Buffer.from(`${b}.${g}`), acikAnahtar, Buffer.from(i, "base64url"))) return "imza";

  const c = coz(g), simdi = Math.floor(Date.now() / 1000);
  if (c.iss !== kesif.issuer) return "yayıncı";
  const kitle = Array.isArray(c.aud) ? c.aud : [c.aud];
  if (!kitle.includes(ISTEMCI_KIMLIGI)) return `hedef kitle (${c.aud})`;
  if (simdi >= c.exp) return "süre";
  if (beklenenNonce !== undefined && c.nonce !== beklenenNonce) return "nonce";
  return { sub: c.sub, ad: c.name, kimlikDogrulamaAni: c.auth_time };
};

const yaz = (ad, sonuc) => console.log(ad.padEnd(30), typeof sonuc === "string" ? `ret (${sonuc})` : `kabul → ${JSON.stringify(sonuc)}`);
yaz("kimlik belirteci", kimlikDogrula(belirtecler.id_token, { beklenenNonce: nonce }));
yaz("beklenen nonce başka", kimlikDogrula(belirtecler.id_token, { beklenenNonce: "baska-deger" }));
yaz("erişim belirteci sunulunca", kimlikDogrula(belirtecler.access_token, { beklenenNonce: nonce }));

const [b, g, i] = belirtecler.id_token.split(".");
const degisikGovde = Buffer.from(JSON.stringify({ ...coz(g), sub: "Y-0002" })).toString("base64url");
yaz("gövdesi değiştirilmiş", kimlikDogrula(`${b}.${degisikGovde}.${i}`, { beklenenNonce: nonce }));
const baskaKid = Buffer.from(JSON.stringify({ ...coz(b), kid: "1999-z" })).toString("base64url");
yaz("tanınmayan anahtar kimliği", kimlikDogrula(`${baskaKid}.${g}.${i}`, { beklenenNonce: nonce }));

// 4) Kullanici ucu erisim belirteci ister.
const ucaSor = async (belirtec) => {
  const c = await fetch(kesif.userinfo_endpoint, { headers: { authorization: `Bearer ${belirtec}` } });
  return `${c.status} ${JSON.stringify(await c.json())}`;
};
console.log("kullanıcı ucu / erişim belirteci:", await ucaSor(belirtecler.access_token));
console.log("kullanıcı ucu / kimlik belirteci:", await ucaSor(belirtecler.id_token));
node saglayici.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1.2
node istemci.mjs
kill $S
yayıncı           : http://127.0.0.1:8561
imza algoritmaları: RS256
anahtar kümesi    : 2026-a (RSA), 2025-b (RSA)
kimlik belirteci               kabul → {"sub":"U-1001","ad":"Deniz Aral","kimlikDogrulamaAni":1785273591}
beklenen nonce başka           ret (nonce)
erişim belirteci sunulunca     ret (hedef kitle (odunc-servisi))
gövdesi değiştirilmiş          ret (imza)
tanınmayan anahtar kimliği     ret (anahtar tanınmıyor)
kullanıcı ucu / erişim belirteci: 200 {"sub":"U-1001","ad":"Deniz Aral","eposta":"[email protected]","sube":"merkez"}
kullanıcı ucu / kimlik belirteci: 401 {"error":"invalid_token","detay":"hedef kitle bu uc degil"}

Zaman damgası her çalıştırmada değişir. Beş satır beş ayrı adımı doğruluyor.

Algoritma, keşif belgesinin bildirdiği listeyle sınırlanır ve belirtecin başlığından alınmaz. JSON web belirteçleri dersinde kurulan kural burada da geçerlidir: kabul edilecek algoritmalar doğrulayıcının yapılandırmasından gelir.

Anahtar seçimi, kid ile kümedeki anahtarlar arasında yapılır. Kümede karşılığı olmayan bir kid, doğrulamayı başlamadan durdurur. Doğrulayıcının, tanımadığı bir kid için kümeyi yeniden çekmesi olağandır; ancak bu yenileme sınırlanmalıdır, aksi hâlde her tanınmayan değer bir dış çağrı doğurur.

Hedef kitle, kimlik belirteci ile erişim belirtecini ayıran adımdır. Üçüncü satırda erişim belirteci kimlik belirteci gibi sunuluyor ve aud alanı odunc-servisi olduğu için reddediliyor. Son satır aynı ayrımın ters yönünü gösteriyor: kimlik belirteci kullanıcı ucuna sunulduğunda 401 alıyor. İki belirteç aynı yayıncıdan, aynı anahtarla imzalanmış olmasına karşın birbirinin yerine geçmiyor; ayrımı yapan tek alan aud iddiasıdır.

Nonce, kimlik belirtecini istemcinin bu oturumuna bağlar. İstemci değeri üretir, akışla birlikte gönderir ve dönen belirteçte aynısını arar. Eşleşmeyen bir belirteç, başka bir akışa aitse tanınmaz. state isteğin istemciye ait olduğunu, nonce belirtecin bu isteğe ait olduğunu doğrular; ikisi ayrı sorulardır ve ikisi de gereklidir.

Kullanıcı ucu (UserInfo endpoint), kimlik belirtecinde bulunmayan ek bilgiyi erişim belirteciyle verir. Şube bilgisi bu uçta dönüyor ama kimlik belirtecinde yok; belirteci büyütmek yerine bilgiyi ayrı bir çağrıya bırakmak, her istekte taşınan başlığı küçük tutar.

Kimlik Belirtecinin Sınırı

Kimlik belirteci geçmişe dair bir bildirimdir: auth_time anında bu kullanıcının kimliği doğrulandı. Sürekli bir yetki değildir. İstemci belirteci doğruladıktan sonra kendi oturumunu açar ve o oturumu kendi kurallarıyla yönetir — oturum tabanlı kimlik doğrulama dersindeki bütün kararlar burada da geçerlidir, oturum kimliğinin girişte yenilenmesi dâhil.

Bundan çıkan kural şudur: kimlik belirteci her isteğe iliştirilmez, kaynak sunucusuna gönderilmez ve uzun süre saklanmaz. Bir kez doğrulanır, karşılığı yerel oturuma yazılır ve işi biter.

Özet

  • Yetki devri protokolü erişim yetkisini devreder; kimlik katmanı buna kullanıcının kim olduğu bilgisini ekler ve openid kapsamıyla devreye girer.
  • Kimlik belirtecinin hedef kitlesi istemci, erişim belirtecinin hedef kitlesi kaynak sunucusudur; ikisi aynı anahtarla imzalansa bile birbirinin yerine geçmez.
  • Keşif belgesi uç adreslerini ve kabul edilen imza algoritmalarını bildirir; anahtar kümesi açık anahtarları kid ile yayımlar ve anahtar değişimini kesintisiz kılar.
  • Doğrulama sırayla algoritma, anahtar seçimi, imza, yayıncı, hedef kitle, süre ve nonce adımlarından geçer; her adım ayrı bir soruyu yanıtlar.
  • state isteğin istemciye, nonce belirtecin o isteğe ait olduğunu doğrular; ikisi birbirinin yerine kullanılamaz.
  • Kimlik belirteci geçmişteki bir doğrulamanın bildirimidir; bir kez doğrulanır, karşılığı yerel oturuma yazılır ve her isteğe iliştirilmez.

Sonraki Adım

Bu kurulumda kimlik sağlayıcı ile ödünç servisi aynı kurumun parçasıydı: sağlayıcı üye kaydını biliyor, sub alanı doğrudan üye koduna karşılık geliyordu. İlçe kütüphaneleri ortak bir ödünç sistemine bağlandığında bu varsayım düşer — her kurumun kendi kullanıcı dizini, kendi kimlik doğrulama kuralları ve kendi kullanıcı kimlikleri vardır. Sonraki ders bu düzeni kurar: bir kurumun doğruladığı kimliğin, imzalı bir iddia belgesiyle başka bir kuruma taşınması, bu belgenin geçerlilik penceresi ve hedef kitlesiyle denetlenmesi, aynı belgenin ikinci kez kullanılmasının engellenmesi ve dışarıdan gelen özniteliklerin yerel role çevrilmesi.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat